diff --git a/audit/2026-07-13/window.xml b/audit/2026-07-13/window.xml new file mode 100644 index 0000000..244b825 --- /dev/null +++ b/audit/2026-07-13/window.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/01-current.xml b/audit/2026-07-14/01-current.xml new file mode 100644 index 0000000..b93abe9 --- /dev/null +++ b/audit/2026-07-14/01-current.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/02-drawer.xml b/audit/2026-07-14/02-drawer.xml new file mode 100644 index 0000000..d5ea71d --- /dev/null +++ b/audit/2026-07-14/02-drawer.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/03-health-archive.xml b/audit/2026-07-14/03-health-archive.xml new file mode 100644 index 0000000..7a9ed28 --- /dev/null +++ b/audit/2026-07-14/03-health-archive.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/04-reports.xml b/audit/2026-07-14/04-reports.xml new file mode 100644 index 0000000..eb982d2 --- /dev/null +++ b/audit/2026-07-14/04-reports.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/05-medications.xml b/audit/2026-07-14/05-medications.xml new file mode 100644 index 0000000..26ba4fb --- /dev/null +++ b/audit/2026-07-14/05-medications.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/06-diet-records.xml b/audit/2026-07-14/06-diet-records.xml new file mode 100644 index 0000000..8d148dd --- /dev/null +++ b/audit/2026-07-14/06-diet-records.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/07-calendar.xml b/audit/2026-07-14/07-calendar.xml new file mode 100644 index 0000000..527ac75 --- /dev/null +++ b/audit/2026-07-14/07-calendar.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/08-followups.xml b/audit/2026-07-14/08-followups.xml new file mode 100644 index 0000000..b149a14 --- /dev/null +++ b/audit/2026-07-14/08-followups.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/09-exercise.xml b/audit/2026-07-14/09-exercise.xml new file mode 100644 index 0000000..54cb5ac --- /dev/null +++ b/audit/2026-07-14/09-exercise.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/10-devices.xml b/audit/2026-07-14/10-devices.xml new file mode 100644 index 0000000..c6b2085 --- /dev/null +++ b/audit/2026-07-14/10-devices.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/11-notifications.xml b/audit/2026-07-14/11-notifications.xml new file mode 100644 index 0000000..bad3035 --- /dev/null +++ b/audit/2026-07-14/11-notifications.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/12-settings.xml b/audit/2026-07-14/12-settings.xml new file mode 100644 index 0000000..2d59171 --- /dev/null +++ b/audit/2026-07-14/12-settings.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/13-profile.xml b/audit/2026-07-14/13-profile.xml new file mode 100644 index 0000000..3f4bb2f --- /dev/null +++ b/audit/2026-07-14/13-profile.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/audit/2026-07-14/project-audit-report.md b/audit/2026-07-14/project-audit-report.md new file mode 100644 index 0000000..97982ec --- /dev/null +++ b/audit/2026-07-14/project-audit-report.md @@ -0,0 +1,261 @@ +# 小脉健康项目全面检查报告 + +检查日期:2026-07-14 +检查范围:Flutter Android 真机主要用户路径、Flutter 静态检查与测试、.NET 后端测试、前后端接口对应关系、用户/医生/管理员业务逻辑、权限安全、合规页面与 Android/iOS 发布准备。 +本轮只检查,没有修改业务代码,也没有使用 Docker。 + +## 一、总体结论 + +项目主体功能已经比较完整,普通用户主路径能进入,当前 Flutter 静态检查通过,Flutter 47 项测试和后端 44 项测试全部通过。但测试通过不等于已经稳定:本次仍确认了多项自动测试没有覆盖的真实断点。 + +当前不适合进入正式环境或商店审核。最优先的不是继续统一小间距,而是先处理问诊鉴权、管理员验证码、上传文件访问、蓝牙上传、账号注销清理和正式包权限。这些问题会影响真实数据、安全或用户是否能完成任务。 + +## 二、必须先修的问题 + +### 1. AI 流式聊天的 token 只被“读出来”,没有验证真伪 + +- 用户表现:正常用户不一定马上发现,但攻击者可以自己伪造一个 token,冒充任意用户进入 AI 对话、读取该用户上下文,并可能触发健康数据写入。 +- 原因:SSE 接口没有强制登录;备用 token 处理只解析 JWT 内容,没有校验签名、过期时间、签发方和接收方。 +- 位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` 的 `GetUserIdFromToken`。 +- 优先级:最高。 + +### 2. 医生问诊实时通道没有登录和问诊归属校验 + +- 用户表现:知道问诊编号的人可以加入房间,甚至把自己声明成“用户”或“医生”发送消息;消息还会真实写入数据库。 +- 原因:SignalR Hub 没有鉴权,加入房间和发送消息时也没有核对当前账号是否属于该患者或医生。 +- 位置:`backend/src/Health.WebApi/Hubs/ConsultationHub.cs`。 +- 优先级:最高。 + +### 3. 管理员存在固定手机号和固定验证码 + +- 用户表现:正式环境若沿用当前代码,知道固定号码的人可用 `000000` 登录管理员端。 +- 原因:管理员手机号和验证码直接写在认证服务中,而且不只在开发环境生效。 +- 位置:`backend/src/Health.Infrastructure/Auth/AuthService.cs`。 +- 优先级:最高。 + +### 4. 上传的报告、聊天图片和 PDF 可被未登录访问 + +- 用户表现:文件链接一旦泄露,不登录也能打开健康报告或聊天附件。 +- 原因:整个 `/uploads` 目录作为公开静态目录提供,没有按用户、医生关系或临时授权校验。 +- 位置:`backend/src/Health.WebApi/Program.cs` 的上传目录静态映射。 +- 优先级:最高。 + +### 5. 蓝牙血压计能读到数据,但自动上传会失败 + +- 用户表现:设备连接成功、血压已经读取,最后却提示上传失败;血压和心率不会真正保存到后端。 +- 原因:前端提交到 `/api/health-records/batch`,当前后端没有这个接口。 +- 位置:`health_app/lib/pages/device/device_management_page.dart` 与 `backend/src/Health.WebApi/Endpoints/health_endpoints.cs`。 +- 优先级:高。 + +### 6. 每次打开医生问诊页都会创建一条新问诊并消耗次数 + +- 用户表现:只是进入页面看一下、没有发送任何内容,也会新增问诊;反复进出会快速消耗每月次数。显示剩余 0 次时仍可能继续创建,甚至出现负数。 +- 原因:前端初始化先查次数,然后无条件创建;后端创建接口没有真正执行三次上限,也没有验证医生仍然有效。 +- 位置:`health_app/lib/providers/consultation_provider.dart`、`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`。 +- 优先级:高。 + +### 7. 问诊消息发送失败时仍显示为已发送 + +- 用户表现:网络断开或服务器失败后,消息仍留在聊天里,没有失败标记和重试入口,用户会误以为医生已经收到。 +- 原因:发送异常被静默忽略,本地消息没有回滚或转成失败状态。 +- 位置:`health_app/lib/providers/consultation_provider.dart` 的 `sendMessage`。 +- 优先级:高,且不符合“不能假成功”的项目要求。 + +### 8. Android 正式包没有网络权限 + +- 用户表现:Debug 真机可联网,但以后构建 release 包后,所有登录、数据、AI 和报告请求都无法联网。 +- 原因:`INTERNET` 只在 debug/profile Manifest 中,main Manifest 没有;release 不会合并 debug 配置。 +- 位置:`health_app/android/app/src/main/AndroidManifest.xml`。 +- 优先级:正式包前必须修。 + +### 9. 账号注销只删数据库记录,不会清理服务器上传文件 + +- 用户表现:App 显示账号已删除,但报告原图、聊天图片、PDF 等仍可能留在服务器目录;公开链接仍可能继续访问。 +- 原因:账号注销批量删除数据库数据时没有收集并删除物理文件。单独删除报告有删文件,注销账号没有走同一流程。 +- 位置:`backend/src/Health.Infrastructure/Users/EfUserRepository.cs`。 +- 优先级:高,涉及合规承诺和真实删除。 + +### 10. iOS 相机和相册权限说明缺失 + +- 用户表现:以后在 iPhone 上拍饮食、拍报告或选相册时,系统可能直接终止 App,而不是正常弹出权限框。 +- 原因:Info.plist 有蓝牙说明,但没有 `NSCameraUsageDescription` 和 `NSPhotoLibraryUsageDescription`。 +- 位置:`health_app/ios/Runner/Info.plist`。 +- 优先级:iOS 调试前必须修。 + +## 三、重要功能和稳定性问题 + +### 11. 多个请求同时遇到登录过期时,可能刚刷新成功又被强制退出 + +多个 401 会同时拿同一个刷新 token。第一个请求刷新并保存新 token 后,第二个请求可能因旧 token 已失效而失败,随后把刚保存的新 token 一并清掉。用户会偶发跳回登录页。 + +位置:`health_app/lib/core/api_client.dart` 的 401 刷新逻辑。 + +### 12. 启动时只要服务器暂时连不上,就把用户当成未登录 + +刷新 token 的请求遇到断网或本地后端未启动时,认证状态直接变为未登录,但本地 token 又没有清除。用户会看到登录页,网络恢复后也不会自动重试,只能重启或重新操作。 + +位置:`health_app/lib/providers/auth_provider.dart` 的 `_checkAuth`。 + +### 13. 问诊轮询用随机 GUID 判断“之后的消息”,可能漏消息 + +SignalR 失败后会回退轮询。后端把随机消息 ID 与上一条随机 ID 做大小比较,这不等于消息时间顺序,可能漏掉医生新回复。正确游标应基于服务器时间或稳定递增字段。 + +位置:`backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs`。 + +### 14. 问诊 AI 开场白只存在前端,没有保存 + +用户看到的“AI 分身问候”不是数据库消息,医生端看不到;重新进入又会新建问诊并生成另一条本地问候,历史不连续。 + +位置:`health_app/lib/providers/consultation_provider.dart`。 + +### 15. 短信验证码没有频率、次数和防刷限制 + +发送接口可反复调用,验证码校验也没有失败次数限制。正式短信接入后会产生短信轰炸、费用和验证码暴力尝试风险。 + +位置:认证端点和 `AuthService`。 + +### 16. 管理员新增、停用、修改、删除医生时,几套医生数据可能不同步 + +- 手机号已属于普通用户时,仍可创建医生实体,但不会把已有用户升级并绑定医生档案。 +- 修改医生手机号/姓名时,没有同步医生登录账号与医生档案。 +- 停用医生只改一处状态,医生档案的状态可能仍是启用。 +- 删除医生后,医生登录用户仍可能保留 Doctor 角色,但档案已失去关联,登录后只能看到“档案未关联”。 +- 医生手机号没有唯一约束,可能出现重复医生。 + +位置:`backend/src/Health.Infrastructure/Admin/AdminService.cs`。 + +### 17. 附件本地路径没有限制在 uploads 目录内 + +附件 URL 中的相对路径直接拼到服务器路径,没有做规范化后再确认最终路径仍位于 uploads 内,也没有核对文件是否属于当前用户。这会带来跨用户读取或路径越界风险。 + +位置:`backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs`。 + +### 18. 通知点击先联网标记已读,失败时目的页面也打不开 + +用户点一条未读通知时,先请求后端标记已读;如果这一步失败,后续报告/用药/健康页面不会打开,也没有明确提示。 + +位置:`health_app/lib/pages/notifications/notification_center_page.dart`。 + +### 19. 用户端与医生端部分“今天/本月”按 UTC 计算 + +中国时区的凌晨时段可能被算到上一天或上个月,影响问诊次数、今日随访和提醒归类。正式环境应统一明确业务时区。 + +位置:问诊和医生工作台中使用 `DateTime.UtcNow.Date` 或 UTC 月份的逻辑。 + +## 四、UI 与交互问题 + +### 20. 用药和运动列表默认露出整片红色删除背景 + +真机截图中,用药行和运动行在没有滑动时已经整行变红;运动行还出现“打卡”和“删除”重叠。用户会误以为数据异常或正处于危险删除状态。 + +原因是通用滑动删除组件始终铺红色底层,而这些列表行本身透明。测试只验证滑动动作,没有验证未滑动时的最终视觉。 + +证据:`05-medications.png`、`09-exercise.png`。 +位置:`health_app/lib/widgets/common_widgets.dart`。 + +### 21. 首页多个纯图标按钮缺少无障碍名称 + +真机可访问性结构中,菜单、附件、输入框和发送按钮等多个可点击区域没有名称。普通用户能凭图标猜,读屏用户无法知道用途。 + +证据:`01-current.xml`。 + +### 22. 删除动作风格不统一 + +报告、用药、运动使用滑动删除,通知使用整行侧滑,蓝牙设备直接显示红色垃圾桶,设置页则用大按钮。交互成本和误触风险不同。建议统一为“列表行滑动后显示删除 + 二次确认用于高价值数据”。 + +### 23. 设置页的红色占比过重 + +“删除账号”和“退出登录”两个大面积危险操作占据页面最强视觉层级,反而压过消息、隐私和服务协议。删除账号保持红色是合理的,但退出登录不必使用整块高饱和红色。 + +证据:`12-settings.png`。 + +### 24. 侧边栏健康仪表盘过于抢眼 + +大面积蓝紫粉渐变和四个小框成为最强区域,与项目“克制、专业、模块色只做识别”的偏好有偏差;指标小字和透明边框的对比度也偏弱。 + +证据:`02-drawer.png`。 + +### 25. 空状态表达不统一 + +复查随访使用居中图标和说明;饮食使用一条白色容器;其他页面可能只留空白。建议统一空状态的标题、原因和下一步操作,但不需要每页再套一张大卡片。 + +证据:`06-diet-records.png`、`08-followups.png`。 + +### 26. 部分图标按钮只有约 40dp + +例如蓝牙页顶部新增按钮使用固定 40×40,低于常见的 48dp 触控建议;对手部不便或视力较弱的健康类用户不够友好。 + +### 27. 大字体、横屏、小屏和读屏尚无验证证据 + +许多页面使用固定高度、固定行布局和多个并排信息块。当前只在一台 1440×3200 Android 真机、默认字体比例下检查,不能据此确认大字体和 iOS 横屏不会溢出。 + +## 五、合规与发布准备问题 + +### 28. 合规文档仍有主体和联系方式占位符 + +隐私政策、服务协议、个人信息清单和 SDK 清单里仍有 `xxx`、待定短信服务商等内容,不能用于正式审核。 + +### 29. App 内文档与 H5 文档存在多套日期和内容 + +App 内隐私/协议日期为 6 月 29 日,个人信息与 SDK 清单为 7 月 9 日;H5 清单日期为 7 月 7 日。以后修改一处很容易漏另一处。建议确定单一内容源或至少增加一致性测试。 + +### 30. “关于我们”写了并不存在的意见反馈入口 + +文档提示“设置 → 意见反馈”,实际设置页没有该入口;版本还写成 `Build 20260101`,与当前 `1.0.0+1` 不一致。 + +### 31. 用户同意协议没有服务端记录 + +当前“同意”只是登录页内存中的一个布尔值,未保存同意时间、协议版本或政策更新后的重新同意记录。服务端接口也不要求同意状态,无法证明用户同意了哪一版条款。 + +### 32. Android 权限声明偏多且缺少版本限制 + +当前声明了蓝牙广播权限,但 App 主要是扫描/连接;旧版存储和蓝牙权限也没有按 Android 版本加 `maxSdkVersion`。正式个人信息清单应与实际最小权限保持一致。 + +### 33. 本地 token 使用普通 SQLite 明文保存 + +符合项目“不用 shared_preferences”的约定,但 access token 和 refresh token 没有系统级加密保护;同时 Android 也未显式关闭备份。正式版本需要决定是否改用系统安全存储或加密数据库,并限制备份。 + +### 34. iOS 名称和方向仍是模板状态 + +Info.plist 的显示名称还是 `Health App`,并允许 iPhone 横屏;当前页面主要按竖屏设计,尚未验证横屏。Bundle ID、签名和证书则仍按项目计划待配置。 + +### 35. CORS 当前允许任意来源并携带凭据 + +本地调试方便,但正式环境应只允许确定的 H5/管理端域名,不能继续接受任意 Origin。 + +## 六、已确认正常或已有保护的部分 + +- 当前 Flutter `analyze` 无错误。 +- Flutter 47 项测试全部通过。 +- 后端 44 项测试全部通过。 +- 报告、用药、饮食、运动、健康数据等主要删除接口都按当前用户筛选,不是只删前端显示。 +- 单独删除报告时会同时删除报告原文件。 +- AI 历史对话恢复后仍保留原 conversationId,可以继续发送新消息;历史确认卡只读是合理保护。 +- 管理员和医生普通 HTTP 端点已有角色过滤,问题主要集中在固定管理员验证码、医生数据同步和未鉴权 SignalR Hub。 +- 设置、通知、报告列表已经较接近“一行一行、文字区分隔线”的目标风格。 +- 隐私政策、服务协议、个人信息清单、SDK 清单和关于我们均已有 App 内入口与 H5 文件。 + +## 七、真机流程健康度 + +1. 首页与 AI 对话:可进入;视觉基本完整,但纯图标无障碍名称不足,AI SSE 鉴权有严重后端风险。 +2. 侧边栏:可打开;功能入口齐全,仪表盘色彩和层级偏重。 +3. 健康档案:可加载和编辑;固定保存区需要继续验证大字体遮挡。 +4. 报告管理:列表正常;文件公开访问是严重隐私风险。 +5. 用药管理:数据能加载;列表红底显示错误明显。 +6. 饮食记录:日期、空状态和趋势能显示;空状态样式与其他页不统一。 +7. 健康日历:日期和计划能显示;业务时区需要后端统一。 +8. 复查随访:空状态清楚;本次账号没有数据,未实测详情。 +9. 运动计划:数据能加载;红底和操作文字重叠明显。 +10. 蓝牙设备:绑定设备能显示;自动读取后的批量上传链路断开。 +11. 通知中心:历史和未读数能显示;未读通知在网络失败时可能打不开目的页。 +12. 设置与合规入口:入口齐全;危险操作权重过高,文档仍有占位和不一致。 +13. 个人信息:真机运行版本可进入;检查期间源代码刚加入新的基本资料编辑入口,当前运行包尚未热更新到这一版。 +14. 医生端:已完成代码与接口核对;没有医生测试账号,本次未做完整真机流程。 +15. 管理员端:已完成代码与接口核对;没有实际执行新增/停用/删除,避免改变真实数据。 +16. iOS:仅做配置与代码检查;没有证书、Mac 构建和 iPhone 实测,不能确认界面与权限流程。 + +## 八、证据限制 + +- 真机截图来自本次检查时设备上正在运行的调试包。检查过程中项目源代码仍在被更新,后加入的个人资料编辑和设备错误文案没有重新构建到该运行包,因此相关结论以当前代码为准、旧界面截图只用于对应未变化页面。 +- 没有执行真实删除、账号注销、医生管理、短信发送或蓝牙测量,避免改变现有数据;这些部分通过前后端代码和接口对应关系确认。 +- 没有正式域名、HTTPS、短信服务商、对象存储、iOS 签名和商店账号,因此只检查了当前准备缺口。 diff --git a/audit/ui-pages/window.xml b/audit/ui-pages/window.xml new file mode 100644 index 0000000..4a5cbfa --- /dev/null +++ b/audit/ui-pages/window.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/Health.Application/AI/AiConversationContracts.cs b/backend/src/Health.Application/AI/AiConversationContracts.cs index 9b158cf..bcb8068 100644 --- a/backend/src/Health.Application/AI/AiConversationContracts.cs +++ b/backend/src/Health.Application/AI/AiConversationContracts.cs @@ -35,7 +35,7 @@ public interface IAiConversationService Task> ListAsync(Guid userId, CancellationToken ct); Task> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct); Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct); - Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct); Task DeleteAllAsync(Guid userId, CancellationToken ct); } diff --git a/backend/src/Health.Application/AI/AiConversationService.cs b/backend/src/Health.Application/AI/AiConversationService.cs index 5327f55..c20fa60 100644 --- a/backend/src/Health.Application/AI/AiConversationService.cs +++ b/backend/src/Health.Application/AI/AiConversationService.cs @@ -5,7 +5,7 @@ namespace Health.Application.AI; public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService { - private const int HistoryConversationLimit = 7; + private const int HistoryConversationLimit = 30; private readonly IAiConversationRepository _conversations = conversations; public async Task OpenAsync( @@ -83,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation await _conversations.SaveChangesAsync(ct); } - public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct) + public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct) { var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct); - if (conversation == null) return; + if (conversation == null) return false; _conversations.Delete(conversation); await _conversations.SaveChangesAsync(ct); + return true; } public async Task DeleteAllAsync(Guid userId, CancellationToken ct) diff --git a/backend/src/Health.Application/AI/PatientContextService.cs b/backend/src/Health.Application/AI/PatientContextService.cs index ee7de85..1234725 100644 --- a/backend/src/Health.Application/AI/PatientContextService.cs +++ b/backend/src/Health.Application/AI/PatientContextService.cs @@ -24,7 +24,9 @@ public sealed class PatientContextService( if (archive != null) { if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); - if (archive.Surgeries.Count > 0) + if (archive.SurgeryHistoryStatus == "无") + sb.AppendLine("手术史: 无"); + else if (archive.Surgeries.Count > 0) sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}"); else if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})"); diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs index f6d809a..784b277 100644 --- a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs @@ -22,6 +22,7 @@ public sealed record HealthArchiveDto( IReadOnlyList DietRestrictions, IReadOnlyList ChronicDiseases, string? FamilyHistory, + string? SurgeryHistoryStatus, DateTime UpdatedAt); public sealed record HealthArchiveUpdateRequest( @@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest( IReadOnlyList? DietRestrictions, IReadOnlyList? ChronicDiseases, string? FamilyHistory, - IReadOnlyList? Surgeries = null); + IReadOnlyList? Surgeries = null, + string? SurgeryHistoryStatus = null); public interface IHealthArchiveService { diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs index d5811dc..10a6c29 100644 --- a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs @@ -1,5 +1,7 @@ using Health.Domain.Entities; +using System.ComponentModel.DataAnnotations; + namespace Health.Application.HealthArchives; public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService @@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH public async Task UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct) { + Validate(request); var archive = await GetOrCreateEntityAsync(userId, ct); Apply(archive, request); archive.UpdatedAt = DateTime.UtcNow; @@ -110,6 +113,14 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList(); if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList(); if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory; + if (request.SurgeryHistoryStatus != null) + archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus; + } + + private static void Validate(HealthArchiveUpdateRequest request) + { + if (request.SurgeryHistoryStatus is not null and not ("无" or "有")) + throw new ValidationException("手术史状态格式不正确"); } private static object ToAiResult(HealthArchiveDto archive) => new @@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, + archive.SurgeryHistoryStatus, }; private static HealthArchiveDto ToDto(HealthArchive archive) => new( @@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, + archive.SurgeryHistoryStatus, archive.UpdatedAt); private static IReadOnlyList GetSurgeries(HealthArchive archive) diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs b/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs index aac71a9..0984858 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs @@ -27,6 +27,7 @@ public interface IHealthRecordService { Task> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct); Task CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct); + Task> CreateManyAsync(Guid userId, IReadOnlyList requests, CancellationToken ct); Task UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct); Task DeleteAsync(Guid userId, Guid id, CancellationToken ct); Task> GetLatestAsync(Guid userId, CancellationToken ct); diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs index 9a92ea1..2ea2d00 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs @@ -49,6 +49,44 @@ public sealed class HealthRecordService( return record.Id; } + public async Task> CreateManyAsync( + Guid userId, + IReadOnlyList requests, + CancellationToken ct) + { + if (requests.Count == 0) + throw new ArgumentException("至少需要一条健康记录", nameof(requests)); + + foreach (var request in requests) + HealthRecordRules.Validate(request); + + var now = DateTime.UtcNow; + var records = requests.Select(request => new HealthRecord + { + Id = Guid.NewGuid(), + UserId = userId, + MetricType = request.Type, + Systolic = request.Systolic, + Diastolic = request.Diastolic, + Value = request.Value, + Unit = request.Unit, + Source = request.Source, + RecordedAt = request.RecordedAt ?? now, + CreatedAt = now, + IsAbnormal = HealthRecordRules.CheckAbnormal(request), + }).ToList(); + + foreach (var record in records) + await _records.AddAsync(record, ct); + + await _records.SaveChangesAsync(ct); + + foreach (var record in records) + await EnqueueAbnormalNotificationAsync(record, ct); + + return records.Select(record => record.Id).ToList(); + } + public async Task UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct) { var record = await _records.GetOwnedAsync(userId, id, ct); diff --git a/backend/src/Health.Application/Medications/MedicationContracts.cs b/backend/src/Health.Application/Medications/MedicationContracts.cs index b1944bf..ab4751b 100644 --- a/backend/src/Health.Application/Medications/MedicationContracts.cs +++ b/backend/src/Health.Application/Medications/MedicationContracts.cs @@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest( IReadOnlyList? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, - string? Notes); + string? Notes, + bool ClearEndDate = false); public sealed record MedicationDto( Guid Id, diff --git a/backend/src/Health.Application/Medications/MedicationScheduleStatus.cs b/backend/src/Health.Application/Medications/MedicationScheduleStatus.cs new file mode 100644 index 0000000..913d812 --- /dev/null +++ b/backend/src/Health.Application/Medications/MedicationScheduleStatus.cs @@ -0,0 +1,18 @@ +using Health.Domain.Enums; + +namespace Health.Application.Medications; + +public static class MedicationScheduleStatus +{ + public static string Resolve( + TimeOnly scheduledTime, + TimeOnly currentTime, + MedicationLogStatus? logStatus) + { + if (logStatus.HasValue) + return logStatus == MedicationLogStatus.Taken ? "taken" : "skipped"; + if (scheduledTime <= currentTime.AddMinutes(-30)) return "overdue"; + if (scheduledTime <= currentTime.AddHours(3)) return "upcoming"; + return "scheduled"; + } +} diff --git a/backend/src/Health.Application/Medications/MedicationService.cs b/backend/src/Health.Application/Medications/MedicationService.cs index 4e94b07..1380fd5 100644 --- a/backend/src/Health.Application/Medications/MedicationService.cs +++ b/backend/src/Health.Application/Medications/MedicationService.cs @@ -54,7 +54,8 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value; if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay); if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value; - if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value; + if (request.ClearEndDate) med.EndDate = null; + else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value; ValidateDateRange(med.StartDate, med.EndDate); if (request.Notes != null) med.Notes = request.Notes; med.UpdatedAt = DateTime.UtcNow; @@ -130,15 +131,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi foreach (var scheduledTime in med.TimeOfDay) { var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime); - string status; - if (log != null) - status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped"; - else if (scheduledTime <= now.AddMinutes(-30)) - status = "overdue"; - else if (scheduledTime <= now.AddHours(3)) - status = "upcoming"; - else - continue; + var status = MedicationScheduleStatus.Resolve( + scheduledTime, + now, + log?.Status); reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status)); } diff --git a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs index 2cb4114..3e02db8 100644 --- a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs +++ b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs @@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor Task RecoverStaleAsync(CancellationToken ct); Task ProcessNextAsync(CancellationToken ct); } + +public sealed record ReminderCatchUpResult(int CreatedCount); + +public interface IReminderCatchUpService +{ + Task CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs index d5b357d..1c39510 100644 --- a/backend/src/Health.Application/Users/UserContracts.cs +++ b/backend/src/Health.Application/Users/UserContracts.cs @@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest( string? Gender, DateOnly? BirthDate); +public sealed record AccountFileReferences( + IReadOnlyList FileUrls, + IReadOnlyList ConversationMetadataJson); + public interface IUserService { Task GetProfileAsync(Guid userId, CancellationToken ct); @@ -26,6 +30,12 @@ public interface IUserService public interface IUserRepository { Task GetAsync(Guid userId, CancellationToken ct); + Task GetAccountFileReferencesAsync(Guid userId, CancellationToken ct); Task SaveChangesAsync(CancellationToken ct); Task DeleteAccountDataAsync(Guid userId, CancellationToken ct); } + +public interface IAccountFileCleanup +{ + Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Users/UserService.cs b/backend/src/Health.Application/Users/UserService.cs index 6b9da04..8a0968b 100644 --- a/backend/src/Health.Application/Users/UserService.cs +++ b/backend/src/Health.Application/Users/UserService.cs @@ -1,8 +1,11 @@ namespace Health.Application.Users; -public sealed class UserService(IUserRepository users) : IUserService +public sealed class UserService( + IUserRepository users, + IAccountFileCleanup fileCleanup) : IUserService { private readonly IUserRepository _users = users; + private readonly IAccountFileCleanup _fileCleanup = fileCleanup; public async Task GetProfileAsync(Guid userId, CancellationToken ct) { @@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService return true; } - public Task DeleteAccountAsync(Guid userId, CancellationToken ct) => - _users.DeleteAccountDataAsync(userId, ct); + public async Task DeleteAccountAsync(Guid userId, CancellationToken ct) + { + var references = await _users.GetAccountFileReferencesAsync(userId, ct); + await _fileCleanup.DeleteAsync(userId, references, ct); + await _users.DeleteAccountDataAsync(userId, ct); + } } diff --git a/backend/src/Health.Domain/Entities/support_entities.cs b/backend/src/Health.Domain/Entities/support_entities.cs index 45abfac..dfac21c 100644 --- a/backend/src/Health.Domain/Entities/support_entities.cs +++ b/backend/src/Health.Domain/Entities/support_entities.cs @@ -35,6 +35,7 @@ public sealed class HealthArchive public List DietRestrictions { get; set; } = []; // 饮食限制 public List ChronicDiseases { get; set; } = []; // 慢病史 public string? FamilyHistory { get; set; } // 家族病史 + public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史 public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public User User { get; set; } = null!; 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 54f7d99..cc3c868 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -43,7 +43,7 @@ public static class MedicationAgentHandler return toolName switch { "manage_medication" => await ExecuteManageMedication(medications, userId, args, ct), - _ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" } + _ => new { success = false, message = $"未知工具: {toolName}" } }; } diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index b510c1c..5fef6f5 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -157,9 +157,9 @@ public sealed class PromptManager """; private const string UnifiedPrompt = """ - 你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。 + 你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。 - 核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。 + 核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。 领域判断与可用工具: 1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data @@ -182,10 +182,18 @@ public sealed class PromptManager 规则: - 用户可能一句话涉及多个领域,全部处理 - - 数值明确+指标明确→直接生成待确认命令,不要追问 + - 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入 + - 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录 + - 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令 + - 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录 + - 指标或数值缺失时只追问缺失部分,不猜测数值 + - 多个明确指标要逐项调用 record_health_data,不能遗漏 - 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库 - 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认 + - 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片 + - 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊 + - 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒 - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 - - 回复语气温暖、专业 + - 回复语气温和、清楚、专业,避免假装医生或作确定诊断 """; } diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs new file mode 100644 index 0000000..e3213dc --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs @@ -0,0 +1,1477 @@ +// +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("20260713132830_AddHealthArchiveSurgeryStatus")] + partial class AddHealthArchiveSurgeryStatus + { + /// + 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("SurgeryHistoryStatus") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + 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/20260713132830_AddHealthArchiveSurgeryStatus.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.cs new file mode 100644 index 0000000..5de3ea1 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + /// + public partial class AddHealthArchiveSurgeryStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SurgeryHistoryStatus", + table: "HealthArchives", + type: "character varying(8)", + maxLength: 8, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SurgeryHistoryStatus", + table: "HealthArchives"); + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 9e6f222..0b7cb30 100644 --- a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations b.Property("SurgeryDate") .HasColumnType("date"); + b.Property("SurgeryHistoryStatus") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + b.Property("SurgeryType") .HasColumnType("text"); diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 37bc5fa..aac842a 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -170,6 +170,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon builder.Entity(e => { e.HasIndex(a => a.UserId).IsUnique(); + e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8); }); builder.Entity(e => diff --git a/backend/src/Health.Infrastructure/Notifications/EfReminderCatchUpService.cs b/backend/src/Health.Infrastructure/Notifications/EfReminderCatchUpService.cs new file mode 100644 index 0000000..dfff152 --- /dev/null +++ b/backend/src/Health.Infrastructure/Notifications/EfReminderCatchUpService.cs @@ -0,0 +1,284 @@ +using Health.Application.Notifications; +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Infrastructure.Notifications; + +public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService +{ + private readonly AppDbContext _db = db; + + public async Task CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct) + { + var pref = await GetPreferenceAsync(userId, ct); + if (!pref.PushEnabled) return new ReminderCatchUpResult(0); + + var beijingNow = utcNow.AddHours(8); + var today = DateOnly.FromDateTime(beijingNow); + var todayStartUtc = beijingNow.Date.AddHours(-8); + var todayEndUtc = todayStartUtc.AddDays(1); + var created = 0; + + created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct); + created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct); + created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct); + created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct); + + if (created > 0) + await _db.SaveChangesAsync(ct); + + return new ReminderCatchUpResult(created); + } + + private async Task GetPreferenceAsync(Guid userId, CancellationToken ct) + { + var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct); + if (pref != null) return pref; + + pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId }; + await _db.NotificationPreferences.AddAsync(pref, ct); + return pref; + } + + private async Task CreateHealthRecordReminderAsync( + Guid userId, + NotificationPreference pref, + DateTime beijingNow, + DateTime todayStartUtc, + DateTime todayEndUtc, + CancellationToken ct) + { + if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0; + var window = HealthRecordWindow(beijingNow); + if (window == null) return 0; + + var recordedTypes = await _db.HealthRecords.AsNoTracking() + .Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc) + .Select(r => r.MetricType) + .Distinct() + .ToListAsync(ct); + + var missing = EnabledHealthMetrics(pref) + .Where(x => !recordedTypes.Contains(x.Type)) + .ToList(); + if (missing.Count == 0) return 0; + + var date = DateOnly.FromDateTime(beijingNow); + var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}"); + return await AddNotificationIfMissingAsync( + userId, + sourceId, + "health_record_reminder", + window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标", + $"建议录入:{string.Join("、", missing.Select(x => x.Label))}", + "info", + "health", + null, + ct); + } + + private async Task CreateMedicationRemindersAsync( + Guid userId, + NotificationPreference pref, + DateTime beijingNow, + DateOnly today, + DateTime todayStartUtc, + DateTime todayEndUtc, + CancellationToken ct) + { + if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0; + var now = TimeOnly.FromDateTime(beijingNow); + var earliest = now.AddHours(-4); + var medications = await _db.Medications.AsNoTracking() + .Where(m => m.UserId == userId && m.IsActive) + .ToListAsync(ct); + var created = 0; + + foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today))) + { + foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now))) + { + var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l => + l.UserId == userId && + l.MedicationId == medication.Id && + l.ScheduledTime == scheduledTime && + l.CreatedAt >= todayStartUtc && + l.CreatedAt < todayEndUtc, ct); + if (logged) continue; + + var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}"); + var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") } + .Where(x => !string.IsNullOrWhiteSpace(x))); + created += await AddNotificationIfMissingAsync( + userId, + sourceId, + "MedicationReminder", + "用药时间到了", + $"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。", + "info", + "medication", + medication.Id.ToString(), + ct); + } + } + + return created; + } + + private async Task CreateExerciseRemindersAsync( + Guid userId, + NotificationPreference pref, + DateTime beijingNow, + DateOnly today, + CancellationToken ct) + { + if (IsInDnd(pref, beijingNow)) return 0; + var now = TimeOnly.FromDateTime(beijingNow); + var dueItems = await _db.ExercisePlanItems.AsNoTracking() + .Include(x => x.Plan) + .Where(x => x.Plan.UserId == userId && + x.ScheduledDate == today && + !x.IsCompleted && + !x.IsRestDay && + x.Plan.ReminderTime <= now) + .ToListAsync(ct); + var created = 0; + + foreach (var item in dueItems) + { + var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}"); + created += await AddNotificationIfMissingAsync( + userId, + sourceId, + "ExerciseReminder", + "今日运动计划", + $"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。", + "info", + "exercise", + item.PlanId.ToString(), + ct); + } + + return created; + } + + private async Task CreateFollowUpRemindersAsync( + Guid userId, + NotificationPreference pref, + DateTime beijingNow, + CancellationToken ct) + { + if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0; + var nowUtc = beijingNow.AddHours(-8); + var upcomingEndUtc = nowUtc.AddHours(24); + var followUps = await _db.FollowUps.AsNoTracking() + .Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc) + .ToListAsync(ct); + var created = 0; + + foreach (var followUp in followUps) + { + var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h"); + created += await AddNotificationIfMissingAsync( + userId, + sourceId, + "follow_up_reminder", + "复查提醒", + $"{followUp.Title} 即将到期,请提前安排时间。", + "info", + "followup", + followUp.Id.ToString(), + ct); + } + + return created; + } + + private async Task AddNotificationIfMissingAsync( + Guid userId, + Guid sourceId, + string type, + string title, + string message, + string severity, + string? actionType, + string? actionTargetId, + CancellationToken ct) + { + if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct)) + return 0; + + await _db.UserNotifications.AddAsync(new UserNotification + { + Id = Guid.NewGuid(), + UserId = userId, + SourceId = sourceId, + Type = type, + Title = title, + Message = message, + Severity = severity, + ActionType = actionType, + ActionTargetId = actionTargetId, + CreatedAt = DateTime.UtcNow, + }, ct); + return 1; + } + + private static string? HealthRecordWindow(DateTime beijingNow) + { + var minutes = beijingNow.Hour * 60 + beijingNow.Minute; + if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning"; + if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon"; + if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening"; + return null; + } + + private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref) + { + var result = new List<(HealthMetricType, string)>(); + if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压")); + if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率")); + if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖")); + if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧")); + if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重")); + return result; + } + + private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow) + { + if (!pref.DndEnabled) return false; + var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute; + return pref.DndStartMinutes <= pref.DndEndMinutes + ? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes + : minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes; + } + + private static bool IsMedicationActiveOn(Medication medication, DateOnly date) => + (medication.StartDate == null || medication.StartDate <= date) && + (medication.EndDate == null || medication.EndDate >= date); + + private static bool ShouldDoseToday(Medication medication, DateOnly today) + { + if (medication.Frequency is MedicationFrequency.Daily + or MedicationFrequency.TwiceDaily + or MedicationFrequency.ThreeTimesDaily + or MedicationFrequency.AsNeeded) + return true; + + var startDate = medication.StartDate ?? today; + if (medication.Frequency == MedicationFrequency.EveryOtherDay) + return (today.DayNumber - startDate.DayNumber) % 2 == 0; + if (medication.Frequency == MedicationFrequency.Weekly) + return today.DayOfWeek == startDate.DayOfWeek; + return true; + } + + private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) => + end >= start ? value >= start && value <= end : value >= start || value <= end; + + private static Guid StableGuid(string key) + { + var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key)); + return new Guid(bytes); + } +} diff --git a/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs b/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs index 86b5099..ed9c577 100644 --- a/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs +++ b/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs @@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat NotificationMessage message, CancellationToken ct) { + var pushEnabled = await _db.NotificationPreferences.AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => (bool?)x.PushEnabled) + .FirstOrDefaultAsync(ct); + if (pushEnabled is false) return false; + if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct)) return false; @@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.Status, "Failed") - .SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数") + .SetProperty(x => x.LastError, "Notification task timed out and reached max retry count") .SetProperty(x => x.UpdatedAt, now), ct); } @@ -81,61 +87,55 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati .FirstOrDefaultAsync(ct); if (candidate == null) return false; - var claimed = await _db.NotificationOutbox - .Where(x => x.Id == candidate.Id && x.Status == "Pending") - .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.Status, "Processing") - .SetProperty(x => x.Attempts, x => x.Attempts + 1) - .SetProperty(x => x.UpdatedAt, now), ct); - if (claimed == 0) return true; + var task = await _db.NotificationOutbox + .FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct); + if (task == null) return true; + + task.Status = "Processing"; + task.Attempts += 1; + task.UpdatedAt = now; + await _db.SaveChangesAsync(ct); try { - var message = JsonSerializer.Deserialize(candidate.Payload) - ?? throw new InvalidOperationException("通知消息内容为空"); + var message = JsonSerializer.Deserialize(task.Payload) + ?? throw new InvalidOperationException("Notification payload is empty"); var preference = await _db.NotificationPreferences.AsNoTracking() - .FirstOrDefaultAsync(x => x.UserId == candidate.UserId, ct); + .FirstOrDefaultAsync(x => x.UserId == task.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)) + .AnyAsync(x => x.SourceId == task.SourceTaskId, ct)) { await _db.UserNotifications.AddAsync(new UserNotification { Id = Guid.NewGuid(), - UserId = candidate.UserId, - SourceId = candidate.SourceTaskId, + UserId = task.UserId, + SourceId = task.SourceTaskId, Type = message.Type, Title = message.Title, Message = message.Message, Severity = message.Severity, ActionType = message.ActionType, ActionTargetId = message.ActionTargetId, - CreatedAt = candidate.CreatedAt, + CreatedAt = task.CreatedAt, }, ct); - // Persist the inbox item first. If completion fails, stale-task - // recovery can safely retry because SourceId is idempotent. await _db.SaveChangesAsync(ct); } - await _db.NotificationOutbox.Where(x => x.Id == candidate.Id) - .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.Status, "Completed") - .SetProperty(x => x.LastError, (string?)null) - .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + task.Status = "Completed"; + task.LastError = null; + task.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); } catch (Exception ex) { - var attempts = candidate.Attempts + 1; - await _db.NotificationOutbox.Where(x => x.Id == candidate.Id) - .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending") - .SetProperty(x => x.AvailableAt, - DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5)) - .SetProperty(x => x.LastError, - ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message) - .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending"; + task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5); + task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; + task.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); } return true; @@ -151,14 +151,25 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati : minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes); if (inDnd) return false; - return type switch + return NormalizeType(type) switch { "medication_reminder" => preference.MedicationReminder, + "exercise_reminder" => true, "follow_up_reminder" => preference.FollowUpReminder, "doctor_reply" => preference.DoctorReply, "abnormal_alert" => preference.AbnormalAlert, "health_record_reminder" => preference.HealthRecordReminder, + "health_metric_alert" => preference.AbnormalAlert, _ => true, }; } + + private static string NormalizeType(string type) => type switch + { + "MedicationReminder" => "medication_reminder", + "ExerciseReminder" => "exercise_reminder", + "HealthMetricAlert" => "health_metric_alert", + "FollowUpReminder" => "follow_up_reminder", + _ => type.ToLowerInvariant(), + }; } diff --git a/backend/src/Health.Infrastructure/Users/EfUserRepository.cs b/backend/src/Health.Infrastructure/Users/EfUserRepository.cs index 50119f8..33f524e 100644 --- a/backend/src/Health.Infrastructure/Users/EfUserRepository.cs +++ b/backend/src/Health.Infrastructure/Users/EfUserRepository.cs @@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository public Task GetAsync(Guid userId, CancellationToken ct) => _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + public async Task GetAccountFileReferencesAsync(Guid userId, CancellationToken ct) + { + var fileUrls = new List(); + + var userAvatar = await _db.Users + .Where(u => u.Id == userId) + .Select(u => u.AvatarUrl) + .FirstOrDefaultAsync(ct); + AddIfPresent(fileUrls, userAvatar); + + var doctorProfile = await _db.DoctorProfiles + .Where(p => p.UserId == userId) + .Select(p => new { p.DoctorId, p.AvatarUrl }) + .FirstOrDefaultAsync(ct); + AddIfPresent(fileUrls, doctorProfile?.AvatarUrl); + + if (doctorProfile?.DoctorId != null) + { + var doctorAvatar = await _db.Doctors + .Where(d => d.Id == doctorProfile.DoctorId) + .Select(d => d.AvatarUrl) + .FirstOrDefaultAsync(ct); + AddIfPresent(fileUrls, doctorAvatar); + } + + fileUrls.AddRange(await _db.Reports + .Where(r => r.UserId == userId && r.FileUrl != "") + .Select(r => r.FileUrl) + .ToListAsync(ct)); + + var metadata = await _db.ConversationMessages + .Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "") + .Select(m => m.MetadataJson!) + .ToListAsync(ct); + + return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata); + } + public Task SaveChangesAsync(CancellationToken ct) => _db.SaveChangesAsync(ct); @@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository await transaction.CommitAsync(ct); } + + private static void AddIfPresent(List values, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) values.Add(value); + } } diff --git a/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs b/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs new file mode 100644 index 0000000..30dc1f2 --- /dev/null +++ b/backend/src/Health.Infrastructure/Users/local_account_file_cleanup.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Health.Application.Users; + +namespace Health.Infrastructure.Users; + +public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup +{ + private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot); + + public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) + { + var fileUrls = new HashSet(references.FileUrls, StringComparer.OrdinalIgnoreCase); + foreach (var metadataJson in references.ConversationMetadataJson) + AddMetadataUrls(fileUrls, metadataJson); + + foreach (var fileUrl in fileUrls) + { + ct.ThrowIfCancellationRequested(); + var localPath = ResolveLocalPath(fileUrl); + if (localPath != null && File.Exists(localPath)) File.Delete(localPath); + } + + ct.ThrowIfCancellationRequested(); + var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N")); + if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true); + + return Task.CompletedTask; + } + + private static void AddMetadataUrls(HashSet fileUrls, string metadataJson) + { + try + { + using var document = JsonDocument.Parse(metadataJson); + AddStringProperty(document.RootElement, "imageUrl", fileUrls); + AddStringProperty(document.RootElement, "pdfUrl", fileUrls); + } + catch (JsonException) + { + // 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。 + } + } + + private static void AddStringProperty(JsonElement root, string propertyName, HashSet fileUrls) + { + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty(propertyName, out var property) || + property.ValueKind != JsonValueKind.String) + return; + + var value = property.GetString(); + if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value); + } + + private string? ResolveLocalPath(string fileUrl) + { + var urlPath = fileUrl; + if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri)) + urlPath = absoluteUri.AbsolutePath; + + var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase); + if (uploadsIndex < 0) return null; + + string relativePath; + try + { + relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]); + } + catch (UriFormatException) + { + return null; + } + + var queryIndex = relativePath.IndexOfAny(['?', '#']); + if (queryIndex >= 0) relativePath = relativePath[..queryIndex]; + relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); + + var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath)); + var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null; + } +} 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 4b9dec7..313078a 100644 --- a/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs @@ -1,112 +1,66 @@ using Health.Application.Notifications; -using Health.Domain.Enums; namespace Health.WebApi.BackgroundServices; /// -/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户, -/// 对未录入指标且开关开启的用户推送提醒。 -/// 中国时区 UTC+8。 +/// Produces in-app catch-up reminders for health records. +/// The actual reminder rules live in IReminderCatchUpService so foreground +/// app checks and background scans stay consistent. /// public sealed class HealthRecordReminderService( IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { - private readonly IServiceScopeFactory _scopeFactory = scopeFactory; - private readonly ILogger _logger = logger; - - // 北京时间 9:00 和 12:00 触发 - private static readonly int[] ReminderHoursCst = [9, 12]; - + private static readonly int[] ReminderHoursCst = [9, 13, 19]; private DateTime _lastFired = DateTime.MinValue; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("健康录入提醒服务已启动"); + logger.LogInformation("Health record in-app reminder service started"); while (!stoppingToken.IsCancellationRequested) { try { var nowCst = DateTime.UtcNow.AddHours(8); - // 当前小时是否在触发列表,且今天该小时还没触发过 - if (ReminderHoursCst.Contains(nowCst.Hour) - && (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour)) + if (ReminderHoursCst.Contains(nowCst.Hour) && + (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour)) { - await ScanAndPushAsync(nowCst, stoppingToken); + await CheckUsersAsync(DateTime.UtcNow, stoppingToken); _lastFired = nowCst; } } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } - catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Health record in-app reminder scan failed"); + } await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } } - private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct) + private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct) { - using var scope = _scopeFactory.CreateScope(); + using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var producer = scope.ServiceProvider.GetRequiredService(); + var catchUp = scope.ServiceProvider.GetRequiredService(); - var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC - var todayEnd = todayStart.AddDays(1); - - // 取所有启用了健康录入提醒的用户偏好 - var prefs = await db.NotificationPreferences - .Where(p => p.PushEnabled && p.HealthRecordReminder) + var userIds = await db.NotificationPreferences.AsNoTracking() + .Where(x => x.PushEnabled && x.HealthRecordReminder) + .Select(x => x.UserId) .ToListAsync(ct); - foreach (var pref in prefs) + var created = 0; + foreach (var userId in userIds) { - 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) - .Select(r => r.MetricType) - .Distinct() - .ToListAsync(ct); - - // 哪些指标启用开关但当天没录入 - var missing = new List<(HealthMetricType type, bool enabled, string label)> - { - (HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"), - (HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"), - (HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"), - (HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"), - (HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"), - }; - - var missingLabels = missing - .Where(m => m.enabled && !recordedTypes.Contains(m.type)) - .Select(m => m.label) - .ToList(); - - if (missingLabels.Count == 0) continue; - - // 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时 - var sourceId = MakeSourceId(pref.UserId, nowCst); - var summary = string.Join("、", missingLabels); - - await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage( - Type: "health_record_reminder", - Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入", - Message: $"建议录入:{summary}", - Severity: "info", - ActionType: "health", - ActionTargetId: null), ct); + var result = await catchUp.CheckDueAsync(userId, utcNow, ct); + created += result.CreatedCount; } - } - private static Guid MakeSourceId(Guid userId, DateTime nowCst) - { - // 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复 - var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}"; - var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key)); - return new Guid(bytes); + if (created > 0) + logger.LogInformation("Created {Count} in-app catch-up reminders", created); } } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 6d7b344..f25b669 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -321,8 +321,12 @@ public static class AiChatEndpoints var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); - await conversations.DeleteAsync(userId.Value, id, ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var deleted = await conversations.DeleteAsync(userId.Value, id, ct); + return deleted + ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) + : Results.Json( + new { code = 40401, data = (object?)null, message = "对话不存在" }, + statusCode: StatusCodes.Status404NotFound); }); // 一键清空当前用户的全部对话 diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index 6cc82cd..45d0447 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -29,6 +29,20 @@ public static class DoctorEndpoints return profile?.DoctorId; } + public static (DateTime StartUtc, DateTime EndUtc) GetBeijingDayRange(DateTime utcNow) + { + var beijingNow = utcNow.ToUniversalTime().AddHours(8); + var startUtc = new DateTime( + beijingNow.Year, + beijingNow.Month, + beijingNow.Day, + 0, + 0, + 0, + DateTimeKind.Utc).AddHours(-8); + return (startUtc, startUtc.AddDays(1)); + } + public static void MapDoctorEndpoints(this WebApplication app) { var group = app.MapGroup("/api/doctor").RequireAuthorization(); @@ -56,8 +70,7 @@ public static class DoctorEndpoints var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId); var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed); var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor); - var todayStart = DateTime.UtcNow.Date; - var todayEnd = todayStart.AddDays(1); + var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow); var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming); // 待办列表 @@ -190,7 +203,8 @@ public static class DoctorEndpoints user.HealthArchive.Allergies, user.HealthArchive.DietRestrictions, user.HealthArchive.ChronicDiseases, - user.HealthArchive.FamilyHistory + user.HealthArchive.FamilyHistory, + user.HealthArchive.SurgeryHistoryStatus }, latestRecords, trendRecords, diff --git a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs index 7b33ab6..82c4192 100644 --- a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs @@ -57,8 +57,10 @@ public static class ExerciseEndpoints group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) => { var userId = GetUserId(http); - await exercises.DeleteAsync(userId, id, ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var deleted = await exercises.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("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) => diff --git a/backend/src/Health.WebApi/Endpoints/file_endpoints.cs b/backend/src/Health.WebApi/Endpoints/file_endpoints.cs index c06890d..92018a6 100644 --- a/backend/src/Health.WebApi/Endpoints/file_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/file_endpoints.cs @@ -12,8 +12,9 @@ public static class FileEndpoints { var group = app.MapGroup("/api/files").RequireAuthorization(); - group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) => + group.MapPost("/upload", async (HttpContext http, CancellationToken ct) => { + var request = http.Request; if (!request.HasFormContentType) return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" }); @@ -23,7 +24,12 @@ public static class FileEndpoints return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" }); var results = new List(); - var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); + var userDirectoryName = GetUserId(http).ToString("N"); + var uploadsDir = Path.Combine( + Directory.GetCurrentDirectory(), + "uploads", + "users", + userDirectoryName); Directory.CreateDirectory(uploadsDir); foreach (var file in files) @@ -48,7 +54,7 @@ public static class FileEndpoints id = fileId, name = file.FileName, size = file.Length, - url = $"/uploads/{storedName}", + url = $"/uploads/users/{userDirectoryName}/{storedName}", contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType }); } @@ -56,4 +62,9 @@ public static class FileEndpoints return Results.Ok(new { code = 0, data = results, message = (string?)null }); }); } + + private static Guid GetUserId(HttpContext http) => + Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) + ? id + : Guid.Empty; } diff --git a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs index 750e82c..ed232b9 100644 --- a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs @@ -34,6 +34,20 @@ public static class HealthEndpoints return Results.Ok(new { code = 0, data = new { id }, message = (string?)null }); }); + group.MapPost("/batch", async ( + IReadOnlyList requests, + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => + { + var userId = GetUserId(http); + var ids = await healthRecords.CreateManyAsync( + userId, + requests.Select(request => request.ToServiceRequest()).ToList(), + ct); + return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null }); + }); + group.MapPut("/{id:guid}", async ( Guid id, CreateHealthRecordRequest req, diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index d066fc4..567a803 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -77,8 +77,10 @@ public static class MedicationEndpoints group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct); + return cancelled + ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "未找到对应的服药打卡记录" }); }); } @@ -112,14 +114,19 @@ public static class MedicationEndpoints if (root.TryGetProperty("frequency", out var f) && Enum.TryParse(f.GetString(), out var freq)) frequency = freq; + var hasEndDate = root.TryGetProperty("endDate", out var ed); + var clearEndDate = hasEndDate && + (ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString())); + return new MedicationPatchRequest( root.TryGetProperty("name", out var n) ? n.GetString() : null, root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, frequency, root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null, root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null, - root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null, - root.TryGetProperty("notes", out var nt) ? nt.GetString() : null); + hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null, + root.TryGetProperty("notes", out var nt) ? nt.GetString() : null, + clearEndDate); } private static List ReadTimes(JsonElement root, string propertyName) diff --git a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs index 3115fd6..1ef883e 100644 --- a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs @@ -19,6 +19,14 @@ public static class NotificationEndpoints return Results.Ok(new { code = 0, data = result, message = (string?)null }); }); + group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == Guid.Empty) return Results.Unauthorized(); + var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct); + return Results.Ok(new { code = 0, data = result, message = (string?)null }); + }); + group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => { var userId = GetUserId(http); @@ -104,7 +112,7 @@ public static class NotificationEndpoints await db.SaveChangesAsync(ct); // 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00 - if (body.HealthRecordReminder is true && pref.HealthRecordReminder) + if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder) { var nowCst = DateTime.UtcNow.AddHours(8); var todayStart = nowCst.Date.AddHours(-8); diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index 081f24a..1e0e83b 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -49,7 +49,8 @@ public static class UserEndpoints req.DietRestrictions, req.ChronicDiseases, req.FamilyHistory, - surgeries), + surgeries, + req.SurgeryHistoryStatus), ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); @@ -70,6 +71,7 @@ public sealed record UpdateArchiveRequest( string? Diagnosis, string? SurgeryType, string? SurgeryDate, List? Allergies, List? DietRestrictions, List? ChronicDiseases, string? FamilyHistory, - List? Surgeries); + List? Surgeries, + string? SurgeryHistoryStatus); public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date); diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index e3e63b5..a20f27e 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -142,6 +142,7 @@ builder.Services.AddScoped( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -149,6 +150,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(_ => new LocalAccountFileCleanup( + Path.Combine(Directory.GetCurrentDirectory(), "uploads"))); // ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)---- builder.Services.AddHttpClient(client => diff --git a/backend/tests/Health.Tests/account_deletion_tests.cs b/backend/tests/Health.Tests/account_deletion_tests.cs new file mode 100644 index 0000000..204f97d --- /dev/null +++ b/backend/tests/Health.Tests/account_deletion_tests.cs @@ -0,0 +1,120 @@ +using Health.Application.Users; +using Health.Domain.Entities; +using Health.Infrastructure.Users; + +namespace Health.Tests; + +public sealed class AccountDeletionTests +{ + [Fact] + public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles() + { + var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}"); + var userId = Guid.NewGuid(); + var userDirectory = Path.Combine(root, "users", userId.ToString("N")); + var reportPath = Path.Combine(root, "reports", "owned-report.pdf"); + var chatImagePath = Path.Combine(root, "owned-chat.jpg"); + var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg"); + var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt"); + + try + { + Directory.CreateDirectory(userDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!); + await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned"); + await File.WriteAllTextAsync(reportPath, "owned"); + await File.WriteAllTextAsync(chatImagePath, "owned"); + await File.WriteAllTextAsync(otherUserPath, "other"); + await File.WriteAllTextAsync(outsidePath, "outside"); + + var cleanup = new LocalAccountFileCleanup(root); + var references = new AccountFileReferences( + ["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"], + ["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]); + + await cleanup.DeleteAsync(userId, references, CancellationToken.None); + + Assert.False(Directory.Exists(userDirectory)); + Assert.False(File.Exists(reportPath)); + Assert.False(File.Exists(chatImagePath)); + Assert.True(File.Exists(otherUserPath)); + Assert.True(File.Exists(outsidePath)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + if (File.Exists(outsidePath)) File.Delete(outsidePath); + } + } + + [Fact] + public async Task UserService_CleansFilesBeforeDeletingDatabaseData() + { + var calls = new List(); + var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []); + var repository = new FakeUserRepository(references, calls); + var cleanup = new FakeAccountFileCleanup(calls); + var service = new UserService(repository, cleanup); + + await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None); + + Assert.Equal(["references", "files", "database"], calls); + Assert.Same(references, cleanup.ReceivedReferences); + } + + [Fact] + public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails() + { + var calls = new List(); + var repository = new FakeUserRepository(new AccountFileReferences([], []), calls); + var service = new UserService(repository, new FailingAccountFileCleanup(calls)); + + await Assert.ThrowsAsync(() => + service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None)); + + Assert.Equal(["references", "files"], calls); + } + + private sealed class FakeUserRepository( + AccountFileReferences references, + List calls) : IUserRepository + { + public Task GetAsync(Guid userId, CancellationToken ct) => Task.FromResult(null); + + public Task GetAccountFileReferencesAsync(Guid userId, CancellationToken ct) + { + calls.Add("references"); + return Task.FromResult(references); + } + + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + + public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct) + { + calls.Add("database"); + return Task.CompletedTask; + } + } + + private sealed class FakeAccountFileCleanup(List calls) : IAccountFileCleanup + { + public AccountFileReferences? ReceivedReferences { get; private set; } + + public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) + { + calls.Add("files"); + ReceivedReferences = references; + return Task.CompletedTask; + } + } + + private sealed class FailingAccountFileCleanup(List calls) : IAccountFileCleanup + { + public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct) + { + calls.Add("files"); + throw new IOException("file is locked"); + } + } +} diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index c6275ad..d7a4f06 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -42,7 +42,8 @@ public sealed class ApplicationServiceTests var userId = Guid.NewGuid(); var repository = new FakeHealthArchiveRepository(new HealthArchive { - Id = Guid.NewGuid(), UserId = userId, + Id = Guid.NewGuid(), + UserId = userId, }); var service = new HealthArchiveService(repository); @@ -75,14 +76,35 @@ public sealed class ApplicationServiceTests Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01); } + [Fact] + public async Task HealthRecords_BatchPersistsAllReadingsWithOneSave() + { + var repository = new CapturingHealthRecordRepository(); + var service = new HealthRecordService(repository); + + var ids = await service.CreateManyAsync( + Guid.NewGuid(), + [ + new HealthRecordUpsertRequest(HealthMetricType.BloodPressure, 116, 89, null, "mmHg", HealthRecordSource.DeviceSync, DateTime.UtcNow), + new HealthRecordUpsertRequest(HealthMetricType.HeartRate, null, null, 72, "bpm", HealthRecordSource.DeviceSync, DateTime.UtcNow), + ], + CancellationToken.None); + + Assert.Equal(2, ids.Count); + Assert.Equal(2, repository.Added.Count); + Assert.Equal(1, repository.SaveCount); + } + [Fact] public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() { var userId = Guid.NewGuid(); var repository = new FakeHealthArchiveRepository(new HealthArchive { - Id = Guid.NewGuid(), UserId = userId, - SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2), + Id = Guid.NewGuid(), + UserId = userId, + SurgeryType = "Legacy surgery", + SurgeryDate = new DateOnly(2010, 1, 2), }); var service = new HealthArchiveService(repository); @@ -110,13 +132,71 @@ public sealed class ApplicationServiceTests Assert.False(result.Created); } + [Fact] + public async Task Conversation_List_ReturnsAtMostThirtyRecentItems() + { + var userId = Guid.NewGuid(); + var conversations = Enumerable.Range(0, 35) + .Select(index => new Conversation + { + Id = Guid.NewGuid(), + UserId = userId, + AgentType = AgentType.Unified, + UpdatedAt = DateTime.UtcNow.AddMinutes(-index), + }) + .ToList(); + var service = new AiConversationService(new ListConversationRepository(conversations)); + + var result = await service.ListAsync(userId, CancellationToken.None); + + Assert.Equal(30, result.Count); + } + + [Fact] + public void Conversation_Delete_ReportsWhetherARecordWasActuallyDeleted() + { + var method = typeof(IAiConversationService).GetMethod(nameof(IAiConversationService.DeleteAsync)); + + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + [Fact] + public async Task Conversation_Open_ReactivatesTheSameOwnedConversation() + { + var userId = Guid.NewGuid(); + var conversation = new Conversation + { + Id = Guid.NewGuid(), + UserId = userId, + AgentType = AgentType.Unified, + UpdatedAt = DateTime.UtcNow.AddDays(-1), + }; + var service = new AiConversationService(new FakeConversationRepository(conversation)); + + var result = await service.OpenAsync( + userId, + conversation.Id, + AgentType.Unified, + "新的追问", + CancellationToken.None); + + Assert.True(result.Found); + Assert.False(result.Created); + Assert.Equal(conversation.Id, result.ConversationId); + } + [Fact] public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp() { var date = new DateOnly(2026, 6, 19); var medication = new Medication { - Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)] + Id = Guid.NewGuid(), + IsActive = true, + Name = "阿司匹林", + StartDate = date.AddDays(-1), + TimeOfDay = [new TimeOnly(8, 0)] }; var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 }); @@ -154,7 +234,11 @@ public sealed class ApplicationServiceTests var repository = new FakeExerciseRepository(); var plan = new ExercisePlan { - Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0), + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + StartDate = today, + EndDate = today.AddDays(9), + ReminderTime = new TimeOnly(19, 0), }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 }); @@ -175,11 +259,19 @@ public sealed class ApplicationServiceTests var repository = new FakeExerciseRepository(); var plan = new ExercisePlan { - Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0), + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + StartDate = today.AddDays(-1), + EndDate = today, + ReminderTime = new TimeOnly(19, 0), }; var item = new ExercisePlanItem { - Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30, + Id = Guid.NewGuid(), + Plan = plan, + ScheduledDate = today.AddDays(-1), + ExerciseType = "散步", + DurationMinutes = 30, }; plan.Items.Add(item); repository.SetPlan(plan); @@ -199,6 +291,8 @@ public sealed class ApplicationServiceTests private sealed class CapturingHealthRecordRepository : IHealthRecordRepository { public DateTime? RecordedAfter { get; private set; } + public List Added { get; } = []; + public int SaveCount { get; private set; } public Task> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) => Task.FromResult>([]); public Task GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult(null); @@ -208,9 +302,9 @@ public sealed class ApplicationServiceTests RecordedAfter = recordedAfter; return Task.FromResult>([]); } - public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask; + public Task AddAsync(HealthRecord record, CancellationToken ct) { Added.Add(record); return Task.CompletedTask; } public void Delete(HealthRecord record) { } - public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; } } private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository @@ -228,6 +322,33 @@ public sealed class ApplicationServiceTests public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } + private sealed class ListConversationRepository(List conversations) : IAiConversationRepository + { + private readonly List _conversations = conversations; + public Task GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => + Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId)); + public Task GetAsync(Guid conversationId, CancellationToken ct) => + Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId)); + public Task> ListAsync(Guid userId, CancellationToken ct) => + Task.FromResult>(_conversations + .Where(item => item.UserId == userId) + .OrderByDescending(item => item.UpdatedAt) + .ToList()); + public Task> GetMessagesAsync(Guid conversationId, CancellationToken ct) => + Task.FromResult>([]); + public Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => + Task.FromResult>([]); + public Task AddConversationAsync(Conversation value, CancellationToken ct) + { + _conversations.Add(value); + return Task.CompletedTask; + } + public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask; + public Task DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0); + public void Delete(Conversation value) => _conversations.Remove(value); + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } + private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository { public Task GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot); diff --git a/backend/tests/Health.Tests/doctor_endpoint_tests.cs b/backend/tests/Health.Tests/doctor_endpoint_tests.cs new file mode 100644 index 0000000..460c19f --- /dev/null +++ b/backend/tests/Health.Tests/doctor_endpoint_tests.cs @@ -0,0 +1,17 @@ +using Health.WebApi.Endpoints; + +namespace Health.Tests; + +public sealed class DoctorEndpointTests +{ + [Fact] + public void BeijingDayRange_MapsEarlyUtcTimeToTheCorrectLocalDate() + { + var utcNow = new DateTime(2026, 7, 13, 17, 30, 0, DateTimeKind.Utc); + + var (startUtc, endUtc) = DoctorEndpoints.GetBeijingDayRange(utcNow); + + Assert.Equal(new DateTime(2026, 7, 13, 16, 0, 0, DateTimeKind.Utc), startUtc); + Assert.Equal(new DateTime(2026, 7, 14, 16, 0, 0, DateTimeKind.Utc), endUtc); + } +} diff --git a/backend/tests/Health.Tests/entity_tests.cs b/backend/tests/Health.Tests/entity_tests.cs index f9978a8..184d68e 100644 --- a/backend/tests/Health.Tests/entity_tests.cs +++ b/backend/tests/Health.Tests/entity_tests.cs @@ -184,6 +184,7 @@ public class EntityTests DietRestrictions = ["低盐", "低脂"], ChronicDiseases = ["高血压", "高血脂"], FamilyHistory = "父亲冠心病", + SurgeryHistoryStatus = "有", }; db.HealthArchives.Add(archive); await db.SaveChangesAsync(); @@ -193,6 +194,7 @@ public class EntityTests Assert.Equal("冠心病", saved!.Diagnosis); Assert.Equal(2, saved.DietRestrictions.Count); Assert.Contains("低盐", saved.DietRestrictions); + Assert.Equal("有", saved.SurgeryHistoryStatus); } [Fact] diff --git a/backend/tests/Health.Tests/medication_schedule_tests.cs b/backend/tests/Health.Tests/medication_schedule_tests.cs new file mode 100644 index 0000000..3f3fb1a --- /dev/null +++ b/backend/tests/Health.Tests/medication_schedule_tests.cs @@ -0,0 +1,29 @@ +using Health.Application.Medications; +using Health.Domain.Enums; + +namespace Health.Tests; + +public sealed class MedicationScheduleTests +{ + [Fact] + public void FutureDoseOutsideReminderWindow_RemainsVisibleAsScheduled() + { + var status = MedicationScheduleStatus.Resolve( + new TimeOnly(18, 0), + new TimeOnly(8, 0), + logStatus: null); + + Assert.Equal("scheduled", status); + } + + [Fact] + public void LoggedDose_UsesItsActualStatus() + { + var status = MedicationScheduleStatus.Resolve( + new TimeOnly(8, 0), + new TimeOnly(12, 0), + MedicationLogStatus.Taken); + + Assert.Equal("taken", status); + } +} diff --git a/backend/tests/Health.Tests/medication_update_tests.cs b/backend/tests/Health.Tests/medication_update_tests.cs new file mode 100644 index 0000000..0cce20b --- /dev/null +++ b/backend/tests/Health.Tests/medication_update_tests.cs @@ -0,0 +1,54 @@ +using Health.Application.Medications; +using Health.Domain.Entities; + +namespace Health.Tests; + +public sealed class MedicationUpdateTests +{ + [Fact] + public async Task Update_CanClearAnExistingEndDate() + { + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "阿司匹林", + StartDate = new DateOnly(2026, 7, 1), + EndDate = new DateOnly(2026, 7, 31), + }; + var repository = new FakeMedicationRepository(medication); + var service = new MedicationService(repository); + + var updated = await service.UpdateAsync( + medication.UserId, + medication.Id, + new MedicationPatchRequest( + null, null, null, null, null, null, null, + ClearEndDate: true), + CancellationToken.None); + + Assert.True(updated); + Assert.Null(medication.EndDate); + Assert.True(repository.Saved); + } + + private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository + { + public bool Saved { get; private set; } + + public Task> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult>([medication]); + public Task> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult>([medication]); + public Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult>([]); + public Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId ? medication : null); + public Task ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId); + public Task GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(null); + public Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false); + public Task GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(null); + public Task> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult>([medication]); + public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask; + public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask; + public void DeleteMedication(Medication value) { } + public void DeleteLog(MedicationLog log) { } + public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; } + } +} diff --git a/backend/tests/Health.Tests/persistence_pipeline_tests.cs b/backend/tests/Health.Tests/persistence_pipeline_tests.cs index 1517359..1ac13c6 100644 --- a/backend/tests/Health.Tests/persistence_pipeline_tests.cs +++ b/backend/tests/Health.Tests/persistence_pipeline_tests.cs @@ -213,6 +213,178 @@ public sealed class PersistencePipelineTests Assert.Empty(tasks); } + [Fact] + public async Task NotificationOutbox_PushDisabledDoesNotCreateInAppNotification() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var sourceId = Guid.NewGuid(); + db.NotificationPreferences.Add(new NotificationPreference + { + Id = Guid.NewGuid(), + UserId = userId, + PushEnabled = false, + MedicationReminder = true, + HealthRecordReminder = false, + }); + db.NotificationOutbox.Add(new NotificationOutboxRecord + { + Id = Guid.NewGuid(), + UserId = userId, + SourceTaskId = sourceId, + Type = "MedicationReminder", + Payload = """ + {"Type":"MedicationReminder","Title":"用药时间到了","Message":"请按计划服药","Severity":"info","ActionType":"medication","ActionTargetId":null} + """, + Status = "Pending", + AvailableAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + var processor = new EfNotificationOutboxProcessor(db); + + await processor.ProcessNextAsync(CancellationToken.None); + + Assert.Empty(db.UserNotifications); + } + + [Fact] + public async Task ReminderCatchUp_PushDisabledDoesNotCreateHealthRecordReminder() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.NotificationPreferences.Add(new NotificationPreference + { + Id = Guid.NewGuid(), + UserId = userId, + PushEnabled = false, + HealthRecordReminder = true, + HealthRecordReminderBloodPressure = true, + HealthRecordReminderHeartRate = false, + HealthRecordReminderGlucose = false, + HealthRecordReminderSpO2 = false, + HealthRecordReminderWeight = false, + }); + await db.SaveChangesAsync(); + var service = new EfReminderCatchUpService(db); + var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); // 13:00 Beijing + + var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); + var secondPass = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); + + Assert.Equal(0, created.CreatedCount); + Assert.Equal(0, secondPass.CreatedCount); + Assert.Empty(db.UserNotifications); + } + + [Fact] + public async Task ReminderCatchUp_PushEnabledCreatesOnlySelectedMissingMetric() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.NotificationPreferences.Add(new NotificationPreference + { + Id = Guid.NewGuid(), + UserId = userId, + PushEnabled = true, + HealthRecordReminder = true, + HealthRecordReminderBloodPressure = true, + HealthRecordReminderHeartRate = false, + HealthRecordReminderGlucose = false, + HealthRecordReminderSpO2 = false, + HealthRecordReminderWeight = false, + }); + await db.SaveChangesAsync(); + var service = new EfReminderCatchUpService(db); + var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); + + var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); + + Assert.Equal(1, result.CreatedCount); + var notification = Assert.Single(db.UserNotifications); + Assert.Contains("血压", notification.Message); + Assert.DoesNotContain("心率", notification.Message); + } + + [Fact] + public async Task ReminderCatchUp_DndStillPreventsReminder() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + db.NotificationPreferences.Add(new NotificationPreference + { + Id = Guid.NewGuid(), + UserId = userId, + PushEnabled = true, + HealthRecordReminder = true, + HealthRecordReminderBloodPressure = true, + DndEnabled = true, + DndStartMinutes = 12 * 60, + DndEndMinutes = 14 * 60, + }); + await db.SaveChangesAsync(); + var service = new EfReminderCatchUpService(db); + var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); + + var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); + + Assert.Equal(0, result.CreatedCount); + Assert.Empty(db.UserNotifications); + } + + [Fact] + public async Task ReminderCatchUp_CreatesMedicationAndExerciseRemindersForDueItems() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var date = new DateOnly(2026, 7, 13); + db.NotificationPreferences.Add(new NotificationPreference + { + Id = Guid.NewGuid(), + UserId = userId, + PushEnabled = true, + MedicationReminder = true, + HealthRecordReminder = false, + }); + db.Medications.Add(new Medication + { + Id = Guid.NewGuid(), + UserId = userId, + Name = "测试药物", + Dosage = "1片", + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(8, 0)], + StartDate = date, + IsActive = true, + }); + var plan = new ExercisePlan + { + Id = Guid.NewGuid(), + UserId = userId, + StartDate = date, + EndDate = date, + ReminderTime = new TimeOnly(9, 0), + }; + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), + ScheduledDate = date, + ExerciseType = "散步", + DurationMinutes = 30, + }); + db.ExercisePlans.Add(plan); + await db.SaveChangesAsync(); + var service = new EfReminderCatchUpService(db); + var nowUtc = new DateTime(2026, 7, 13, 2, 0, 0, DateTimeKind.Utc); // 10:00 Beijing + + var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None); + + Assert.Equal(2, created.CreatedCount); + Assert.Contains(db.UserNotifications, x => x.Type == "MedicationReminder"); + Assert.Contains(db.UserNotifications, x => x.Type == "ExerciseReminder"); + } + private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new() { Id = Guid.NewGuid(), diff --git a/backend/tests/Health.Tests/prompt_manager_tests.cs b/backend/tests/Health.Tests/prompt_manager_tests.cs new file mode 100644 index 0000000..e965865 --- /dev/null +++ b/backend/tests/Health.Tests/prompt_manager_tests.cs @@ -0,0 +1,18 @@ +using Health.Infrastructure.AI; +using Health.Domain.Enums; + +namespace Health.Tests; + +public sealed class PromptManagerTests +{ + [Fact] + public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests() + { + var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified); + + Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt); + Assert.Contains("血氧98%是不是太高了", prompt); + Assert.Contains("先回答问题,不调用 record_health_data", prompt); + Assert.Contains("帮我记录血压116/89", prompt); + } +} diff --git a/docs/superpowers/plans/2026-07-14-secondary-page-color-refresh.md b/docs/superpowers/plans/2026-07-14-secondary-page-color-refresh.md new file mode 100644 index 0000000..176362f --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-secondary-page-color-refresh.md @@ -0,0 +1,47 @@ +# Secondary Page Color Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Execute this plan inline in the current session. Do not create a branch or worktree. + +**Goal:** Produce a restrained first-pass visual refresh for secondary pages while leaving the home page and all business behavior unchanged. + +**Architecture:** Centralize a light neutral secondary-page canvas with a white AppBar in `GradientScaffold`, keep the existing home implementation untouched, and introduce one reusable outlined create FAB for the pages that currently expose add/upload actions. Fix swipe-delete rows at the shared widget layer so medication and exercise rows always paint an opaque white content surface over the red delete action. + +**Tech Stack:** Flutter, Material 3, flutter_test. + +## Global Constraints + +- Work on the existing `main` branch and preserve all unrelated dirty-worktree changes. +- Do not change backend APIs, providers, routing, or page business logic. +- Keep the current home page visual design unchanged. +- Secondary pages use a `#F7F8FA` canvas with white content surfaces; module colors remain content accents only. +- Reuse the existing `AppRadius` and `AppColors.actionOutlineGradient` tokens. + +--- + +### Task 1: Lock the shared secondary-page visual behavior + +**Files:** +- Modify: `health_app/test/swipe_delete_tile_test.dart` +- Create: `health_app/test/secondary_page_visuals_test.dart` +- Modify: `health_app/lib/core/app_theme.dart` +- Modify: `health_app/lib/widgets/common_widgets.dart` + +- [ ] Add failing widget tests for an opaque swipe-row surface, a white `GradientScaffold`, and the shared outlined create FAB. +- [ ] Run the targeted tests and confirm they fail for the missing visual behavior. +- [ ] Make `GradientScaffold` and the global AppBar surface white without changing the home page. +- [ ] Add the reusable white, purple-gradient-outline create FAB. +- [ ] Paint swipe-row content white so red appears only in the revealed action area. +- [ ] Run the targeted tests and confirm they pass. + +### Task 2: Adopt the common create action on active user pages + +**Files:** +- Modify: `health_app/lib/pages/chart/trend_page.dart` +- Modify: `health_app/lib/pages/medication/medication_list_page.dart` +- Modify: `health_app/lib/pages/exercise/exercise_plan_page.dart` +- Modify: `health_app/lib/pages/report/report_pages.dart` + +- [ ] Replace page-specific solid-color FAB styling with the shared create FAB. +- [ ] Preserve every existing callback, tooltip, route, and upload sheet. +- [ ] Format only the touched Dart files. +- [ ] Run the focused widget tests, the full Flutter test suite, and `flutter analyze`. diff --git a/docs/superpowers/specs/2026-07-14-notification-preferences-design.md b/docs/superpowers/specs/2026-07-14-notification-preferences-design.md new file mode 100644 index 0000000..c25e923 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-notification-preferences-design.md @@ -0,0 +1,47 @@ +# 通知设置改造设计 + +## 目标 + +让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。 + +## 页面结构 + +主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。 + +健康录入提醒区域只展示: + +- “每日健康录入提醒”总开关。 +- “提醒指标”入口,副标题展示当前已选指标摘要。 + +点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。 + +## 状态与保存 + +前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。 + +- 首次加载成功后才展示设置内容。 +- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。 +- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。 +- 保存失败恢复原值并显示错误提示。 +- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。 +- 免打扰时间采用相同的真实保存与失败恢复规则。 + +## 后端行为 + +保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。 + +通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。 + +## 错误处理 + +- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。 +- 保存过程中禁止同一设置重复提交。 +- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。 +- 后端返回失败时不显示成功状态。 + +## 测试 + +- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。 +- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。 +- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。 +- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。 diff --git a/docs/ui-design-system.md b/docs/ui-design-system.md new file mode 100644 index 0000000..ef9a084 --- /dev/null +++ b/docs/ui-design-system.md @@ -0,0 +1,79 @@ +# 健康 App 前端 UI 设计系统 + +这份规范用于后续逐页精修。首页是品牌化例外,保持当前紫色背景和已有结构;以下规则主要约束普通二级页面。 + +## 1. 页面层级 + +- 标题栏:纯白背景,深色标题和返回图标。 +- 页面画布:`#F7F8FA`,只负责建立内容边界,不作为卡片颜色。 +- 主要内容组:纯白背景,区域间保留 12–16 px 的画布间距。 +- 列表行:同一内容组内连续排列,用文字区域浅分隔线区分,不为每一行单独套卡片。 +- 重点对象:报告、设备、AI 分析和关键概览可以使用独立白色卡片。 + +## 2. 颜色角色 + +- 品牌紫:通用选中、焦点、品牌按钮描边和少量重点操作。 +- 模块色:只用于统一样式的图标、极浅图标底、图表曲线、小标签和状态识别。 +- 中性色:页面画布、分隔线、正文层级和不可用状态。 +- 语义色:绿色表示成功/正常,黄色表示提醒/等待,红色表示错误/危险,蓝色表示信息/进行中。 +- 禁止模块色用于整页背景、大面积头部、大卡片底色和普通新增按钮。 + +## 3. 间距 + +- 页面左右边距:16 px。 +- 页面顶部内容间距:12 px。 +- 内容组间距:12–16 px。 +- 列表图标与文字:12 px。 +- 列表行垂直内边距:根据内容使用 12 或 16 px,不通过缩小字体制造紧凑感。 + +## 4. 圆角与阴影 + +- 所有圆角使用 `AppRadius`,不得在页面里随意新增数值。 +- 连续内容组默认使用 16 px 圆角。 +- 输入框和小型操作使用 10–14 px 圆角。 +- 普通内容组默认无阴影、无描边。 +- 浮动操作可以使用一层轻微中性阴影;同一组件不同时使用明显边框和重阴影。 + +## 5. 字体层级 + +- 页面标题:19–20 px,粗体,颜色 `textPrimary`。 +- 区域标题:18 px,粗体。 +- 列表主标题:16–17 px,中粗体。 +- 正文和补充说明:13–15 px,保证对比度,不使用过浅小字。 +- 数值、单位、份量和热量在同一行时保持基线、字号和字重协调。 +- 选中状态不能改变文字大小、字重或位置。 + +## 6. 图标 + +- 全 App 优先使用同一套 Material 线性 `outlined/rounded` 图标,不混用大面积实心图标。 +- 普通列表图标底座统一为 40×40 px、10 px 圆角,图标统一为 22 px。 +- 不同模块可以改变图标和极浅底色,但尺寸、线条感觉、底座形状和对齐必须一致。 +- 无底座的小型操作图标统一为 20–24 px,并保证至少 44 px 点击区域。 + +## 7. 按钮 + +- 通用新增:白底、紫色渐变细描边、深紫色线性加号;所有模块共用同一组件。 +- 页面主要操作:优先使用白底紫色品牌描边;是否使用实心按钮由逐页任务重要性决定。 +- 次要操作:白底或极浅灰底、深色文字。 +- 危险操作:白底红字;只有确认删除阶段显示红色危险面。 +- 新增、上传、绑定和记录属于通用操作,不跟随模块颜色。 + +## 8. 列表与滑动操作 + +- 列表行连续排列,分隔线从文字区域开始,不穿过左侧图标。 +- 整行可点击,附属按钮不能缩小主点击区域。 +- 删除背景默认完全隐藏,向左打开删除操作后才出现。 +- 删除失败时保留真实列表数据,不提前移除前端项目。 + +## 9. 弹窗、表单和状态 + +- 弹窗、底部面板统一白色,不跟随模块色。 +- 输入框焦点继续使用品牌紫,错误提示使用语义红。 +- 空状态说明原因并提供一个明确下一步;加载、空、失败和未开放必须能被用户区分。 +- 暂未启用的问诊和随访显示明确准备状态,不伪装成可用功能。 + +## 10. 逐页精修原则 + +- 每次只精修一个真实页面或一组完全同构页面。 +- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。 +- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。 diff --git a/health_app/android/app/src/main/AndroidManifest.xml b/health_app/android/app/src/main/AndroidManifest.xml index f2db025..618008f 100644 --- a/health_app/android/app/src/main/AndroidManifest.xml +++ b/health_app/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,6 @@ - ((ref) { final auth = ref.watch(authProvider); if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页 + final currentRoute = ref.watch(routeStackProvider).last.name; + if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash final role = auth.user?.role ?? 'User'; if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡 // 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死) @@ -109,6 +111,10 @@ class _RootNavigator extends ConsumerWidget { // 登录后自动跳转(在下一帧完成,无闪烁) if (authState.isLoggedIn && current.name == 'login') { WidgetsBinding.instance.addPostFrameCallback((_) { + ref.invalidate(latestHealthProvider); + ref.invalidate(medicationListProvider); + ref.invalidate(medicationReminderProvider); + ref.invalidate(currentExercisePlanProvider); final role = authState.user?.role ?? 'User'; if (role == 'Admin') { goRoute(ref, 'adminHome'); diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index ea74bb0..4a12f10 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://192.168.1.34:5000', + defaultValue: 'http://10.4.237.12:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index 3a448a6..de0f241 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -26,9 +26,9 @@ class AppColors { static const Color medication = Color(0xFF00B8D9); static const Color medicationLight = Color(0xFFEAFBFF); static const Color medicationBorder = Color(0xFFB8F0FA); - static const Color exercise = Color(0xFF8EC5FC); - static const Color exerciseLight = Color(0xFFF5F0FF); - static const Color exerciseBorder = Color(0xFFDCCBFE); + static const Color exercise = Color(0xFF4F7DD9); + static const Color exerciseLight = Color(0xFFEFF4FF); + static const Color exerciseBorder = Color(0xFFD5E2FF); static const Color report = Color(0xFF6366F1); static const Color reportLight = Color(0xFFEFFBFF); static const Color reportBorder = Color(0xFFBAE6FD); @@ -51,7 +51,7 @@ class AppColors { static const Color followupLight = Color(0xFFFDF2F8); static const Color followupBorder = Color(0xFFFBCFE8); - static const Color background = Color(0xFFF8FAFF); + static const Color background = Color(0xFFF7F8FA); static const Color backgroundSoft = Color(0xFFFFFFFF); static const Color cardBackground = Color(0xFFFFFFFF); static const Color cardInner = Color(0xFFF1F5F9); @@ -70,10 +70,13 @@ class AppColors { static const Color success = Color(0xFF16A34A); static const Color successLight = Color(0xFFEAF8EF); + static const Color successText = Color(0xFF15803D); static const Color error = Color(0xFFEF4444); static const Color errorLight = Color(0xFFFEECEF); + static const Color errorText = Color(0xFFDC2626); static const Color warning = Color(0xFFF59E0B); static const Color warningLight = Color(0xFFFFF7E6); + static const Color warningText = Color(0xFFB45309); static const Color infoLight = Color(0xFFEFF5FF); static const LinearGradient primaryGradient = LinearGradient( diff --git a/health_app/lib/core/app_design_tokens.dart b/health_app/lib/core/app_design_tokens.dart index 9c1c388..ca5c259 100644 --- a/health_app/lib/core/app_design_tokens.dart +++ b/health_app/lib/core/app_design_tokens.dart @@ -168,4 +168,3 @@ class AppTextStyles { height: 1.25, ); } - diff --git a/health_app/lib/core/app_module_visuals.dart b/health_app/lib/core/app_module_visuals.dart index 9d428d5..b3e533e 100644 --- a/health_app/lib/core/app_module_visuals.dart +++ b/health_app/lib/core/app_module_visuals.dart @@ -40,6 +40,8 @@ class AppModuleVisual { class AppModuleVisuals { AppModuleVisuals._(); + static const healthOverviewIcon = Icons.trending_up; + static const ai = AppModuleVisual( module: AppModule.ai, label: 'AI', @@ -73,7 +75,7 @@ class AppModuleVisuals { static const exercise = AppModuleVisual( module: AppModule.exercise, label: '运动', - icon: LucideIcons.footprints, + icon: Icons.directions_run_outlined, color: AppColors.exercise, lightColor: AppColors.exerciseLight, borderColor: AppColors.exerciseBorder, diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index c9ab1be..45f477d 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -15,7 +15,9 @@ import '../pages/settings/notification_prefs_page.dart'; import '../pages/notifications/notification_center_page.dart'; import '../pages/history/conversation_history_page.dart'; import '../pages/profile/profile_page.dart'; +import '../pages/profile/profile_edit_page.dart'; import '../pages/diet/diet_capture_page.dart'; +import '../pages/exercise/exercise_plan_page.dart'; import '../pages/device/device_scan_page.dart'; import '../pages/device/device_management_page.dart'; import '../pages/remaining_pages.dart'; @@ -67,9 +69,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { case 'medCheckIn': return MedicationCheckInPage(medId: params['id']); case 'medicationEdit': - return const MedicationEditPage(); + return MedicationEditPage(id: params['id']); case 'reports': - return const ReportListPage(); + return ReportListPage(openUploadOnEnter: params['openUpload'] == 'true'); case 'aiAnalysis': final id = _requiredParam(params, 'id'); return id == null ? _missingParamPage() : AiAnalysisPage(id: id); @@ -77,11 +79,12 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return ReportOriginalPage( url: params['url'] ?? '', title: params['title'] ?? '原始报告', + fileType: params['fileType'] ?? 'Image', ); case 'exercisePlan': - return const ExercisePlanPage(); + return const EnterpriseExercisePlanPage(); case 'exercisePlanDetail': - return const ExercisePlanPage(); + return const EnterpriseExercisePlanPage(); case 'exerciseCreate': return const ExercisePlanCreatePage(); case 'dietRecords': @@ -90,6 +93,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const DietCapturePage(); case 'profile': return const ProfilePage(); + case 'profileEdit': + return const ProfileEditPage(); case 'doctorProfile': return const DoctorProfileEditPage(); case 'doctorSettings': diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index c011bfc..675814f 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -45,8 +45,7 @@ Future showAppDatePicker( final monthCtrl = FixedExtentScrollController(initialItem: m - 1); final dayCtrl = FixedExtentScrollController(initialItem: d - 1); - int daysInMonth(int year, int month) => - DateTime(year, month + 1, 0).day; + int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; void clampDay() { final maxDay = daysInMonth(y, m); @@ -70,12 +69,22 @@ Future showAppDatePicker( children: [ CupertinoButton( padding: EdgeInsets.zero, - child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))), + child: const Text( + '取消', + style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), onPressed: () => Navigator.pop(ctx), ), CupertinoButton( padding: EdgeInsets.zero, - child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), + child: const Text( + '确定', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), onPressed: () { clampDay(); Navigator.pop(ctx, DateTime(y, m, d)); @@ -87,37 +96,68 @@ Future showAppDatePicker( Expanded( child: Row( children: [ - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: yearCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) { - y = minDate.year + i; - clampDay(); - final maxDay = daysInMonth(y, m); - dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble()); - }, - children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))], - ))), - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: monthCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) { - m = i + 1; - clampDay(); - final maxDay = daysInMonth(y, m); - dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble()); - }, - children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: dayCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) => d = i + 1, - children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: yearCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) { + y = minDate.year + i; + clampDay(); + final maxDay = daysInMonth(y, m); + dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble()); + }, + children: [ + for (var i = minDate.year; i <= maxDate.year; i++) + Center(child: Text('$i', style: _pickerItemStyle)), + ], + ), + ), + ), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: monthCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) { + m = i + 1; + clampDay(); + final maxDay = daysInMonth(y, m); + dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble()); + }, + children: [ + for (var i = 1; i <= 12; i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: dayCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) => d = i + 1, + children: [ + for (var i = 1; i <= daysInMonth(y, m); i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), ], ), ), @@ -153,13 +193,24 @@ Future showAppTimePicker( children: [ CupertinoButton( padding: EdgeInsets.zero, - child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))), + child: const Text( + '取消', + style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), onPressed: () => Navigator.pop(ctx), ), CupertinoButton( padding: EdgeInsets.zero, - child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), - onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)), + child: const Text( + '确定', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + onPressed: () => + Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)), ), ], ), @@ -168,20 +219,46 @@ Future showAppTimePicker( child: Row( children: [ const Spacer(flex: 1), - Expanded(flex: 2, child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: hourCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) => hour = i, - children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), - Expanded(flex: 2, child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: minuteCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) => minute = i, - children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), + Expanded( + flex: 2, + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: hourCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) => hour = i, + children: [ + for (var i = 0; i <= 23; i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), + Expanded( + flex: 2, + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: minuteCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) => minute = i, + children: [ + for (var i = 0; i <= 59; i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), const Spacer(flex: 1), ], ), @@ -211,8 +288,7 @@ Future showAppDateTimePicker( final hourCtrl = FixedExtentScrollController(initialItem: h); final minuteCtrl = FixedExtentScrollController(initialItem: min); - int daysInMonth(int year, int month) => - DateTime(year, month + 1, 0).day; + int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day; void clampDay() { final maxDay = daysInMonth(y, m); @@ -236,12 +312,22 @@ Future showAppDateTimePicker( children: [ CupertinoButton( padding: EdgeInsets.zero, - child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))), + child: const Text( + '取消', + style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), onPressed: () => Navigator.pop(ctx), ), CupertinoButton( padding: EdgeInsets.zero, - child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), + child: const Text( + '确定', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), onPressed: () { clampDay(); Navigator.pop(ctx, DateTime(y, m, d, h, min)); @@ -253,41 +339,102 @@ Future showAppDateTimePicker( Expanded( child: Row( children: [ - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: yearCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); }, - children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))], - ))), - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: monthCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) { m = i + 1; clampDay(); }, - children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: dayCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) => d = i + 1, - children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: hourCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) => h = i, - children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), - Expanded(child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: minuteCtrl, - itemExtent: 40, - onSelectedItemChanged: (i) => min = i, - children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))], - ))), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: yearCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) { + y = minDate.year + i; + clampDay(); + }, + children: [ + for (var i = minDate.year; i <= maxDate.year; i++) + Center(child: Text('$i', style: _pickerItemStyle)), + ], + ), + ), + ), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: monthCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) { + m = i + 1; + clampDay(); + }, + children: [ + for (var i = 1; i <= 12; i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: dayCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) => d = i + 1, + children: [ + for (var i = 1; i <= daysInMonth(y, m); i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: hourCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) => h = i, + children: [ + for (var i = 0; i <= 23; i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), + Expanded( + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: minuteCtrl, + itemExtent: 40, + onSelectedItemChanged: (i) => min = i, + children: [ + for (var i = 0; i <= 59; i++) + Center( + child: Text( + i.toString().padLeft(2, '0'), + style: _pickerItemStyle, + ), + ), + ], + ), + ), + ), ], ), ), @@ -322,28 +469,47 @@ Future showAppCountPicker( children: [ CupertinoButton( padding: EdgeInsets.zero, - child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))), + child: const Text( + '取消', + style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), onPressed: () => Navigator.pop(ctx), ), CupertinoButton( padding: EdgeInsets.zero, - child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), + child: const Text( + '确定', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), onPressed: () => Navigator.pop(ctx, selected), ), ], ), ), Expanded( - child: _wrapPicker(CupertinoPicker( - selectionOverlay: null, - scrollController: FixedExtentScrollController(initialItem: selected - min), - itemExtent: 40, - onSelectedItemChanged: (i) => selected = i + min, - children: [ - for (var i = min; i <= max; i++) - Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)), - ], - )), + child: _wrapPicker( + CupertinoPicker( + selectionOverlay: null, + scrollController: FixedExtentScrollController( + initialItem: selected - min, + ), + itemExtent: 40, + onSelectedItemChanged: (i) => selected = i + min, + children: [ + for (var i = min; i <= max; i++) + Center( + child: Text( + label.isEmpty ? '$i' : '$i$label', + style: _pickerItemStyle, + ), + ), + ], + ), + ), ), ], ), @@ -362,7 +528,16 @@ class AppBackground extends StatelessWidget { final content = safeArea ? SafeArea(child: child) : child; // 全局浅灰白背景,已弃用紫色渐变。 - return ColoredBox(color: const Color(0xFFF6F8FC), child: content); + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)], + ), + ), + child: content, + ); } } @@ -386,10 +561,13 @@ class GradientScaffold extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - // 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。 - backgroundColor: const Color(0xFFF6F8FC), + // 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现。 + backgroundColor: AppColors.background, appBar: appBar, - body: body, + body: ColoredBox( + color: AppColors.background, + child: SizedBox.expand(child: body ?? const SizedBox.shrink()), + ), floatingActionButton: floatingActionButton, drawer: drawer, bottomNavigationBar: bottomNavigationBar, @@ -485,22 +663,22 @@ class AppTheme { surface: surface, brightness: Brightness.light, ), - scaffoldBackgroundColor: Colors.transparent, + scaffoldBackgroundColor: AppColors.background, splashColor: primary.withValues(alpha: 0.06), highlightColor: Colors.transparent, hoverColor: primaryLight.withValues(alpha: 0.55), fontFamily: null, - appBarTheme: AppBarTheme( - backgroundColor: Colors.white.withValues(alpha: 0.9), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, foregroundColor: text, elevation: 0, centerTitle: true, scrolledUnderElevation: 0, surfaceTintColor: Colors.transparent, - titleTextStyle: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w700, + titleTextStyle: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, color: text, ), toolbarHeight: 52, diff --git a/health_app/lib/core/local_database.dart b/health_app/lib/core/local_database.dart index c0b148f..feee65a 100644 --- a/health_app/lib/core/local_database.dart +++ b/health_app/lib/core/local_database.dart @@ -34,17 +34,19 @@ class LocalDatabase { Future write(String key, String value) async { final db = await database; - await db.insert( - 'kv_store', - {'key': key, 'value': value}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('kv_store', { + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); } Future read(String key) async { final db = await database; - final result = - await db.query('kv_store', where: 'key = ?', whereArgs: [key]); + final result = await db.query( + 'kv_store', + where: 'key = ?', + whereArgs: [key], + ); if (result.isEmpty) return null; return result.first['value'] as String?; } diff --git a/health_app/lib/pages/admin/admin_add_doctor_page.dart b/health_app/lib/pages/admin/admin_add_doctor_page.dart index c08145f..2836576 100644 --- a/health_app/lib/pages/admin/admin_add_doctor_page.dart +++ b/health_app/lib/pages/admin/admin_add_doctor_page.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; -import '../../providers/data_providers.dart' show adminServiceProvider; +import '../../providers/data_providers.dart' + show adminServiceProvider, doctorListProvider; import '../../widgets/app_toast.dart'; class AdminAddDoctorPage extends ConsumerStatefulWidget { @@ -48,6 +49,7 @@ class _AdminAddDoctorPageState extends ConsumerState { 'professionalDirection': _directionCtrl.text.trim(), }); if (mounted) { + ref.invalidate(doctorListProvider); AppToast.show(context, '添加成功', type: AppToastType.success); popRoute(ref); } @@ -65,7 +67,7 @@ class _AdminAddDoctorPageState extends ConsumerState { return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: const Text( diff --git a/health_app/lib/pages/admin/admin_home_page.dart b/health_app/lib/pages/admin/admin_home_page.dart index 28ad397..5d70d21 100644 --- a/health_app/lib/pages/admin/admin_home_page.dart +++ b/health_app/lib/pages/admin/admin_home_page.dart @@ -5,25 +5,32 @@ import '../../widgets/admin_drawer.dart'; import 'admin_doctors_page.dart'; import 'admin_patients_page.dart'; -final adminPageProvider = NotifierProvider(AdminPageNotifier.new); +final adminPageProvider = NotifierProvider( + AdminPageNotifier.new, +); class AdminPageNotifier extends Notifier { - @override String build() => 'doctors'; + @override + String build() => 'doctors'; void set(String page) => state = page; } class AdminHomePage extends ConsumerWidget { const AdminHomePage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { + @override + Widget build(BuildContext context, WidgetRef ref) { final page = ref.watch(adminPageProvider); return Scaffold( backgroundColor: AppColors.pageGrey, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, - title: const Text('系统管理', style: TextStyle(color: AppColors.textPrimary)), + title: const Text( + '系统管理', + style: TextStyle(color: AppColors.textPrimary), + ), iconTheme: const IconThemeData(color: AppColors.textPrimary), ), drawer: const AdminDrawer(), diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index a5f8120..ec990f4 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; @@ -57,7 +60,8 @@ class _LoginPageState extends ConsumerState { setState(() => _error = result.error); return; } - if (result.devCode != null) _codeCtrl.text = result.devCode!; + if (!mounted) return; + if (kDebugMode && result.devCode != null) _codeCtrl.text = result.devCode!; setState(() => _countdown = 60); _startCountdown(); } @@ -70,6 +74,18 @@ class _LoginPageState extends ConsumerState { } Future _submit() async { + if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { + setState(() => _error = '请输入手机号和验证码'); + return; + } + if (!_isLogin && _nameCtrl.text.trim().isEmpty) { + setState(() => _error = '请输入姓名'); + return; + } + if (!_isLogin && _selectedDoctorId == null) { + setState(() => _error = '请选择健康服务医生'); + return; + } if (!_agreed) { final accepted = await _showAgreementDialog(); if (!mounted || accepted != true) return; @@ -78,10 +94,6 @@ class _LoginPageState extends ConsumerState { _error = null; }); } - if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { - setState(() => _error = '请输入手机号和验证码'); - return; - } setState(() { _loading = true; _error = null; @@ -94,20 +106,6 @@ class _LoginPageState extends ConsumerState { .read(authProvider.notifier) .login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); } else { - if (_selectedDoctorId == null) { - setState(() { - _loading = false; - _error = '请选择医生'; - }); - return; - } - if (_nameCtrl.text.trim().isEmpty) { - setState(() { - _loading = false; - _error = '请输入姓名'; - }); - return; - } err = await ref .read(authProvider.notifier) .register( @@ -118,27 +116,14 @@ class _LoginPageState extends ConsumerState { ); } + if (!mounted) return; setState(() => _loading = false); if (err != null) { if (err.contains('未注册')) setState(() => _isLogin = false); setState(() => _error = err); return; } - if (!_isLogin) { - setState(() { - _isLogin = true; - _successMsg = '注册成功,请登录'; - _error = null; - _phoneCtrl.clear(); - _codeCtrl.clear(); - _nameCtrl.clear(); - _selectedDoctorId = null; - _selectedDoctorName = null; - }); - ref.read(authProvider.notifier).logout(); - } else { - _goHome(); - } + // 登录和注册成功后的页面切换统一由根导航监听认证状态处理。 } Future _showAgreementDialog() { @@ -152,10 +137,10 @@ class _LoginPageState extends ConsumerState { child: Container( width: double.infinity, constraints: const BoxConstraints(maxWidth: 380), - padding: const EdgeInsets.fromLTRB(22, 28, 22, 22), + padding: const EdgeInsets.fromLTRB(22, 26, 22, 22), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(28), + borderRadius: AppRadius.xlBorder, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.14), @@ -247,7 +232,7 @@ class _LoginPageState extends ConsumerState { alignment: Alignment.center, decoration: BoxDecoration( gradient: _loginActionGradient, - borderRadius: BorderRadius.circular(18), + borderRadius: AppRadius.mdBorder, boxShadow: AppColors.buttonShadow, ), child: const Text( @@ -270,27 +255,13 @@ class _LoginPageState extends ConsumerState { ); } - void _goHome() { - ref.invalidate(latestHealthProvider); - ref.invalidate(medicationListProvider); - ref.invalidate(medicationReminderProvider); - ref.invalidate(currentExercisePlanProvider); - final role = ref.read(authProvider).user?.role ?? 'User'; - if (role == 'Admin') { - goRoute(ref, 'adminHome'); - } else if (role == 'Doctor') { - goRoute(ref, 'doctorHome'); - } else { - goRoute(ref, 'home'); - } - } - void _showDoctorPicker() { showModalBottomSheet( context: context, backgroundColor: Colors.white, + showDragHandle: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), ), builder: (ctx) => Consumer( builder: (ctx, ref, _) { @@ -311,9 +282,9 @@ class _LoginPageState extends ConsumerState { child: Column( children: [ const Padding( - padding: EdgeInsets.all(18), + padding: EdgeInsets.fromLTRB(20, 2, 20, 16), child: Text( - '请选择医生', + '选择健康服务医生', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, @@ -321,16 +292,34 @@ class _LoginPageState extends ConsumerState { ), ), ), - const Divider(height: 1, color: AppColors.divider), Expanded( - child: ListView.builder( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), itemCount: active.length, + separatorBuilder: (_, _) => const Padding( + padding: EdgeInsets.only(left: 50), + child: Divider(height: 1, color: AppColors.divider), + ), itemBuilder: (_, i) { final d = active[i]; final name = d['name'] ?? ''; final title = d['title'] ?? ''; final dept = d['department'] ?? ''; return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: AppRadius.smBorder, + ), + child: const Icon( + LucideIcons.stethoscope, + size: 20, + color: AppColors.primaryDark, + ), + ), title: Text( name, style: const TextStyle( @@ -345,9 +334,10 @@ class _LoginPageState extends ConsumerState { ), ), trailing: _selectedDoctorId == d['id']?.toString() - ? const AppGradientIcon( - icon: Icons.check_circle, + ? const Icon( + LucideIcons.circleCheck, size: 22, + color: AppColors.primaryDark, ) : null, onTap: () { @@ -379,11 +369,6 @@ class _LoginPageState extends ConsumerState { @override Widget build(BuildContext context) { - final authState = ref.watch(authProvider); - if (authState.isLoggedIn && !authState.isLoading) { - WidgetsBinding.instance.addPostFrameCallback((_) => _goHome()); - } - return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.white, @@ -403,140 +388,146 @@ class _LoginPageState extends ConsumerState { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.white.withValues(alpha: 0.10), - Colors.white.withValues(alpha: 0.28), - Colors.white.withValues(alpha: 0.54), + Colors.white.withValues(alpha: 0.02), + Colors.white.withValues(alpha: 0.12), + Colors.white.withValues(alpha: 0.40), ], stops: const [0.0, 0.52, 1.0], ), ), ), ), - SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 28, 20, 24), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: (constraints.maxHeight - 52).clamp( - 0.0, - double.infinity, + AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 24), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: (constraints.maxHeight - 52).clamp( + 0.0, + double.infinity, + ), ), - ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 430), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _LoginHero(), - const SizedBox(height: 28), - _LoginFormCard( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_successMsg != null) - _NoticeBanner( - text: _successMsg!, - success: true, - ), - if (!_isLogin) ...[ - _DoctorSelector( - name: _selectedDoctorName, - onTap: _showDoctorPicker, - ), - const SizedBox(height: 12), - _TextField( - _nameCtrl, - '姓名', - TextInputType.name, - 20, - icon: Icons.person_outline_rounded, - ), - const SizedBox(height: 12), - ], - _TextField( - _phoneCtrl, - '手机号', - TextInputType.phone, - 11, - icon: Icons.local_phone_rounded, - gradientIcon: true, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _TextField( - _codeCtrl, - '验证码', - TextInputType.number, - 6, - icon: Icons.sms_rounded, - gradientIcon: true, - ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 430), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _LoginHero(), + const SizedBox(height: 22), + _LoginFormCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_successMsg != null) + _NoticeBanner( + text: _successMsg!, + success: true, ), - const SizedBox(width: 10), - _SmsButton( - sending: _sending, - countdown: _countdown, - onTap: (_countdown > 0 || _sending) - ? null - : _sendSms, + if (!_isLogin) ...[ + _DoctorSelector( + name: _selectedDoctorName, + onTap: _showDoctorPicker, + ), + const SizedBox(height: 12), + _TextField( + _nameCtrl, + '姓名', + TextInputType.name, + 20, + icon: LucideIcons.userRound, + ), + const SizedBox(height: 12), + ], + _TextField( + _phoneCtrl, + '手机号', + TextInputType.phone, + 11, + icon: LucideIcons.phone, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _TextField( + _codeCtrl, + '验证码', + TextInputType.number, + 6, + icon: LucideIcons.messageSquareText, + ), + ), + const SizedBox(width: 10), + _SmsButton( + sending: _sending, + countdown: _countdown, + onTap: (_countdown > 0 || _sending) + ? null + : _sendSms, + ), + ], + ), + const SizedBox(height: 14), + _Agreement( + agreed: _agreed, + onTap: () => + setState(() => _agreed = !_agreed), + onTermsTap: () => + _openStaticText('terms'), + onPrivacyTap: () => + _openStaticText('privacy'), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + _NoticeBanner( + text: _error!, + success: false, ), ], - ), - const SizedBox(height: 14), - _Agreement( - agreed: _agreed, - onTap: () => - setState(() => _agreed = !_agreed), - onTermsTap: () => _openStaticText('terms'), - onPrivacyTap: () => - _openStaticText('privacy'), - ), - if (_error != null) ...[ - const SizedBox(height: 12), - _NoticeBanner( - text: _error!, - success: false, + const SizedBox(height: 22), + _PrimaryButton( + loading: _loading, + label: _isLogin ? '登录' : '注册', + onTap: _loading ? null : _submit, ), - ], - const SizedBox(height: 22), - _PrimaryButton( - loading: _loading, - label: _isLogin ? '登录' : '注册', - onTap: _loading ? null : _submit, - ), - const SizedBox(height: 16), - GestureDetector( - onTap: () => setState(() { - _isLogin = !_isLogin; - _error = null; - _successMsg = null; - }), - child: Center( - child: AppGradientText( - _isLogin ? '没有账号?去注册' : '已有账号?去登录', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, + const SizedBox(height: 16), + GestureDetector( + onTap: () => setState(() { + _isLogin = !_isLogin; + _error = null; + _successMsg = null; + }), + child: Center( + child: AppGradientText( + _isLogin ? '没有账号?去注册' : '已有账号?去登录', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ], @@ -553,11 +544,11 @@ class _LoginHero extends StatelessWidget { return Column( children: [ _BrandMark(), - const SizedBox(height: 16), + const SizedBox(height: 12), const Text( '小脉健康', style: TextStyle( - fontSize: 30, + fontSize: 28, fontWeight: FontWeight.w900, color: AppColors.textPrimary, letterSpacing: 0, @@ -589,14 +580,13 @@ class _LoginFormCard extends StatelessWidget { return Container( padding: const EdgeInsets.fromLTRB(18, 18, 18, 20), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.94), - borderRadius: BorderRadius.circular(22), - border: Border.all(color: AppColors.border, width: 1.1), + color: Colors.white.withValues(alpha: 0.88), + borderRadius: AppRadius.xlBorder, boxShadow: [ BoxShadow( - color: const Color(0xFF475467).withValues(alpha: 0.10), - blurRadius: 30, - offset: const Offset(0, 16), + color: const Color(0xFF475467).withValues(alpha: 0.07), + blurRadius: 24, + offset: const Offset(0, 12), ), ], ), @@ -609,10 +599,22 @@ class _BrandMark extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 132, - height: 132, - child: Transform.scale( - scale: 1.08, + width: 108, + height: 108, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.86)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.10), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), child: Image.asset( 'assets/branding/health_icon_master.png', fit: BoxFit.cover, @@ -628,16 +630,8 @@ class _TextField extends StatelessWidget { final TextInputType type; final int maxLen; final IconData? icon; - final bool gradientIcon; - const _TextField( - this.ctrl, - this.hint, - this.type, - this.maxLen, { - this.icon, - this.gradientIcon = false, - }); + const _TextField(this.ctrl, this.hint, this.type, this.maxLen, {this.icon}); @override Widget build(BuildContext context) => TextField( @@ -654,38 +648,22 @@ class _TextField extends StatelessWidget { counterText: '', prefixIcon: icon == null ? null - : gradientIcon - ? _GradientPrefixIcon(icon: icon!) - : Icon(icon, size: 20, color: AppColors.textSecondary), + : Icon(icon, size: 20, color: AppColors.primaryDark), filled: true, - fillColor: const Color(0xFFF8FAFC), + fillColor: AppColors.cardInner, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: AppColors.border, width: 1.1), + borderRadius: AppRadius.mdBorder, + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: AppColors.auraIndigo, width: 1.2), + borderRadius: AppRadius.mdBorder, + borderSide: const BorderSide(color: AppColors.primary, width: 1.2), ), ), ); } -class _GradientPrefixIcon extends StatelessWidget { - final IconData icon; - const _GradientPrefixIcon({required this.icon}); - - @override - Widget build(BuildContext context) { - return AppGradientIcon( - icon: icon, - size: 23, - gradient: _LoginPageState._loginActionGradient, - ); - } -} - class _DoctorSelector extends StatelessWidget { final String? name; final VoidCallback onTap; @@ -699,21 +677,20 @@ class _DoctorSelector extends StatelessWidget { height: 50, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border, width: 1.1), + color: AppColors.cardInner, + borderRadius: AppRadius.mdBorder, ), child: Row( children: [ const Icon( - Icons.medical_services_outlined, + LucideIcons.stethoscope, size: 20, color: AppColors.textSecondary, ), const SizedBox(width: 12), Expanded( child: Text( - name ?? '请选择医生', + name ?? '请选择健康服务医生(必选)', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, @@ -723,7 +700,11 @@ class _DoctorSelector extends StatelessWidget { ), ), ), - const Icon(Icons.keyboard_arrow_down, color: AppColors.textHint), + const Icon( + LucideIcons.chevronDown, + size: 19, + color: AppColors.textHint, + ), ], ), ), @@ -751,11 +732,10 @@ class _SmsButton extends StatelessWidget { height: 50, alignment: Alignment.center, decoration: BoxDecoration( - gradient: disabled ? null : _LoginPageState._loginActionGradient, - color: disabled ? AppColors.cardInner : null, - borderRadius: BorderRadius.circular(14), + color: disabled ? AppColors.cardInner : Colors.white, + borderRadius: AppRadius.mdBorder, border: Border.all( - color: disabled ? AppColors.border : Colors.transparent, + color: disabled ? AppColors.borderLight : AppColors.primaryLight, ), ), child: Text( @@ -767,7 +747,7 @@ class _SmsButton extends StatelessWidget { style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, - color: disabled ? AppColors.textSecondary : Colors.white, + color: disabled ? AppColors.textSecondary : AppColors.primaryDark, ), ), ), @@ -805,7 +785,7 @@ class _Agreement extends StatelessWidget { decoration: BoxDecoration( gradient: agreed ? _LoginPageState._loginActionGradient : null, color: agreed ? null : Colors.white, - borderRadius: BorderRadius.circular(5), + borderRadius: AppRadius.xsBorder, border: Border.all( color: agreed ? Colors.transparent : AppColors.border, ), @@ -856,7 +836,7 @@ class _NoticeBanner extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: success ? AppColors.successLight : AppColors.errorLight, - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.mdBorder, border: Border.all( color: success ? AppColors.success.withValues(alpha: 0.16) @@ -907,7 +887,7 @@ class _PrimaryButton extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( gradient: _LoginPageState._loginActionGradient, - borderRadius: BorderRadius.circular(14), + borderRadius: AppRadius.mdBorder, boxShadow: AppColors.buttonShadow, ), child: loading diff --git a/health_app/lib/pages/care_plan_ui_logic.dart b/health_app/lib/pages/care_plan_ui_logic.dart new file mode 100644 index 0000000..22a7603 --- /dev/null +++ b/health_app/lib/pages/care_plan_ui_logic.dart @@ -0,0 +1,41 @@ +enum CarePlanPhase { active, upcoming, ended } + +CarePlanPhase resolveCarePlanPhase({ + bool enabled = true, + String? startDate, + String? endDate, + DateTime? today, +}) { + if (!enabled) return CarePlanPhase.ended; + + final current = _dateOnly(today ?? DateTime.now()); + final start = DateTime.tryParse(startDate ?? ''); + final end = DateTime.tryParse(endDate ?? ''); + if (start != null && _dateOnly(start).isAfter(current)) { + return CarePlanPhase.upcoming; + } + if (end != null && _dateOnly(end).isBefore(current)) { + return CarePlanPhase.ended; + } + return CarePlanPhase.active; +} + +String? validateMedicationForm({ + required String name, + required String dosage, + required List times, + required DateTime startDate, + DateTime? endDate, +}) { + if (name.trim().isEmpty) return '请输入药品名称'; + if (dosage.trim().isEmpty) return '请输入服药剂量'; + if (times.isEmpty) return '请至少设置一个服药时间'; + if (times.toSet().length != times.length) return '服药时间不能重复'; + if (endDate != null && _dateOnly(endDate).isBefore(_dateOnly(startDate))) { + return '结束日期不能早于开始日期'; + } + return null; +} + +DateTime _dateOnly(DateTime value) => + DateTime(value.year, value.month, value.day); diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index c121890..694cd17 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; 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_design_tokens.dart'; import '../../core/app_theme.dart'; @@ -10,6 +11,8 @@ import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; import '../../providers/data_providers.dart'; +import '../../widgets/common_widgets.dart'; +import '../../widgets/app_empty_state.dart'; const String defaultTrendMetricType = 'blood_pressure'; @@ -21,6 +24,18 @@ const Set supportedTrendMetricTypes = { 'weight', }; +class _TrendColors { + _TrendColors._(); + + static const bloodPressure = Color(0xFFD45B68); + static const heartRate = Color(0xFFD97757); + static const glucose = Color(0xFF4F6EF7); + static const spo2 = Color(0xFF168F7A); + static const weight = Color(0xFF7559C7); + static const systolic = bloodPressure; + static const diastolic = Color(0xFF3B82F6); +} + String normalizeTrendMetricType(String? metricType) { if (metricType == null) return defaultTrendMetricType; return supportedTrendMetricTypes.contains(metricType) @@ -28,6 +43,42 @@ String normalizeTrendMetricType(String? metricType) { : defaultTrendMetricType; } +List> filterTrendRecordsForPeriod( + List> records, + int days, + DateTime now, +) { + final today = DateTime(now.year, now.month, now.day); + final start = today.subtract(Duration(days: days - 1)); + return records.where((record) { + final date = record['date']; + return date is DateTime && !date.isBefore(start); + }).toList(); +} + +String formatTrendNumber(num? value) { + if (value == null) return '--'; + final number = value.toDouble(); + return number == number.roundToDouble() + ? number.toInt().toString() + : number.toStringAsFixed(1); +} + +String trendStatusLabel(Map record, String metricType) { + if (record['isAbnormal'] != true) return ''; + if (metricType != 'blood_pressure') return '异常'; + + final systolic = (record['systolic'] as num?)?.toDouble(); + final diastolic = (record['diastolic'] as num?)?.toDouble(); + if (systolic != null && systolic >= 140) return '收缩压偏高'; + if (diastolic != null && diastolic >= 90) return '舒张压偏高'; + if ((systolic != null && systolic < 90) || + (diastolic != null && diastolic < 60)) { + return '血压偏低'; + } + return '血压异常'; +} + /// 健康概览趋势页 — 五大指标合到一个页面 class TrendPage extends ConsumerStatefulWidget { final String? metricType; // 可选初始选中指标 @@ -41,7 +92,9 @@ class _TrendPageState extends ConsumerState { String _selected = defaultTrendMetricType; List> _allRecords = []; List> _filtered = []; + Set _failedTypes = {}; bool _loading = true; + int _periodDays = 7; int? _selectedIdx; // 当前选中的数据点(_filtered 索引),null = 未选中 /// 计算"好看"的坐标轴步长,最小为 1(保证标签都是整数)。 @@ -75,11 +128,36 @@ class _TrendPageState extends ConsumerState { } static const _metrics = [ - {'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)}, - {'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)}, - {'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)}, - {'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)}, - {'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)}, + { + 'key': 'blood_pressure', + 'label': '血压', + 'color': _TrendColors.bloodPressure, + 'icon': LucideIcons.gauge, + }, + { + 'key': 'heart_rate', + 'label': '心率', + 'color': _TrendColors.heartRate, + 'icon': LucideIcons.heartPulse, + }, + { + 'key': 'glucose', + 'label': '血糖', + 'color': _TrendColors.glucose, + 'icon': LucideIcons.droplet, + }, + { + 'key': 'spo2', + 'label': '血氧', + 'color': _TrendColors.spo2, + 'icon': LucideIcons.wind, + }, + { + 'key': 'weight', + 'label': '体重', + 'color': _TrendColors.weight, + 'icon': LucideIcons.scale, + }, ]; static const _units = { @@ -101,6 +179,11 @@ class _TrendPageState extends ConsumerState { String get _unit => _units[_selected] ?? ''; String get _name => _names[_selected] ?? ''; bool get _isBP => _selected == 'blood_pressure'; + String get _selectedApiType => _apiTypeForMetric(_selected); + List> get _chartRecords => + filterTrendRecordsForPeriod(_filtered, _periodDays, DateTime.now()); + Map? get _latestRecord => + _filtered.isEmpty ? null : _filtered.last; Color get _color => _metrics.firstWhere( (m) => m['key'] == normalizeTrendMetricType(_selected), @@ -119,41 +202,47 @@ class _TrendPageState extends ConsumerState { try { final api = ref.read(apiClientProvider); final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight']; - final all = >[]; - for (final t in types) { - try { - final res = await api.get( - '/api/health-records/trend', - queryParameters: {'type': t, 'period': 365}, - ); - final list = - (res.data['data'] as List?)?.cast>() ?? []; - for (final r in list) { - all.add({ - 'id': r['id'], - 'type': t, - 'date': - DateTime.tryParse( - r['recordedAt']?.toString() ?? '', - )?.toLocal() ?? - DateTime.now(), - 'systolic': r['systolic'], - 'diastolic': r['diastolic'], - 'value': r['value'], - 'unit': r['unit'] ?? '', - 'isAbnormal': r['isAbnormal'] == true, - }); + final failedTypes = {}; + final groups = await Future.wait( + types.map((type) async { + try { + final res = await api.get( + '/api/health-records/trend', + queryParameters: {'type': type, 'period': 365}, + ); + final list = + (res.data['data'] as List?)?.cast>() ?? []; + return list.map((record) { + return { + 'id': record['id'], + 'type': type, + 'date': + DateTime.tryParse( + record['recordedAt']?.toString() ?? '', + )?.toLocal() ?? + DateTime.now(), + 'systolic': record['systolic'], + 'diastolic': record['diastolic'], + 'value': record['value'], + 'unit': record['unit'] ?? '', + 'isAbnormal': record['isAbnormal'] == true, + }; + }).toList(); + } catch (e) { + failedTypes.add(type); + debugPrint('[Trend] 加载 $type 趋势失败: $e'); + return >[]; } - } catch (e) { - debugPrint('[Trend] 加载趋势失败: $e'); - } - } + }), + ); + final all = groups.expand((group) => group).toList(); all.sort( (a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime), ); if (mounted) { setState(() { _allRecords = all; + _failedTypes = failedTypes; _loading = false; }); _filter(); @@ -164,17 +253,10 @@ class _TrendPageState extends ConsumerState { } void _filter() { - final typeName = _selected == 'blood_pressure' - ? 'BloodPressure' - : _selected == 'heart_rate' - ? 'HeartRate' - : _selected == 'glucose' - ? 'Glucose' - : _selected == 'spo2' - ? 'SpO2' - : 'Weight'; setState(() { - _filtered = _allRecords.where((r) => r['type'] == typeName).toList(); + _filtered = _allRecords + .where((record) => record['type'] == _selectedApiType) + .toList(); _filtered.sort( (a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime), ); @@ -184,11 +266,22 @@ class _TrendPageState extends ConsumerState { void _switchMetric(String key) { setState(() { _selected = key; + _periodDays = key == 'weight' ? 30 : 7; _selectedIdx = null; }); _filter(); } + String _apiTypeForMetric(String key) { + return switch (key) { + 'blood_pressure' => 'BloodPressure', + 'heart_rate' => 'HeartRate', + 'glucose' => 'Glucose', + 'spo2' => 'SpO2', + _ => 'Weight', + }; + } + // ---- 手动录入 ---- void _showAddDialog() { final ctrl1 = TextEditingController(); @@ -252,29 +345,43 @@ class _TrendPageState extends ConsumerState { const SizedBox(height: 16), SizedBox( width: double.infinity, - child: ElevatedButton( + child: AppGradientOutlineButton( + label: '确认录入', onPressed: () async { final v1 = double.tryParse(ctrl1.text.trim()); - if (v1 == null) return; + final v2 = _isBP ? double.tryParse(ctrl2.text.trim()) : null; + if (v1 == null || (_isBP && v2 == null)) { + AppToast.show( + ctx, + _isBP ? '请完整填写收缩压和舒张压' : '请输入有效的$_name数值', + type: AppToastType.error, + ); + return; + } + if (_isBP && + (v1 < 50 || + v1 > 250 || + v2! < 30 || + v2 > 150 || + v1 <= v2)) { + AppToast.show( + ctx, + '请检查血压数值,收缩压应高于舒张压', + type: AppToastType.error, + ); + return; + } try { final api = ref.read(apiClientProvider); final body = { - 'type': _selected == 'blood_pressure' - ? 'BloodPressure' - : _selected == 'heart_rate' - ? 'HeartRate' - : _selected == 'glucose' - ? 'Glucose' - : _selected == 'spo2' - ? 'SpO2' - : 'Weight', + 'type': _selectedApiType, 'source': 'Manual', 'recordedAt': DateTime.now().toUtc().toIso8601String(), 'unit': _unit, }; if (_isBP) { body['systolic'] = v1.toInt(); - body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0; + body['diastolic'] = v2!.toInt(); } else { body['value'] = v1; } @@ -289,18 +396,13 @@ class _TrendPageState extends ConsumerState { if (!mounted) { return; } - AppToast.show(context, '录入失败', type: AppToastType.error); + AppToast.show( + context, + '录入失败,请检查数值或网络后重试', + type: AppToastType.error, + ); } }, - style: ElevatedButton.styleFrom( - backgroundColor: _color, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - child: const Text('确认录入', style: TextStyle(fontSize: 19)), ), ), const SizedBox(height: 20), @@ -321,10 +423,9 @@ class _TrendPageState extends ConsumerState { title: const Text('健康概览'), centerTitle: true, ), - floatingActionButton: FloatingActionButton( + floatingActionButton: AppCreateFab( + tooltip: '添加健康记录', onPressed: _showAddDialog, - backgroundColor: _color, - child: const Icon(Icons.add), ), body: _loading ? const Center( @@ -339,8 +440,18 @@ class _TrendPageState extends ConsumerState { children: [ _buildMetricTabs(), const SizedBox(height: 16), + if (_latestRecord != null) ...[ + _buildLatestSummary(), + const SizedBox(height: 16), + ], + _buildPeriodSelector(), + const SizedBox(height: 12), _buildChart(), - const SizedBox(height: 20), + if (_chartRecords.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildStatistics(), + ], + const SizedBox(height: 24), _buildHistory(), const SizedBox(height: 80), ], @@ -365,18 +476,33 @@ class _TrendPageState extends ConsumerState { margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: sel ? color : Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: sel ? color : AppColors.border), - ), - child: Text( - m['label'] as String, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: sel ? Colors.white : AppColors.textSecondary, + color: sel ? AppColors.primarySoft : Colors.white, + borderRadius: AppRadius.xlBorder, + border: Border.all( + color: sel ? AppColors.primary : AppColors.borderLight, ), ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + m['icon'] as IconData, + size: 17, + color: sel ? AppColors.primaryDark : color, + ), + const SizedBox(width: 6), + Text( + m['label'] as String, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: sel + ? AppColors.primaryDark + : AppColors.textSecondary, + ), + ), + ], + ), ), ); }).toList(), @@ -384,46 +510,258 @@ class _TrendPageState extends ConsumerState { ); } + Widget _buildLatestSummary() { + final record = _latestRecord!; + final date = record['date'] as DateTime; + final status = trendStatusLabel(record, _selected); + final abnormal = status.isNotEmpty; + return Container( + width: double.infinity, + padding: AppSpacing.panel, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _color.withValues(alpha: 0.10), + borderRadius: AppRadius.mdBorder, + ), + child: Icon(_getMetricIcon(_selected), color: _color, size: 25), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最新$_name', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + spacing: 6, + children: [ + Text( + _displayValue(record), + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 1.05, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + _unit, + style: const TextStyle( + fontSize: 15, + color: AppColors.textHint, + ), + ), + ), + ], + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + abnormal ? status : '正常', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: abnormal ? AppColors.errorText : AppColors.successText, + ), + ), + const SizedBox(height: 6), + Text( + _formatRecordTime(date), + style: const TextStyle(fontSize: 13, color: AppColors.textHint), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPeriodSelector() { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + for (final days in const [7, 30, 90]) + Expanded( + child: InkWell( + onTap: () => setState(() { + _periodDays = days; + _selectedIdx = null; + }), + borderRadius: AppRadius.smBorder, + child: Container( + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _periodDays == days + ? AppColors.primaryLight + : Colors.transparent, + borderRadius: AppRadius.smBorder, + ), + child: Text( + '$days天', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: _periodDays == days + ? AppColors.primaryDark + : AppColors.textSecondary, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatistics() { + final records = _chartRecords; + final values = records + .map((record) => (record['value'] as num?)?.toDouble()) + .whereType() + .toList(); + String average; + String highest; + if (_isBP) { + final systolic = records + .map((record) => (record['systolic'] as num?)?.toDouble()) + .whereType() + .toList(); + final diastolic = records + .map((record) => (record['diastolic'] as num?)?.toDouble()) + .whereType() + .toList(); + average = systolic.isEmpty || diastolic.isEmpty + ? '--' + : '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}'; + final highestRecord = records + .where((record) { + return record['systolic'] is num && record['diastolic'] is num; + }) + .fold?>(null, (current, record) { + if (current == null) return record; + final currentValue = (current['systolic'] as num).toDouble(); + final recordValue = (record['systolic'] as num).toDouble(); + return recordValue > currentValue ? record : current; + }); + highest = highestRecord == null ? '--' : _displayValue(highestRecord); + } else { + average = values.isEmpty + ? '--' + : formatTrendNumber(values.reduce((a, b) => a + b) / values.length); + highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max)); + } + + return Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + _StatisticItem(label: '平均', value: average, unit: _unit), + const SizedBox(height: 46, child: VerticalDivider(width: 1)), + _StatisticItem(label: '最高', value: highest, unit: _unit), + const SizedBox(height: 46, child: VerticalDivider(width: 1)), + _StatisticItem(label: '记录', value: '${records.length}', unit: '次'), + ], + ), + ); + } + + String _displayValue(Map record) { + if (_isBP) { + return '${formatTrendNumber(record['systolic'] as num?)}/${formatTrendNumber(record['diastolic'] as num?)}'; + } + return formatTrendNumber(record['value'] as num?); + } + + String _formatRecordTime(DateTime date) { + final now = DateTime.now(); + final sameDay = + now.year == date.year && now.month == date.month && now.day == date.day; + final time = + '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + return sameDay ? '今天 $time' : '${date.month}月${date.day}日 $time'; + } + // ---- 趋势图 ---- Widget _buildChart() { - if (_filtered.isEmpty) { + if (_failedTypes.contains(_selectedApiType)) { + return Container( + height: 220, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_outlined, color: AppColors.textHint), + const SizedBox(height: 8), + Text('$_name数据加载失败'), + TextButton.icon( + onPressed: _loadAll, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('重新加载'), + ), + ], + ), + ); + } + final records = _chartRecords; + if (records.isEmpty) { return Container( height: 220, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, + borderRadius: AppRadius.lgBorder, ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.show_chart, - size: 48, - color: AppColors.textHint.withValues(alpha: 0.45), - ), - const SizedBox(height: 8), - Text( - '暂无$_name数据', - style: const TextStyle(color: AppColors.textHint), - ), - const SizedBox(height: 4), - const Text( - '点击右下角 + 手动录入', - style: TextStyle(fontSize: 15, color: AppColors.textHint), - ), - ], - ), + child: AppEmptyState( + icon: _getMetricIcon(_selected), + iconColor: _color, + title: '近$_periodDays天没有$_name数据', + subtitle: '点击右下角 + 手动录入', + padding: const EdgeInsets.all(20), ), ); } final spots = []; + final diastolicSpots = []; double minV = double.infinity, maxV = double.negativeInfinity; - for (int i = 0; i < _filtered.length; i++) { - final r = _filtered[i]; + for (int i = 0; i < records.length; i++) { + final r = records[i]; final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble(); @@ -431,6 +769,14 @@ class _TrendPageState extends ConsumerState { if (v < minV) minV = v; if (v > maxV) maxV = v; spots.add(FlSpot(i.toDouble(), v)); + if (_isBP) { + final diastolic = (r['diastolic'] as num?)?.toDouble(); + if (diastolic != null) { + minV = min(minV, diastolic); + maxV = max(maxV, diastolic); + diastolicSpots.add(FlSpot(i.toDouble(), diastolic)); + } + } } if (spots.isEmpty) return const SizedBox.shrink(); @@ -465,9 +811,7 @@ class _TrendPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(8, 20, 16, 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, + borderRadius: AppRadius.lgBorder, ), child: Column( children: [ @@ -484,7 +828,7 @@ class _TrendPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '趋势 (${_filtered.length}条)', + '趋势 (${records.length}条)', style: const TextStyle( fontSize: 15, color: AppColors.textSecondary, @@ -497,6 +841,17 @@ class _TrendPageState extends ConsumerState { ), ], ), + if (_isBP) ...[ + const SizedBox(height: 8), + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _ChartLegend(color: _TrendColors.systolic, label: '收缩压'), + SizedBox(width: 16), + _ChartLegend(color: _TrendColors.diastolic, label: '舒张压'), + ], + ), + ], const SizedBox(height: 10), SizedBox( height: 200, @@ -511,8 +866,8 @@ class _TrendPageState extends ConsumerState { String? tooltipText2; if (_selectedIdx != null && _selectedIdx! >= 0 && - _selectedIdx! < _filtered.length) { - final r = _filtered[_selectedIdx!]; + _selectedIdx! < records.length) { + final r = records[_selectedIdx!]; final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble(); @@ -536,10 +891,19 @@ class _TrendPageState extends ConsumerState { LineChart( LineChartData( minX: 0, - maxX: (spots.length - 1).toDouble(), + maxX: max(1, spots.length - 1).toDouble(), minY: minV, maxY: maxV, - gridData: const FlGridData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: yStep, + getDrawingHorizontalLine: (_) => const FlLine( + color: AppColors.divider, + strokeWidth: 0.8, + dashArray: [4, 4], + ), + ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( leftTitles: AxisTitles( @@ -567,41 +931,19 @@ class _TrendPageState extends ConsumerState { interval: xInterval, getTitlesWidget: (v, meta) { final idx = v.toInt(); - if (idx < 0 || idx >= _filtered.length) { + if (idx < 0 || idx >= records.length) { return const SizedBox.shrink(); } - final d = _filtered[idx]['date'] as DateTime; - // 当前可见刻度与上一个可见刻度相比,月份是否变化 - final prevIdx = idx - xInterval.toInt(); - final showMonth = - idx == 0 || - prevIdx < 0 || - (_filtered[prevIdx]['date'] as DateTime) - .month != - d.month; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${d.day}', - style: const TextStyle( - fontSize: 12, - color: AppColors.textHint, - fontWeight: FontWeight.w500, - ), + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _chartAxisLabel(records, idx), + style: const TextStyle( + fontSize: 11, + color: AppColors.textHint, + fontWeight: FontWeight.w500, ), - if (showMonth) ...[ - const SizedBox(height: 2), - Text( - '${d.month}月', - style: const TextStyle( - fontSize: 11, - color: AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ], - ], + ), ); }, ), @@ -641,23 +983,29 @@ class _TrendPageState extends ConsumerState { ); }, ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - _color.withValues(alpha: 0.22), - _color.withValues(alpha: 0.02), - ], - ), - ), - shadow: Shadow( - color: _color.withValues(alpha: 0.18), - blurRadius: 6, - offset: const Offset(0, 3), - ), + belowBarData: BarAreaData(show: false), ), + if (_isBP && diastolicSpots.isNotEmpty) + LineChartBarData( + spots: diastolicSpots, + isCurved: false, + color: _TrendColors.diastolic, + barWidth: 2.5, + isStrokeCapRound: true, + dotData: FlDotData( + show: diastolicSpots.length <= 30, + getDotPainter: (spot, _, _, _) => + FlDotCirclePainter( + radius: spot.x.toInt() == _selectedIdx + ? 5.5 + : 3.5, + color: Colors.white, + strokeWidth: 2, + strokeColor: _TrendColors.diastolic, + ), + ), + belowBarData: BarAreaData(show: false), + ), ], lineTouchData: LineTouchData( handleBuiltInTouches: false, @@ -693,7 +1041,7 @@ class _TrendPageState extends ConsumerState { ), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(8), + borderRadius: AppRadius.xsBorder, border: Border.all( color: _color.withValues(alpha: 0.35), ), @@ -742,6 +1090,20 @@ class _TrendPageState extends ConsumerState { ); } + String _chartAxisLabel(List> records, int index) { + final date = records[index]['date'] as DateTime; + final sameDayCount = records.where((record) { + final other = record['date'] as DateTime; + return other.year == date.year && + other.month == date.month && + other.day == date.day; + }).length; + if (sameDayCount > 1) { + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + return '${date.month}-${date.day}'; + } + // ---- 历史记录列表 ---- Widget _buildHistory() { if (_filtered.isEmpty) return const SizedBox.shrink(); @@ -756,7 +1118,7 @@ class _TrendPageState extends ConsumerState { ), const Spacer(), Text( - '${_filtered.length}条', + '最近${min(30, _filtered.length)}条', style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, @@ -782,17 +1144,18 @@ class _TrendPageState extends ConsumerState { final date = r['date'] as DateTime; final abnormal = r['isAbnormal'] == true; final id = r['id']?.toString() ?? ''; - final idx = _filtered.indexOf(r); - final isSelected = idx == _selectedIdx; - String display; - if (_isBP) { - display = - '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; - } else { - display = '${r['value'] ?? '--'}'; - } + final chartRecords = _chartRecords; + final selectedId = + _selectedIdx != null && + _selectedIdx! >= 0 && + _selectedIdx! < chartRecords.length + ? chartRecords[_selectedIdx!]['id']?.toString() + : null; + final isSelected = selectedId == id; + final display = _displayValue(r); + final status = trendStatusLabel(r, _selected); return Dismissible( - key: Key(id), + key: ValueKey('$id-${date.microsecondsSinceEpoch}'), direction: DismissDirection.endToStart, background: Container( color: AppColors.error, @@ -807,23 +1170,39 @@ class _TrendPageState extends ConsumerState { try { final api = ref.read(apiClientProvider); await api.delete('/api/health-records/$id'); - setState(() { - _allRecords.removeWhere( - (x) => x['id']?.toString() == id, - ); - _filtered.removeWhere((x) => x['id']?.toString() == id); - _selectedIdx = null; - }); - ref.invalidate(latestHealthProvider); return true; } catch (_) { + if (mounted) { + AppToast.show( + context, + '删除失败,请检查网络后重试', + type: AppToastType.error, + ); + } return false; } }, + onDismissed: (_) { + setState(() { + _allRecords.removeWhere( + (record) => record['id']?.toString() == id, + ); + _filtered.removeWhere( + (record) => record['id']?.toString() == id, + ); + _selectedIdx = null; + }); + ref.invalidate(latestHealthProvider); + }, child: GestureDetector( onTap: () { - if (idx < 0) return; - setState(() => _selectedIdx = isSelected ? null : idx); + final chartIndex = chartRecords.indexWhere( + (record) => record['id']?.toString() == id, + ); + if (chartIndex < 0) return; + setState( + () => _selectedIdx = isSelected ? null : chartIndex, + ); }, child: AnimatedContainer( duration: const Duration(milliseconds: 180), @@ -835,13 +1214,13 @@ class _TrendPageState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric( horizontal: 14, - vertical: 12, + vertical: 10, ), child: Row( children: [ Container( - width: 40, - height: 40, + width: 36, + height: 36, decoration: BoxDecoration( color: _color.withAlpha(20), borderRadius: AppRadius.smBorder, @@ -849,63 +1228,54 @@ class _TrendPageState extends ConsumerState { child: Center( child: Icon( _getMetricIcon(_selected), - size: 21, + size: 19, color: _color, ), ), ), const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - '${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 15, - color: AppColors.textHint, - ), + child: Text( + _formatRecordTime(date), + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$display $_unit', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w700, + color: abnormal + ? AppColors.errorText + : AppColors.textPrimary, ), + ), + if (status.isNotEmpty) ...[ const SizedBox(height: 2), Text( - '$display $_unit', - style: TextStyle( - fontSize: 23, - fontWeight: FontWeight.w700, - color: abnormal - ? AppColors.error - : AppColors.textPrimary, + status, + style: const TextStyle( + fontSize: 12, + color: AppColors.errorText, + fontWeight: FontWeight.w600, ), ), ], - ), + ], ), - if (abnormal) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.errorLight, - borderRadius: AppRadius.xsBorder, - ), - child: const Text( - '异常', - style: TextStyle( - fontSize: 14, - color: AppColors.error, - fontWeight: FontWeight.w500, - ), - ), - ), ], ), ), if (!isLast) const Padding( - padding: EdgeInsets.only(left: 66), + padding: EdgeInsets.only(left: 62), child: Divider( height: 1, thickness: 0.7, @@ -926,19 +1296,80 @@ class _TrendPageState extends ConsumerState { } IconData _getMetricIcon(String key) { - switch (key) { - case 'blood_pressure': - return Icons.monitor_heart_outlined; - case 'heart_rate': - return Icons.favorite_border; - case 'glucose': - return Icons.water_drop_outlined; - case 'spo2': - return Icons.air_outlined; - case 'weight': - return Icons.monitor_weight_outlined; - default: - return Icons.show_chart; - } + return _metrics.firstWhere( + (metric) => metric['key'] == key, + orElse: () => _metrics.first, + )['icon'] + as IconData; + } +} + +class _StatisticItem extends StatelessWidget { + final String label; + final String value; + final String unit; + + const _StatisticItem({ + required this.label, + required this.value, + required this.unit, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: AppColors.textHint), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + unit, + style: const TextStyle(fontSize: 11, color: AppColors.textHint), + ), + ], + ), + ); + } +} + +class _ChartLegend extends StatelessWidget { + final Color color; + final String label; + + const _ChartLegend({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 14, + height: 3, + decoration: BoxDecoration( + color: color, + borderRadius: AppRadius.pillBorder, + ), + ), + const SizedBox(width: 5), + Text( + label, + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + ], + ); } } diff --git a/health_app/lib/pages/consultation/consultation_pages.dart b/health_app/lib/pages/consultation/consultation_pages.dart index c3df791..6ebbd8c 100644 --- a/health_app/lib/pages/consultation/consultation_pages.dart +++ b/health_app/lib/pages/consultation/consultation_pages.dart @@ -63,7 +63,7 @@ class _DoctorChatPageState extends ConsumerState { return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, title: Column( children: [ Text( diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart index 1bda296..b44da03 100644 --- a/health_app/lib/pages/device/device_management_page.dart +++ b/health_app/lib/pages/device/device_management_page.dart @@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/app_colors.dart'; +import '../../core/api_client.dart'; import '../../core/app_module_visuals.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../models/ble_device.dart'; @@ -17,6 +19,8 @@ import '../../providers/omron_device_provider.dart'; import '../../services/health_ble_service.dart'; import '../../widgets/ble_sync_dialog.dart'; import '../../widgets/app_toast.dart'; +import '../../widgets/app_empty_state.dart'; +import 'device_sync_ui_logic.dart'; const _deviceVisual = AppModuleVisuals.device; @@ -45,6 +49,7 @@ class _DeviceManagementPageState extends ConsumerState bool _scanning = false; bool _permissionBlocked = false; bool _disposed = false; + String? _pageMessage; @override void initState() { @@ -58,15 +63,18 @@ class _DeviceManagementPageState extends ConsumerState begin: 0.85, end: 1.35, ).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_startForegroundScan()); + }); } @override void dispose() { _disposed = true; _scanSub?.cancel(); - _scanCtrl.dispose(); unawaited(FlutterBluePlus.stopScan()); unawaited(_bleService.disconnect()); + _scanCtrl.dispose(); super.dispose(); } @@ -94,7 +102,19 @@ class _DeviceManagementPageState extends ConsumerState if (adapterState != BluetoothAdapterState.on) { try { await FlutterBluePlus.turnOn(); - } catch (_) {} + } catch (_) { + if (mounted) { + setState(() => _pageMessage = '蓝牙未开启,无法查找已绑定设备'); + AppToast.show( + context, + deviceSyncErrorMessage( + const DeviceSyncFailure(DeviceSyncFailureStage.bluetooth), + ), + type: AppToastType.warning, + ); + } + return; + } } try { @@ -104,6 +124,7 @@ class _DeviceManagementPageState extends ConsumerState final boundIds = ref .read(omronDeviceProvider) .devices + .where((device) => HealthBleService.isSyncImplemented(device.type)) .map((device) => device.id) .toList(); if (boundIds.isEmpty) { @@ -121,23 +142,44 @@ class _DeviceManagementPageState extends ConsumerState androidScanMode: AndroidScanMode.lowLatency, oneByOne: true, ); - if (mounted) setState(() => _scanning = true); - } catch (e) { - if (mounted) setState(() => _scanning = false); + if (mounted) { + setState(() { + _scanning = true; + _pageMessage = null; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _scanning = false; + _pageMessage = '设备查找失败,请检查蓝牙状态后重试'; + }); + AppToast.show(context, _pageMessage!, type: AppToastType.error); + } } } Future _ensureBlePermissions() async { - final statuses = await [ - Permission.bluetoothScan, - Permission.bluetoothConnect, - ].request(); + Map statuses; + try { + statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + ].request(); + } catch (_) { + if (mounted) { + setState(() => _pageMessage = '无法读取蓝牙权限状态'); + AppToast.show(context, _pageMessage!, type: AppToastType.error); + } + return false; + } final granted = statuses.values.every((status) => status.isGranted); if (granted) { _permissionBlocked = false; return true; } _permissionBlocked = true; + if (mounted) setState(() => _pageMessage = '需要蓝牙权限才能同步设备'); if (_disposed || !mounted) return false; AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning); await showDialog( @@ -224,6 +266,7 @@ class _DeviceManagementPageState extends ConsumerState setState(() { _busyDeviceId = bound.id; _connectedDeviceId = null; + _pageMessage = '正在连接 ${bound.name}'; }); await FlutterBluePlus.stopScan(); @@ -237,7 +280,10 @@ class _DeviceManagementPageState extends ConsumerState timeout: const Duration(seconds: 35), onConnected: () { if (!_disposed && mounted) { - setState(() => _connectedDeviceId = bound.id); + setState(() { + _connectedDeviceId = bound.id; + _pageMessage = '已连接,等待设备测量数据'; + }); } }, onMeasurementReceived: () { @@ -246,21 +292,48 @@ class _DeviceManagementPageState extends ConsumerState ); if (syncResult is BloodPressureSyncResult) { if (_disposed || !mounted) return; + setState(() => _pageMessage = '已读取数据,正在安全上传'); final saved = await _persistBloodPressure( syncResult.device, syncResult.reading, ); if (!_disposed && mounted && saved) { + setState(() => _pageMessage = '同步完成'); await _showBloodPressureDialog(syncResult.reading); } } } on TimeoutException { - if (!_disposed && mounted && _connectedDeviceId == bound.id) { - AppToast.show(context, '已连接设备,但本次未收到测量数据', type: AppToastType.warning); - } - } on Exception { if (!_disposed && mounted) { - AppToast.show(context, '数据录入失败,请稍后重试', type: AppToastType.error); + final message = _connectedDeviceId == bound.id + ? deviceSyncErrorMessage( + const DeviceSyncFailure(DeviceSyncFailureStage.measurement), + ) + : deviceSyncErrorMessage( + const DeviceSyncFailure(DeviceSyncFailureStage.connection), + ); + setState(() => _pageMessage = message); + AppToast.show(context, message, type: AppToastType.warning); + } + } on UnsupportedBleDeviceException catch (error) { + if (!_disposed && mounted) { + setState(() => _pageMessage = error.message); + AppToast.show(context, error.message, type: AppToastType.warning); + } + } on ApiException catch (error) { + if (!_disposed && mounted) { + final message = deviceSyncErrorMessage( + DeviceSyncFailure(DeviceSyncFailureStage.upload, error.message), + ); + setState(() => _pageMessage = message); + AppToast.show(context, message, type: AppToastType.error); + } + } on Exception catch (error) { + if (!_disposed && mounted) { + final message = deviceSyncErrorMessage( + DeviceSyncFailure(DeviceSyncFailureStage.connection, '$error'), + ); + setState(() => _pageMessage = message); + AppToast.show(context, message, type: AppToastType.error); } } finally { await _bleService.disconnect(); @@ -282,11 +355,12 @@ class _DeviceManagementPageState extends ConsumerState if (await notifier.isDuplicateReading(device, reading)) return false; final api = ref.read(apiClientProvider); - await api.post('/api/health-records', data: reading.toHealthRecord()); + final records = >[reading.toHealthRecord()]; final heartRateRecord = reading.toHeartRateRecord(); if (heartRateRecord != null) { - await api.post('/api/health-records', data: heartRateRecord); + records.add(heartRateRecord); } + await api.post('/api/health-records/batch', data: records); await notifier.recordSuccessfulSync(device: device, reading: reading); return true; } @@ -299,10 +373,14 @@ class _DeviceManagementPageState extends ConsumerState Widget build(BuildContext context) { final state = ref.watch(omronDeviceProvider); final devices = state.devices; - final connected = _connectedDeviceId != null; - final boundKey = _boundIdsKey(devices.map((device) => device.id).toList()); + final syncableDevices = devices + .where((device) => HealthBleService.isSyncImplemented(device.type)) + .toList(); + final boundKey = _boundIdsKey( + syncableDevices.map((device) => device.id).toList(), + ); - if (devices.isNotEmpty && + if (syncableDevices.isNotEmpty && !_scanning && _busyDeviceId == null && !_permissionBlocked && @@ -313,7 +391,10 @@ class _DeviceManagementPageState extends ConsumerState } ref.listen(omronDeviceProvider, (prev, next) { - final nextIds = next.devices.map((device) => device.id).toList(); + final nextIds = next.devices + .where((device) => HealthBleService.isSyncImplemented(device.type)) + .map((device) => device.id) + .toList(); final nextKey = _boundIdsKey(nextIds); if (nextIds.isEmpty) { _activeScanBoundKey = ''; @@ -343,9 +424,11 @@ class _DeviceManagementPageState extends ConsumerState tooltip: '新增设备', onPressed: () => pushRoute(ref, 'deviceScan'), style: IconButton.styleFrom( - backgroundColor: _deviceVisual.lightColor, - foregroundColor: _deviceVisual.color, + backgroundColor: Colors.white, + foregroundColor: AppColors.primaryDark, fixedSize: const Size(40, 40), + side: const BorderSide(color: AppColors.primaryLight), + shape: const CircleBorder(), ), icon: const Icon(Icons.add_rounded), ), @@ -358,6 +441,20 @@ class _DeviceManagementPageState extends ConsumerState padding: const EdgeInsets.fromLTRB(18, 10, 18, 120), children: [ _DevicesHeader(deviceCount: devices.length), + if (devices.isNotEmpty) ...[ + const SizedBox(height: 12), + _SyncStatusBar( + message: + _pageMessage ?? + (syncableDevices.isEmpty + ? '当前设备已绑定,暂不支持自动同步' + : _scanning + ? '正在等待已绑定设备的测量数据' + : '设备同步已暂停'), + active: _scanning || _busyDeviceId != null, + onRetry: _busyDeviceId == null ? _startForegroundScan : null, + ), + ], const SizedBox(height: 18), if (devices.isEmpty) const _EmptyDevices() @@ -382,7 +479,7 @@ class _DeviceManagementPageState extends ConsumerState child: _ScanIndicator( animation: _scanAnim, scanning: _scanning, - syncing: connected, + syncing: _connectedDeviceId != null || _busyDeviceId != null, ), ), ], @@ -394,7 +491,7 @@ class _DeviceManagementPageState extends ConsumerState final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder), title: const Text('解绑设备'), content: Text('确定解绑这台${device.type.label}吗?'), actions: [ @@ -404,7 +501,7 @@ class _DeviceManagementPageState extends ConsumerState ), TextButton( onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: AppColors.error), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), child: const Text('解绑'), ), ], @@ -416,309 +513,6 @@ class _DeviceManagementPageState extends ConsumerState } } -class _DevicesHeader extends StatelessWidget { - final int deviceCount; - - const _DevicesHeader({required this.deviceCount}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 18), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.borderLight, width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), - child: Row( - children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - gradient: _deviceVisual.gradient, - borderRadius: BorderRadius.circular(16), - ), - child: const Icon( - Icons.bluetooth_audio_rounded, - color: Colors.white, - size: 27, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '已绑定设备', - style: TextStyle( - fontSize: 19, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - '$deviceCount 台设备', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w800, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _DeviceSection extends StatelessWidget { - final BleDeviceType type; - final List devices; - final String? connectedDeviceId; - final String? busyDeviceId; - final ValueChanged onUnbind; - - const _DeviceSection({ - required this.type, - required this.devices, - required this.connectedDeviceId, - required this.busyDeviceId, - required this.onUnbind, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: _deviceVisual.lightColor, - borderRadius: BorderRadius.circular(11), - border: Border.all(color: AppColors.borderLight), - ), - child: Icon(type.icon, size: 19, color: _deviceVisual.color), - ), - const SizedBox(width: 10), - Text( - type.label, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, - ), - ), - const SizedBox(width: 8), - Text( - '${devices.length}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: AppColors.textHint, - ), - ), - ], - ), - ), - for (final device in devices) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _BoundDeviceTile( - device: device, - connected: connectedDeviceId == device.id, - onUnbind: () => onUnbind(device), - ), - ), - ], - ); - } -} - -class _BoundDeviceTile extends StatelessWidget { - final BoundBleDevice device; - final bool connected; - final VoidCallback onUnbind; - - const _BoundDeviceTile({ - required this.device, - required this.connected, - required this.onUnbind, - }); - - @override - Widget build(BuildContext context) { - final accent = connected ? AppColors.success : AppColors.textHint; - return AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.fromLTRB(16, 15, 10, 15), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: connected ? AppColors.success : AppColors.border, - width: connected ? 1.4 : 1.1, - ), - boxShadow: connected - ? [ - BoxShadow( - color: AppColors.success.withValues(alpha: 0.14), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ] - : AppColors.cardShadowLight, - ), - child: Row( - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 180), - width: 50, - height: 50, - decoration: BoxDecoration( - color: connected - ? AppColors.successLight - : const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderLight), - ), - child: Icon(device.type.icon, color: accent, size: 25), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - device.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - '最近同步:${_formatLastSync(device.lastSyncAt)}', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.textHint, - ), - ), - ], - ), - ), - if (connected) - Container( - margin: const EdgeInsets.only(right: 4), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppColors.successLight, - borderRadius: BorderRadius.circular(999), - ), - child: const Text( - '已连接', - style: TextStyle( - fontSize: 12, - height: 1, - fontWeight: FontWeight.w900, - color: AppColors.success, - ), - ), - ), - IconButton( - tooltip: '解绑设备', - onPressed: connected ? null : onUnbind, - icon: const Icon(Icons.delete_outline_rounded), - color: connected ? AppColors.textHint : AppColors.error, - ), - ], - ), - ); - } - - String _formatLastSync(DateTime? value) { - if (value == null) return '暂无'; - final now = DateTime.now(); - final dayLabel = - value.year == now.year && - value.month == now.month && - value.day == now.day - ? '今天' - : '${value.month}/${value.day}'; - final minute = value.minute.toString().padLeft(2, '0'); - return '$dayLabel ${value.hour}:$minute'; - } -} - -class _EmptyDevices extends StatelessWidget { - const _EmptyDevices(); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(24, 46, 24, 46), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: AppColors.borderLight, width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), - child: Column( - children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: AppColors.deviceLight, - borderRadius: BorderRadius.circular(22), - ), - child: const Icon( - Icons.bluetooth_disabled_rounded, - color: AppColors.device, - size: 36, - ), - ), - const SizedBox(height: 18), - const Text( - '还没有绑定设备', - style: TextStyle( - fontSize: 19, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - const Text( - '右上角添加设备', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.textSecondary, - ), - ), - ], - ), - ); - } -} - class _ScanIndicator extends StatelessWidget { final Animation animation; final bool scanning; @@ -771,9 +565,9 @@ class _ScanIndicator extends StatelessWidget { shape: BoxShape.circle, boxShadow: [ BoxShadow( - color: AppColors.device.withValues(alpha: 0.24), - blurRadius: 18, - offset: const Offset(0, 8), + color: AppColors.device.withValues(alpha: 0.22), + blurRadius: 16, + offset: const Offset(0, 7), ), ], ), @@ -790,3 +584,339 @@ class _ScanIndicator extends StatelessWidget { ); } } + +class _DevicesHeader extends StatelessWidget { + final int deviceCount; + + const _DevicesHeader({required this.deviceCount}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: _deviceVisual.lightColor, + borderRadius: AppRadius.smBorder, + ), + child: Icon(_deviceVisual.icon, color: AppColors.device, size: 27), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '已绑定设备', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + '$deviceCount 台设备', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _DeviceSection extends StatelessWidget { + final BleDeviceType type; + final List devices; + final String? connectedDeviceId; + final String? busyDeviceId; + final ValueChanged onUnbind; + + const _DeviceSection({ + required this.type, + required this.devices, + required this.connectedDeviceId, + required this.busyDeviceId, + required this.onUnbind, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: AppRadius.lgBorder, + child: ColoredBox( + color: Colors.white, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 10), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: _deviceVisual.lightColor, + borderRadius: AppRadius.smBorder, + ), + child: Icon( + type.icon, + size: 19, + color: _deviceVisual.color, + ), + ), + const SizedBox(width: 10), + Text( + type.label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + Text( + '${devices.length} 台', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], + ), + ), + for (var index = 0; index < devices.length; index++) + _BoundDeviceTile( + device: devices[index], + connected: connectedDeviceId == devices[index].id, + busy: busyDeviceId == devices[index].id, + showDivider: index < devices.length - 1, + onUnbind: () => onUnbind(devices[index]), + ), + ], + ), + ), + ); + } +} + +class _BoundDeviceTile extends StatelessWidget { + final BoundBleDevice device; + final bool connected; + final bool busy; + final bool showDivider; + final VoidCallback onUnbind; + + const _BoundDeviceTile({ + required this.device, + required this.connected, + required this.busy, + required this.showDivider, + required this.onUnbind, + }); + + @override + Widget build(BuildContext context) { + final accent = connected ? AppColors.successText : AppColors.textHint; + final availability = deviceSyncAvailabilityLabel(device.type); + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.only(left: 14), + decoration: BoxDecoration( + color: connected + ? AppColors.successLight.withValues(alpha: 0.45) + : Colors.white, + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 42, + height: 42, + decoration: BoxDecoration( + color: connected ? AppColors.successLight : AppColors.deviceLight, + borderRadius: AppRadius.smBorder, + ), + child: Icon(device.type.icon, color: accent, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 70), + padding: const EdgeInsets.fromLTRB(0, 12, 6, 12), + decoration: BoxDecoration( + border: showDivider + ? const Border( + bottom: BorderSide( + color: AppColors.divider, + width: 0.7, + ), + ) + : null, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + availability ?? + (busy + ? connected + ? '已连接,正在读取数据' + : '正在连接设备' + : '最近同步:${_formatLastSync(device.lastSyncAt)}'), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: availability == null + ? AppColors.textHint + : AppColors.textSecondary, + ), + ), + ], + ), + ), + if (connected) + Container( + margin: const EdgeInsets.only(right: 2), + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: AppRadius.pillBorder, + ), + child: const Text( + '已连接', + style: TextStyle( + fontSize: 12, + height: 1, + fontWeight: FontWeight.w800, + color: AppColors.successText, + ), + ), + ), + IconButton( + tooltip: '解绑设备', + onPressed: busy ? null : onUnbind, + icon: const Icon(Icons.delete_outline_rounded, size: 21), + color: AppColors.textHint, + ), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatLastSync(DateTime? value) { + if (value == null) return '暂无'; + final now = DateTime.now(); + final dayLabel = + value.year == now.year && + value.month == now.month && + value.day == now.day + ? '今天' + : '${value.month}-${value.day}'; + final minute = value.minute.toString().padLeft(2, '0'); + return '$dayLabel ${value.hour}:$minute'; + } +} + +class _EmptyDevices extends StatelessWidget { + const _EmptyDevices(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: AppEmptyState( + icon: _deviceVisual.icon, + iconColor: _deviceVisual.color, + title: '还没有绑定设备', + subtitle: '点击右上角添加设备', + ), + ); + } +} + +class _SyncStatusBar extends StatelessWidget { + final String message; + final bool active; + final Future Function()? onRetry; + + const _SyncStatusBar({ + required this.message, + required this.active, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.fromLTRB(14, 11, 8, 11), + decoration: BoxDecoration( + color: active ? AppColors.deviceLight : Colors.white, + borderRadius: AppRadius.mdBorder, + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + Icon( + active ? Icons.sync_rounded : Icons.info_outline_rounded, + size: 20, + color: active ? AppColors.device : AppColors.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ), + if (!active && onRetry != null) + TextButton(onPressed: onRetry, child: const Text('重试')), + ], + ), + ); +} diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 666958e..13e3429 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../models/ble_device.dart'; @@ -290,8 +291,6 @@ class _DeviceScanPageState extends ConsumerState Widget build(BuildContext context) { return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.white.withValues(alpha: 0.92), - elevation: 0, title: const Text( '新增设备', style: TextStyle(color: AppColors.textPrimary), @@ -303,19 +302,30 @@ class _DeviceScanPageState extends ConsumerState ), body: _results.isEmpty ? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning) - : ListView.separated( + : ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), - itemCount: _results.length, - separatorBuilder: (context, index) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final result = _results[index]; - return _DeviceResultTile( - result: result, - connecting: - _connectingId == result.device.remoteId.toString(), - onConnect: () => _connectBindAndSync(result), - ); - }, + children: [ + ClipRRect( + borderRadius: AppRadius.lgBorder, + child: ColoredBox( + color: Colors.white, + child: Column( + children: [ + for (var index = 0; index < _results.length; index++) + _DeviceResultTile( + result: _results[index], + connecting: + _connectingId == + _results[index].device.remoteId.toString(), + showDivider: index < _results.length - 1, + onConnect: () => + _connectBindAndSync(_results[index]), + ), + ], + ), + ), + ), + ], ), ); } @@ -413,11 +423,13 @@ class _ScanningEmpty extends StatelessWidget { class _DeviceResultTile extends StatelessWidget { final ScanResult result; final bool connecting; + final bool showDivider; final VoidCallback onConnect; const _DeviceResultTile({ required this.result, required this.connecting, + required this.showDivider, required this.onConnect, }); @@ -425,23 +437,16 @@ class _DeviceResultTile extends StatelessWidget { Widget build(BuildContext context) { final type = HealthBleService.deviceTypeFromScan(result); final name = _deviceNameOf(result, type); - return Container( - padding: const EdgeInsets.all(15), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), + return Padding( + padding: const EdgeInsets.only(left: 14), child: Row( children: [ Container( - width: 48, - height: 48, + width: 42, + height: 42, decoration: BoxDecoration( color: AppColors.deviceLight, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.deviceBorder), + borderRadius: AppRadius.smBorder, ), child: Icon( type?.icon ?? Icons.bluetooth_rounded, @@ -449,52 +454,76 @@ class _DeviceResultTile extends StatelessWidget { size: 25, ), ), - const SizedBox(width: 14), + const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - type?.label ?? '健康设备', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - color: AppColors.textHint, - ), - ), - ], - ), - ), - const SizedBox(width: 10), - SizedBox( - height: 38, - child: FilledButton( - onPressed: connecting ? null : onConnect, - style: FilledButton.styleFrom( - backgroundColor: AppColors.device, - foregroundColor: Colors.white, - disabledBackgroundColor: AppColors.cardInner, - disabledForegroundColor: AppColors.textHint, - padding: const EdgeInsets.symmetric(horizontal: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - ), + child: Container( + constraints: const BoxConstraints(minHeight: 70), + padding: const EdgeInsets.fromLTRB(0, 12, 12, 12), + decoration: BoxDecoration( + border: showDivider + ? const Border( + bottom: BorderSide( + color: AppColors.divider, + width: 0.7, + ), + ) + : null, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + type?.label ?? '健康设备', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: AppColors.textHint, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + SizedBox( + height: 36, + child: OutlinedButton( + onPressed: connecting ? null : onConnect, + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.device, + disabledForegroundColor: AppColors.textHint, + side: BorderSide( + color: connecting + ? AppColors.borderLight + : AppColors.device, + ), + padding: const EdgeInsets.symmetric(horizontal: 13), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.smBorder, + ), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + child: Text(connecting ? '连接中' : '连接'), + ), + ), + ], ), - child: Text(connecting ? '连接中' : '连接'), ), ), ], @@ -537,7 +566,7 @@ class _ScanPulse extends StatelessWidget { height: 64, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(20), + borderRadius: AppRadius.xlBorder, border: Border.all(color: AppColors.borderLight), ), child: const Icon( diff --git a/health_app/lib/pages/device/device_sync_ui_logic.dart b/health_app/lib/pages/device/device_sync_ui_logic.dart new file mode 100644 index 0000000..99b9cbc --- /dev/null +++ b/health_app/lib/pages/device/device_sync_ui_logic.dart @@ -0,0 +1,33 @@ +import '../../models/ble_device.dart'; + +enum DeviceSyncFailureStage { + permission, + bluetooth, + connection, + measurement, + upload, +} + +class DeviceSyncFailure implements Exception { + final DeviceSyncFailureStage stage; + final String? detail; + + const DeviceSyncFailure(this.stage, [this.detail]); +} + +String? deviceSyncAvailabilityLabel(BleDeviceType type) { + return type == BleDeviceType.bloodPressure ? null : '暂不支持自动同步'; +} + +String deviceSyncErrorMessage(DeviceSyncFailure failure) { + final detail = failure.detail?.trim(); + return switch (failure.stage) { + DeviceSyncFailureStage.permission => '请开启蓝牙权限后再同步设备', + DeviceSyncFailureStage.bluetooth => '蓝牙未开启,请开启后重试', + DeviceSyncFailureStage.connection => + detail?.isNotEmpty == true ? '设备连接失败:$detail' : '设备连接失败,请靠近设备后重试', + DeviceSyncFailureStage.measurement => '已连接设备,但本次未收到测量数据', + DeviceSyncFailureStage.upload => + detail?.isNotEmpty == true ? '数据上传失败:$detail' : '数据已读取,但上传失败,请检查网络后重试', + }; +} diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 83f2103..2481f52 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -9,6 +9,8 @@ import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; +import '../../widgets/common_widgets.dart'; +import '../../widgets/app_empty_state.dart'; import 'diet_nutrition_widgets.dart'; import 'diet_record_logic.dart'; @@ -321,10 +323,9 @@ class DietNotifier extends Notifier { } } -// ─────────── 饮食主题色(暖橙系,不再用紫色)─────────── +// 饮食主题色 const _dietAccent = DietPalette.primary; const _dietKcalText = DietPalette.calorie; -const _dietGradient = DietPalette.gradient; class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); @@ -352,7 +353,7 @@ class _DietCapturePageState extends ConsumerState { final state = ref.watch(dietProvider); return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.white.withValues(alpha: 0.9), + backgroundColor: Colors.white, title: const Text( '饮食分析', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), @@ -425,7 +426,6 @@ class _DietCapturePageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -480,7 +480,7 @@ class _DietCapturePageState extends ConsumerState { ); } - // ─────────── 分析中 ─────────── + // 分析中 Widget _buildAnalyzing(DietState state) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -503,7 +503,7 @@ class _DietCapturePageState extends ConsumerState { const SizedBox(height: 8), Text( state.errorMessage!, - style: const TextStyle(fontSize: 14, color: AppColors.error), + style: const TextStyle(fontSize: 14, color: AppColors.errorText), textAlign: TextAlign.center, ), ], @@ -513,43 +513,15 @@ class _DietCapturePageState extends ConsumerState { } Widget _buildNoFoodHint() { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Column( - children: [ - const Icon( - Icons.image_not_supported_outlined, - size: 44, - color: AppColors.textHint, - ), - const SizedBox(height: 14), - const Text( - '未识别到食物', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 6), - const Text( - '请重新拍摄或选择含有食物的清晰照片', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: AppColors.textSecondary), - ), - ], - ), + return AppEmptyState( + icon: Icons.image_not_supported_outlined, + iconColor: _dietAccent, + title: '未识别到食物', + subtitle: '请重新拍摄或选择含有食物的清晰照片', ); } - // ─────────── 食物列表 ─────────── + // 食物列表 Widget _buildFoodList(WidgetRef ref) { final state = ref.watch(dietProvider); return Container( @@ -557,7 +529,6 @@ class _DietCapturePageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -618,8 +589,9 @@ class _DietCapturePageState extends ConsumerState { height: 26, alignment: Alignment.center, decoration: BoxDecoration( - gradient: food.selected ? _dietGradient : null, - color: food.selected ? null : DietPalette.primarySoft, + color: food.selected + ? AppColors.primary + : AppColors.primarySoft, borderRadius: AppRadius.smBorder, border: Border.all( color: food.selected @@ -751,7 +723,7 @@ class _DietCapturePageState extends ConsumerState { return c; } - // ─────────── AI 点评 ─────────── + // AI 点评 Widget _buildAiCommentary(String? text) { final hasText = text != null && text.trim().isNotEmpty; return Container( @@ -759,7 +731,6 @@ class _DietCapturePageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -770,8 +741,8 @@ class _DietCapturePageState extends ConsumerState { width: 4, height: 18, decoration: BoxDecoration( - gradient: _dietGradient, - borderRadius: BorderRadius.circular(999), + color: _dietAccent, + borderRadius: AppRadius.pillBorder, ), ), const SizedBox(width: 9), @@ -816,31 +787,9 @@ class _DietCapturePageState extends ConsumerState { top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), - child: GestureDetector( - onTap: _saveDietRecord, - child: Container( - height: 50, - alignment: Alignment.center, - decoration: BoxDecoration( - gradient: _dietGradient, - borderRadius: AppRadius.mdBorder, - boxShadow: [ - BoxShadow( - color: _dietAccent.withValues(alpha: 0.24), - blurRadius: 14, - offset: const Offset(0, 7), - ), - ], - ), - child: const Text( - '保存记录', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - ), + child: AppGradientOutlineButton( + label: '保存记录', + onPressed: _saveDietRecord, ), ), ), diff --git a/health_app/lib/pages/diet/diet_nutrition_widgets.dart b/health_app/lib/pages/diet/diet_nutrition_widgets.dart index e24d934..9cd1536 100644 --- a/health_app/lib/pages/diet/diet_nutrition_widgets.dart +++ b/health_app/lib/pages/diet/diet_nutrition_widgets.dart @@ -133,9 +133,7 @@ class _CalorieRingPainter extends CustomPainter { ..strokeWidth = 9 ..strokeCap = StrokeCap.round; final progressPaint = Paint() - ..shader = const LinearGradient( - colors: [Color(0xFF8FA7FF), DietPalette.calorie], - ).createShader(Offset.zero & size) + ..shader = DietPalette.gradient.createShader(Offset.zero & size) ..style = PaintingStyle.stroke ..strokeWidth = 9 ..strokeCap = StrokeCap.round; diff --git a/health_app/lib/pages/diet/diet_record_logic.dart b/health_app/lib/pages/diet/diet_record_logic.dart index bf90641..b5e27e3 100644 --- a/health_app/lib/pages/diet/diet_record_logic.dart +++ b/health_app/lib/pages/diet/diet_record_logic.dart @@ -1,3 +1,12 @@ +import 'package:flutter/widgets.dart'; + +enum DietRecordSwipeAction { edit, delete } + +DietRecordSwipeAction dietRecordSwipeAction(DismissDirection direction) => + direction == DismissDirection.startToEnd + ? DietRecordSwipeAction.edit + : DietRecordSwipeAction.delete; + List recentDietDates(DateTime now, {int count = 7}) { final today = DateTime(now.year, now.month, now.day); return List.generate( diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart index 6e2e930..00bf038 100644 --- a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -62,7 +62,7 @@ class _DoctorFollowUpEditPageState return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: Text( diff --git a/health_app/lib/pages/doctor/doctor_home_page.dart b/health_app/lib/pages/doctor/doctor_home_page.dart index d655efe..b33c5c9 100644 --- a/health_app/lib/pages/doctor/doctor_home_page.dart +++ b/health_app/lib/pages/doctor/doctor_home_page.dart @@ -23,7 +23,7 @@ class DoctorHomePage extends ConsumerWidget { child: Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: Text( diff --git a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart index 566c743..4b5035b 100644 --- a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -21,7 +21,7 @@ class DoctorPatientDetailPage extends ConsumerWidget { return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: const Text( @@ -125,11 +125,13 @@ class DoctorPatientDetailPage extends ConsumerWidget { (archive['surgeries'] as List).isNotEmpty) _row( '手术史', - (archive['surgeries'] as List).map((item) { - final surgery = item as Map; - final date = surgery['date']?.toString() ?? ''; - return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}'; - }).join(';'), + (archive['surgeries'] as List) + .map((item) { + final surgery = item as Map; + final date = surgery['date']?.toString() ?? ''; + return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}'; + }) + .join(';'), ) else if (archive['surgeryType'] != null) _row( diff --git a/health_app/lib/pages/doctor/doctor_profile_page.dart b/health_app/lib/pages/doctor/doctor_profile_page.dart index 3019a18..c514087 100644 --- a/health_app/lib/pages/doctor/doctor_profile_page.dart +++ b/health_app/lib/pages/doctor/doctor_profile_page.dart @@ -40,7 +40,7 @@ class _DoctorProfileEditPageState extends ConsumerState { return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: const Text( diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 15d9959..c3370f3 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -90,7 +90,7 @@ class _DoctorReportDetailPageState return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: const Text( @@ -298,8 +298,12 @@ class _DoctorReportDetailPageState width: double.infinity, child: OutlinedButton.icon( onPressed: () { - pushRoute(ref, 'reportOriginal', params: {'url': fileUrl, 'title': '原始报告'}); - }, + pushRoute( + ref, + 'reportOriginal', + params: {'url': fileUrl, 'title': '原始报告'}, + ); + }, icon: const Icon(Icons.visibility, size: 18), label: const Text('查看原始报告'), style: OutlinedButton.styleFrom( diff --git a/health_app/lib/pages/doctor/doctor_settings_page.dart b/health_app/lib/pages/doctor/doctor_settings_page.dart index 8cc8169..22ef548 100644 --- a/health_app/lib/pages/doctor/doctor_settings_page.dart +++ b/health_app/lib/pages/doctor/doctor_settings_page.dart @@ -11,7 +11,7 @@ class DoctorSettingsPage extends ConsumerWidget { return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, surfaceTintColor: Colors.transparent, title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)), @@ -28,8 +28,20 @@ class DoctorSettingsPage extends ConsumerWidget { '推送通知', trailing: Switch(value: true, onChanged: (_) {}), ), - _SettingTile(Icons.description_outlined, '隐私政策', onTap: () { pushRoute(ref, 'staticText', params: {'type': 'privacy'}); }), - _SettingTile(Icons.article_outlined, '用户协议', onTap: () { pushRoute(ref, 'staticText', params: {'type': 'terms'}); }), + _SettingTile( + Icons.description_outlined, + '隐私政策', + onTap: () { + pushRoute(ref, 'staticText', params: {'type': 'privacy'}); + }, + ), + _SettingTile( + Icons.article_outlined, + '用户协议', + onTap: () { + pushRoute(ref, 'staticText', params: {'type': 'terms'}); + }, + ), const SizedBox(height: 24), _SettingTile( Icons.delete_forever_outlined, diff --git a/health_app/lib/pages/exercise/exercise_plan_page.dart b/health_app/lib/pages/exercise/exercise_plan_page.dart new file mode 100644 index 0000000..a1762ed --- /dev/null +++ b/health_app/lib/pages/exercise/exercise_plan_page.dart @@ -0,0 +1,604 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api_client.dart'; +import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; +import '../../core/app_module_visuals.dart'; +import '../../core/app_theme.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/data_providers.dart'; +import '../../widgets/app_empty_state.dart'; +import '../../widgets/app_future_view.dart'; +import '../../widgets/app_toast.dart'; +import '../../widgets/common_widgets.dart'; +import '../care_plan_ui_logic.dart'; + +class EnterpriseExercisePlanPage extends ConsumerStatefulWidget { + const EnterpriseExercisePlanPage({super.key}); + + @override + ConsumerState createState() => + _EnterpriseExercisePlanPageState(); +} + +class _EnterpriseExercisePlanPageState + extends ConsumerState { + Future>>? _future; + final Set _busyItems = {}; + final Set _deletingPlans = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() => setState(() { + _future = ref.read(exerciseServiceProvider).getPlans(); + }); + + Future _refresh() async { + _load(); + await _future; + } + + Future _toggleCheckIn(String itemId) async { + if (itemId.isEmpty || _busyItems.contains(itemId)) return; + setState(() => _busyItems.add(itemId)); + try { + await ref.read(exerciseServiceProvider).checkIn(itemId); + ref.invalidate(currentExercisePlanProvider); + _load(); + } catch (error) { + if (mounted) { + AppToast.show( + context, + error is ApiException ? error.message : '打卡失败,请稍后重试', + type: AppToastType.error, + ); + } + } finally { + if (mounted) setState(() => _busyItems.remove(itemId)); + } + } + + Future _confirmDelete(String id, String title) async { + if (id.isEmpty || _deletingPlans.contains(id)) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除运动计划'), + content: Text('确定删除“$title”吗?删除后无法恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _deletingPlans.add(id)); + try { + await ref.read(exerciseServiceProvider).deletePlan(id); + ref.invalidate(currentExercisePlanProvider); + _load(); + if (mounted) { + AppToast.show(context, '运动计划已删除', type: AppToastType.success); + } + } catch (error) { + if (mounted) { + AppToast.show( + context, + error is ApiException ? error.message : '删除失败,请稍后重试', + type: AppToastType.error, + ); + } + } finally { + if (mounted) setState(() => _deletingPlans.remove(id)); + } + } + + @override + Widget build(BuildContext context) { + return GradientScaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), + title: const Text('运动计划'), + ), + floatingActionButton: AppCreateFab( + tooltip: '新建运动计划', + onPressed: () => pushRoute(ref, 'exerciseCreate'), + ), + body: AppFutureView>>( + future: _future, + onRetry: _load, + errorTitle: '运动计划加载失败', + onData: (_, plans) { + if (plans.isEmpty) { + return AppEmptyState( + icon: AppModuleVisuals.exercise.icon, + title: '暂无运动计划', + subtitle: '点击右下角添加运动计划', + iconColor: AppColors.exercise, + ); + } + final todayKey = _dateKey(DateTime.now()); + final validPlans = plans.where((plan) { + return (plan['items'] as List?)?.isNotEmpty == true; + }).toList(); + final todayItems = validPlans + .expand(_itemsOf) + .where((item) => item['scheduledDate']?.toString() == todayKey) + .where((item) => item['isRestDay'] != true) + .toList(); + final completedToday = todayItems + .where((item) => item['isCompleted'] == true) + .length; + final groups = >>{ + for (final phase in CarePlanPhase.values) phase: [], + }; + for (final plan in validPlans) { + groups[resolveCarePlanPhase( + startDate: plan['startDate']?.toString(), + endDate: plan['endDate']?.toString(), + )]! + .add(plan); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: AppSpacing.pageWithFab, + children: [ + _TodayExercisePanel( + items: todayItems, + completed: completedToday, + busyItems: _busyItems, + onCheckIn: _toggleCheckIn, + ), + const SizedBox(height: 18), + for (final phase in CarePlanPhase.values) + if (groups[phase]!.isNotEmpty) ...[ + _ExercisePlanGroup( + phase: phase, + plans: groups[phase]!, + todayKey: todayKey, + busyItems: _busyItems, + deletingPlans: _deletingPlans, + onCheckIn: _toggleCheckIn, + onDelete: _confirmDelete, + ), + const SizedBox(height: 18), + ], + ], + ), + ); + }, + ), + ); + } +} + +class _TodayExercisePanel extends StatelessWidget { + final List> items; + final int completed; + final Set busyItems; + final Future Function(String) onCheckIn; + + const _TodayExercisePanel({ + required this.items, + required this.completed, + required this.busyItems, + required this.onCheckIn, + }); + + @override + Widget build(BuildContext context) { + final progress = items.isEmpty ? 0.0 : completed / items.length; + final next = items.cast?>().firstWhere( + (item) => item?['isCompleted'] != true, + orElse: () => null, + ); + if (items.isEmpty) { + return Container( + padding: AppSpacing.panel, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: AppColors.exerciseLight, + borderRadius: AppRadius.smBorder, + ), + child: Icon( + AppModuleVisuals.exercise.icon, + color: AppColors.exercise, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('今日休息', style: AppTextStyles.sectionTitle), + SizedBox(height: 3), + Text('今天没有安排运动,保持轻松活动即可', style: AppTextStyles.listSubtitle), + ], + ), + ), + ], + ), + ); + } + return Container( + padding: AppSpacing.panel, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('今日任务', style: AppTextStyles.sectionTitle), + const SizedBox(height: 3), + Text( + '$completed / ${items.length} 已完成', + style: AppTextStyles.listSubtitle, + ), + ], + ), + ), + if (next != null) + _CompactActionButton( + busy: busyItems.contains(next['id']?.toString() ?? ''), + completed: false, + onPressed: () => onCheckIn(next['id']?.toString() ?? ''), + ), + ], + ), + const SizedBox(height: 14), + ClipRRect( + borderRadius: AppRadius.pillBorder, + child: LinearProgressIndicator( + minHeight: 5, + value: progress, + backgroundColor: AppColors.cardInner, + valueColor: const AlwaysStoppedAnimation(AppColors.exercise), + ), + ), + if (next != null) ...[ + const SizedBox(height: 12), + Text( + '${next['exerciseType'] ?? '运动'} · ${next['durationMinutes'] ?? 0} 分钟', + style: AppTextStyles.listTitle.copyWith(fontSize: 16), + ), + ], + ], + ), + ); + } +} + +class _ExercisePlanGroup extends StatelessWidget { + final CarePlanPhase phase; + final List> plans; + final String todayKey; + final Set busyItems; + final Set deletingPlans; + final Future Function(String) onCheckIn; + final Future Function(String, String) onDelete; + + const _ExercisePlanGroup({ + required this.phase, + required this.plans, + required this.todayKey, + required this.busyItems, + required this.deletingPlans, + required this.onCheckIn, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final color = _phaseColor(phase); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 9), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + '${_phaseLabel(phase)}(${plans.length})', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: color, + ), + ), + ], + ), + ), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + children: List.generate(plans.length, (index) { + final plan = plans[index]; + final items = _itemsOf(plan); + final id = plan['id']?.toString() ?? ''; + final title = _exerciseTitle(items); + final isDeleting = deletingPlans.contains(id); + return SwipeDeleteTile( + key: ValueKey(id), + margin: EdgeInsets.zero, + borderRadius: BorderRadius.zero, + enabled: !isDeleting, + onDelete: () => onDelete(id, title), + child: _ExercisePlanRow( + plan: plan, + title: title, + phase: phase, + todayKey: todayKey, + busyItems: busyItems, + deleting: isDeleting, + showDivider: index < plans.length - 1, + onCheckIn: onCheckIn, + ), + ); + }), + ), + ), + ], + ); + } +} + +class _ExercisePlanRow extends StatelessWidget { + final Map plan; + final String title; + final CarePlanPhase phase; + final String todayKey; + final Set busyItems; + final bool deleting; + final bool showDivider; + final Future Function(String) onCheckIn; + + const _ExercisePlanRow({ + required this.plan, + required this.title, + required this.phase, + required this.todayKey, + required this.busyItems, + required this.deleting, + required this.showDivider, + required this.onCheckIn, + }); + + @override + Widget build(BuildContext context) { + final items = _itemsOf(plan); + final done = items.where((item) => item['isCompleted'] == true).length; + final progress = items.isEmpty ? 0.0 : done / items.length; + final todayItem = items.cast?>().firstWhere( + (item) => item?['scheduledDate']?.toString() == todayKey, + orElse: () => null, + ); + final canCheckIn = + phase == CarePlanPhase.active && + todayItem != null && + todayItem['isRestDay'] != true; + final itemId = todayItem?['id']?.toString() ?? ''; + final completed = todayItem?['isCompleted'] == true; + + return Material( + color: Colors.white, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.exerciseLight, + borderRadius: AppRadius.smBorder, + ), + child: Icon( + AppModuleVisuals.exercise.icon, + color: AppColors.exercise, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.listTitle.copyWith( + fontSize: 17, + ), + ), + ), + const SizedBox(width: 8), + Text( + '$done/${items.length}天', + style: AppTextStyles.tag, + ), + ], + ), + const SizedBox(height: 4), + Text( + '${_compactDate(plan['startDate'])} - ${_compactDate(plan['endDate'])}', + style: AppTextStyles.listSubtitle.copyWith( + fontSize: 13, + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: AppRadius.pillBorder, + child: LinearProgressIndicator( + minHeight: 4, + value: progress, + backgroundColor: AppColors.cardInner, + valueColor: AlwaysStoppedAnimation( + _phaseColor(phase), + ), + ), + ), + ], + ), + ), + if (canCheckIn && !deleting) ...[ + const SizedBox(width: 8), + _CompactActionButton( + busy: busyItems.contains(itemId) || deleting, + completed: completed, + onPressed: () => onCheckIn(itemId), + ), + ], + if (deleting) + const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + ), + if (showDivider) + const Positioned( + left: 68, + right: 0, + bottom: 0, + child: Divider( + height: 1, + thickness: 0.7, + color: AppColors.divider, + ), + ), + ], + ), + ); + } +} + +class _CompactActionButton extends StatelessWidget { + final bool busy; + final bool completed; + final VoidCallback onPressed; + + const _CompactActionButton({ + required this.busy, + required this.completed, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: OutlinedButton( + onPressed: busy ? null : onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 14), + backgroundColor: completed ? AppColors.successLight : Colors.white, + foregroundColor: completed + ? AppColors.successText + : AppColors.exercise, + side: BorderSide( + color: completed ? AppColors.successText : AppColors.exercise, + ), + shape: RoundedRectangleBorder(borderRadius: AppRadius.smBorder), + ), + child: busy + ? const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(completed ? '撤销' : '打卡', style: AppTextStyles.miniButton), + ), + ); + } +} + +List> _itemsOf(Map plan) => + (plan['items'] as List?)?.cast>() ?? []; + +String _exerciseTitle(List> items) { + final names = items + .where((item) => item['isRestDay'] != true) + .map((item) => item['exerciseType']?.toString().trim() ?? '') + .where((name) => name.isNotEmpty) + .toSet() + .toList(); + if (names.isEmpty) return '运动计划'; + return names.length == 1 ? names.first : '${names.first}等${names.length}项'; +} + +String _dateKey(DateTime date) => + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + +String _compactDate(Object? value) { + final parsed = DateTime.tryParse(value?.toString() ?? ''); + return parsed == null + ? '--' + : '${parsed.year}.${parsed.month.toString().padLeft(2, '0')}.${parsed.day.toString().padLeft(2, '0')}'; +} + +String _phaseLabel(CarePlanPhase phase) => switch (phase) { + CarePlanPhase.active => '进行中', + CarePlanPhase.upcoming => '即将开始', + CarePlanPhase.ended => '已结束', +}; + +Color _phaseColor(CarePlanPhase phase) => switch (phase) { + CarePlanPhase.active => AppColors.successText, + CarePlanPhase.upcoming => AppColors.blueMeasure, + CarePlanPhase.ended => AppColors.textHint, +}; diff --git a/health_app/lib/pages/history/conversation_history_page.dart b/health_app/lib/pages/history/conversation_history_page.dart index 9742572..aebba3f 100644 --- a/health_app/lib/pages/history/conversation_history_page.dart +++ b/health_app/lib/pages/history/conversation_history_page.dart @@ -2,12 +2,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/conversation_history_provider.dart'; import '../../widgets/app_toast.dart'; +Future deleteConversationForDismiss( + Future Function() delete, +) async { + try { + await delete(); + return true; + } catch (_) { + return false; + } +} + class ConversationHistoryPage extends ConsumerWidget { const ConversationHistoryPage({super.key}); @@ -17,7 +29,7 @@ class ConversationHistoryPage extends ConsumerWidget { return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.white.withValues(alpha: 0.9), + backgroundColor: Colors.white, title: const Text('对话记录'), leading: IconButton( icon: const Icon(Icons.arrow_back), @@ -29,7 +41,7 @@ class ConversationHistoryPage extends ConsumerWidget { child: const Text( '清空', style: TextStyle( - color: AppColors.error, + color: AppColors.errorText, fontWeight: FontWeight.w800, ), ), @@ -51,13 +63,15 @@ class ConversationHistoryPage extends ConsumerWidget { : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 14, 16, 24), itemCount: list.length, - separatorBuilder: (_, index) => const SizedBox(height: 10), + separatorBuilder: (_, index) => const SizedBox.shrink(), itemBuilder: (context, index) { final item = list[index]; return _HistoryTile( item: item, onTap: () => _openConversation(context, ref, item.id), onDelete: () => _deleteOne(context, ref, item.id), + isFirst: index == 0, + isLast: index == list.length - 1, ); }, ), @@ -81,21 +95,22 @@ class ConversationHistoryPage extends ConsumerWidget { popRoute(ref); } - Future _deleteOne( + Future _deleteOne( BuildContext context, WidgetRef ref, String id, ) async { - try { - await ref.read(conversationHistoryProvider.notifier).deleteOne(id); - if (context.mounted) { - AppToast.show(context, '已删除', type: AppToastType.success); - } - } catch (_) { - if (context.mounted) { - AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error); - } + final deleted = await deleteConversationForDismiss( + () => ref.read(conversationHistoryProvider.notifier).deleteOne(id), + ); + if (context.mounted) { + AppToast.show( + context, + deleted ? '已删除' : '删除失败,请稍后重试', + type: deleted ? AppToastType.success : AppToastType.error, + ); } + return deleted; } Future _confirmClearAll(BuildContext context, WidgetRef ref) async { @@ -111,7 +126,7 @@ class ConversationHistoryPage extends ConsumerWidget { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: AppColors.error), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), child: const Text('清空'), ), ], @@ -136,12 +151,16 @@ class ConversationHistoryPage extends ConsumerWidget { class _HistoryTile extends StatelessWidget { final ConversationListItem item; final VoidCallback onTap; - final VoidCallback onDelete; + final Future Function() onDelete; + final bool isFirst; + final bool isLast; const _HistoryTile({ required this.item, required this.onTap, required this.onDelete, + required this.isFirst, + required this.isLast, }); @override @@ -153,25 +172,25 @@ class _HistoryTile extends StatelessWidget { alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 18), decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(14), + color: AppColors.errorText, + borderRadius: AppRadius.lgBorder, ), child: const Icon(Icons.delete_outline, color: Colors.white), ), - confirmDismiss: (_) async { - onDelete(); - return true; - }, + confirmDismiss: (_) => onDelete(), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(14), + borderRadius: _rowRadius, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, + borderRadius: _rowRadius, + border: isLast + ? null + : const Border( + bottom: BorderSide(color: AppColors.divider, width: 0.7), + ), ), child: Row( children: [ @@ -225,8 +244,15 @@ class _HistoryTile extends StatelessWidget { local.day == yesterday.day) { return '昨天'; } - return '${local.month}/${local.day}'; + return '${local.month}-${local.day}'; } + + BorderRadius get _rowRadius => BorderRadius.only( + topLeft: isFirst ? const Radius.circular(AppRadius.lg) : Radius.zero, + topRight: isFirst ? const Radius.circular(AppRadius.lg) : Radius.zero, + bottomLeft: isLast ? const Radius.circular(AppRadius.lg) : Radius.zero, + bottomRight: isLast ? const Radius.circular(AppRadius.lg) : Radius.zero, + ); } class _EmptyHint extends StatelessWidget { diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 6e1c0c6..d216ffb 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -1,13 +1,14 @@ import 'dart:io'; import 'dart:async'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; -import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; @@ -28,7 +29,6 @@ class _HomePageState extends ConsumerState final _scrollCtrl = ScrollController(); final _focusNode = FocusNode(); final _scaffoldKey = GlobalKey(); - double? _drawerDragStartX; String? _pickedImagePath; Timer? _notificationTimer; @@ -36,19 +36,21 @@ class _HomePageState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback( - (_) => ref.invalidate(notificationUnreadCountProvider), - ); + _scheduleScrollToLatest(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkDueNotifications(); + _refreshTodayHealthIfCached(); + }); _notificationTimer = Timer.periodic( const Duration(minutes: 2), - (_) => ref.invalidate(notificationUnreadCountProvider), + (_) => _checkDueNotifications(), ); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - ref.invalidate(notificationUnreadCountProvider); + _checkDueNotifications(); } } @@ -76,6 +78,26 @@ class _HomePageState extends ConsumerState } } + void _refreshTodayHealthIfCached() { + if (!ref.read(todayHealthSnapshotProvider).hasValue) return; + ref.invalidate(latestHealthProvider); + ref.invalidate(medicationReminderProvider); + ref.invalidate(currentExercisePlanProvider); + } + + void _scheduleScrollToLatest() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollCtrl.hasClients) return; + _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); + + // Lazy list items can refine the final extent after the first layout. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollCtrl.hasClients) return; + _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); + }); + }); + } + @override Widget build(BuildContext context) { final auth = ref.watch(authProvider); @@ -105,55 +127,36 @@ class _HomePageState extends ConsumerState current, ) { if (current <= (previous ?? 0)) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _scrollCtrl.hasClients) { - _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); - } - }); + _scheduleScrollToLatest(); }); return Scaffold( key: _scaffoldKey, backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), - drawerEnableOpenDragGesture: false, - body: AppBackground( - safeArea: true, - child: Column( - children: [ - _buildHeader(user), - Expanded( - child: Stack( - children: [ - RepaintBoundary( - child: _HomeMessages(scrollCtrl: _scrollCtrl), - ), - Positioned( - left: 0, - top: 0, - bottom: 0, - width: MediaQuery.sizeOf(context).width * 0.8, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (details) { - _drawerDragStartX = details.globalPosition.dx; - }, - onHorizontalDragUpdate: (details) { - final startX = _drawerDragStartX; - if (startX == null) return; - if (details.globalPosition.dx - startX < 28) return; - _drawerDragStartX = null; - _scaffoldKey.currentState?.openDrawer(); - }, - onHorizontalDragEnd: (_) => _drawerDragStartX = null, - onHorizontalDragCancel: () => _drawerDragStartX = null, - ), - ), - ], + drawerEnableOpenDragGesture: true, + drawerDragStartBehavior: DragStartBehavior.down, + drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.8, + body: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)], + ), + ), + child: SafeArea( + child: Column( + children: [ + _buildHeader(user), + Expanded( + child: RepaintBoundary( + child: _HomeMessages(scrollCtrl: _scrollCtrl), + ), ), - ), - _buildBottomBar(context), - ], + _buildBottomBar(context), + ], + ), ), ), ); @@ -165,20 +168,7 @@ class _HomePageState extends ConsumerState : '用户'; final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0; return Container( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.34), - border: Border( - bottom: BorderSide(color: Colors.white.withValues(alpha: 0.62)), - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF101828).withValues(alpha: 0.025), - blurRadius: 16, - offset: const Offset(0, 6), - ), - ], - ), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), child: Row( children: [ Builder( @@ -189,7 +179,7 @@ class _HomePageState extends ConsumerState ); }, ), - const SizedBox(width: 12), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -261,39 +251,44 @@ class _HomePageState extends ConsumerState .read(chatProvider.notifier) .triggerAgent(agent, visual.label), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: selected - ? AppColors.auraIndigo.withValues(alpha: 0.42) - : const Color(0xFFD5DAE4), - width: selected ? 1.4 : 1.1, - ), + color: selected ? null : Colors.transparent, + gradient: selected ? AppColors.actionOutlineGradient : null, + borderRadius: AppRadius.pillBorder, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 16, - height: 16, - decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(6), + padding: const EdgeInsets.all(1.2), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 11.8, + vertical: 7.8, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.pillBorder, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + gradient: visual.gradient, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(visual.icon, size: 13, color: Colors.white), ), - child: Icon(visual.icon, size: 11, color: Colors.white), - ), - const SizedBox(width: 6), - Text( - visual.label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, + const SizedBox(width: 6), + Text( + visual.label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), - ), - ], + ], + ), ), ), ); @@ -314,7 +309,7 @@ class _HomePageState extends ConsumerState ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health), ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet), ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication), - ActiveAgent.report => fromModule('报告分析', AppModuleVisuals.report), + ActiveAgent.report => fromModule('报告', AppModuleVisuals.report), ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise), _ => null, }; @@ -400,74 +395,87 @@ class _HomePageState extends ConsumerState Widget _buildInputBar() { return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _RoundToolButton( - icon: LucideIcons.plus, - onTap: () => _showAttachmentPicker(context), - ), - const SizedBox(width: 8), - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 118), - child: TextField( - controller: _textCtrl, - focusNode: _focusNode, - style: const TextStyle( - fontSize: 15, - color: AppColors.textPrimary, + child: Container( + padding: const EdgeInsets.fromLTRB(6, 5, 6, 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.pillBorder, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _showAttachmentPicker(context), + child: Ink( + width: 38, + height: 38, + decoration: const BoxDecoration( + gradient: AppColors.primaryGradient, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.plus, + size: 21, + color: Colors.white, + ), ), - maxLines: null, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: '输入你想说的...', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 11, + ), + ), + const SizedBox(width: 6), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 112), + child: TextField( + controller: _textCtrl, + focusNode: _focusNode, + style: const TextStyle( + fontSize: 15, + color: AppColors.textPrimary, ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: const BorderSide(color: AppColors.borderLight), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: const BorderSide(color: AppColors.borderLight), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(22), - borderSide: const BorderSide( - color: AppColors.auraIndigo, - width: 1.2, + maxLines: null, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + hintText: '输入你想说的...', + filled: false, + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 12, ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + ), + const SizedBox(width: 6), + Material( + color: Colors.transparent, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _sendMessage, + child: Ink( + width: 38, + height: 38, + decoration: const BoxDecoration( + gradient: AppColors.primaryGradient, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.send, + size: 18, + color: Colors.white, ), ), - onSubmitted: (_) => _sendMessage(), ), ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _sendMessage, - child: Container( - width: 42, - height: 42, - margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2), - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(21), - boxShadow: AppColors.buttonShadow, - ), - child: const Icon( - LucideIcons.send, - size: 19, - color: Colors.white, - ), - ), - ), - ], + ], + ), ), ); } @@ -517,23 +525,7 @@ class _HomePageState extends ConsumerState title: const Text('上传 PDF'), onTap: () async { Navigator.pop(ctx); - _dismissKeyboard(); - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf'], - withData: false, - ); - if (result == null || result.files.isEmpty) return; - final pdfFile = result.files.first; - final path = pdfFile.path; - if (path == null || path.isEmpty) return; - // 上传 + 让 AI 看 PDF 内容 - await ref - .read(chatProvider.notifier) - .sendPdf(path, pdfFile.name, _textCtrl.text.trim()); - _textCtrl.clear(); - _dismissKeyboard(); - if (mounted) setState(() {}); + await _pickPdf(); }, ), ], @@ -563,6 +555,34 @@ class _HomePageState extends ConsumerState FocusManager.instance.primaryFocus?.unfocus(); } + Future _pickPdf() async { + _dismissKeyboard(); + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf'], + withData: false, + ); + if (result == null || result.files.isEmpty) return; + final pdfFile = result.files.first; + final path = pdfFile.path; + if (path == null || path.isEmpty) return; + await ref + .read(chatProvider.notifier) + .sendPdf(path, pdfFile.name, _textCtrl.text.trim()); + _textCtrl.clear(); + _dismissKeyboard(); + } + + Future _checkDueNotifications() async { + try { + await ref.read(inAppNotificationServiceProvider).checkDue(); + } catch (_) { + // Network failures should not block the home page. + } finally { + ref.invalidate(notificationUnreadCountProvider); + } + } + Future _pickFoodImage(ImageSource source) async { final picked = await ImagePicker().pickImage( source: source, @@ -609,8 +629,8 @@ class _HeaderIconButton extends StatelessWidget { clipBehavior: Clip.none, children: [ Container( - width: 38, - height: 38, + width: 44, + height: 44, decoration: BoxDecoration( gradient: AppColors.surfaceGradient, borderRadius: BorderRadius.circular(14), @@ -647,28 +667,3 @@ class _HeaderIconButton extends StatelessWidget { ); } } - -class _RoundToolButton extends StatelessWidget { - final IconData icon; - final VoidCallback onTap; - const _RoundToolButton({required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 42, - height: 42, - margin: const EdgeInsets.only(bottom: 2), - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(21), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Icon(icon, size: 21, color: AppColors.primaryDark), - ), - ); - } -} 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 bcc6aba..689f715 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -13,50 +13,8 @@ import '../../../providers/data_providers.dart'; import '../../../widgets/ai_content.dart'; import '../../../widgets/app_toast.dart'; -/// 卡片入场动画包装(从下往上滑入 + 淡入) -class _AnimatedCardEntry extends StatefulWidget { - final Widget child; - const _AnimatedCardEntry({required this.child}); - - @override - State<_AnimatedCardEntry> createState() => _AnimatedCardEntryState(); -} - -class _AnimatedCardEntryState extends State<_AnimatedCardEntry> - with SingleTickerProviderStateMixin { - late final _ctrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 600), - ); - late final _slide = Tween( - begin: const Offset(0, 0.12), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); - late final _fade = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); - - @override - void initState() { - super.initState(); - _ctrl.forward(); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FadeTransition( - opacity: _fade, - child: SlideTransition(position: _slide, child: widget.child), - ); - } -} +ChatMessage messageAtDisplayIndex(List messages, int index) => + messages[index]; /// 对话消息列表 class ChatMessagesView extends ConsumerWidget { @@ -107,18 +65,20 @@ class ChatMessagesView extends ConsumerWidget { return ListView.builder( 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, - streaming.$1, - streaming.$2 ?? '', + final msg = messageAtDisplayIndex(messages, index); + return KeyedSubtree( + key: ValueKey('chat-message-${msg.id}'), + child: _buildMessageContent( + context, + ref, + msg, + streaming.$1, + streaming.$2 ?? '', + ), ); }, ); @@ -167,224 +127,211 @@ class ChatMessagesView extends ConsumerWidget { final agentColors = _agentColors(agent); final artworkPath = _agentArtworkPath(agent); - return _AnimatedCardEntry( - child: Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 16), - constraints: BoxConstraints(maxWidth: screenWidth * 0.95), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: const Color(0xFF6B5CE7).withValues(alpha: 0.12), - blurRadius: 28, - offset: const Offset(0, 14), - ), - BoxShadow( - color: const Color(0xFF101828).withValues(alpha: 0.06), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ── 标题区域 ── - Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white, - agentColors.bg.withValues(alpha: 0.86), - agentColors.accent.withValues(alpha: 0.12), - ], - stops: const [0.0, 0.58, 1.0], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 16), + constraints: BoxConstraints(maxWidth: screenWidth * 0.95), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6B5CE7).withValues(alpha: 0.12), + blurRadius: 28, + offset: const Offset(0, 14), + ), + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white, + agentColors.bg.withValues(alpha: 0.86), + agentColors.accent.withValues(alpha: 0.12), + ], + stops: const [0.0, 0.58, 1.0], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), ), ), - Positioned( - right: 18, - top: 18, - child: _MiniSignalStrip(colors: agentColors), - ), - Positioned( - right: 18, - bottom: 18, - child: _LinearAccentMark(colors: agentColors), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(18, 20, 18, 22), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AgentMark(icon: info.$1, colors: agentColors), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.76), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: agentColors.accent.withValues( - alpha: 0.18, - ), + ), + Positioned( + right: 18, + top: 18, + child: _MiniSignalStrip(colors: agentColors), + ), + Positioned( + right: 18, + bottom: 18, + child: _LinearAccentMark(colors: agentColors), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(18, 20, 18, 22), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AgentMark(icon: info.$1, colors: agentColors), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.76), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: agentColors.accent.withValues( + alpha: 0.18, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.graphic_eq, - size: 14, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.graphic_eq, + size: 14, + color: agentColors.accent, + ), + const SizedBox(width: 4), + Text( + 'AI 智能助手', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, color: agentColors.accent, ), - const SizedBox(width: 4), - Text( - 'AI 智能助手', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: agentColors.accent, - ), - ), - ], - ), + ), + ], ), - const SizedBox(height: 9), - Text( - info.$2, - style: AppTextStyles.chatTitle.copyWith( - fontSize: 22, - fontWeight: FontWeight.w900, - ), + ), + const SizedBox(height: 9), + Text( + info.$2, + style: AppTextStyles.chatTitle.copyWith( + fontSize: 22, + fontWeight: FontWeight.w900, ), - const SizedBox(height: 8), - Text( - info.$3, - style: AppTextStyles.summarySubtitle, - ), - ], - ), + ), + const SizedBox(height: 8), + Text(info.$3, style: AppTextStyles.summarySubtitle), + ], ), - ], - ), - ), - ], - ), - - // ── 插画展示 ── - Image.asset( - artworkPath, - width: double.infinity, - height: 206, - fit: BoxFit.cover, - ), - - // ── AI问诊引导 ── - if (agent == ActiveAgent.consultation) ...[ - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF0F4FF), - borderRadius: BorderRadius.circular(14), - ), - child: const Text( - '在下方输入框描述您的症状,AI医生将为您提供专业建议', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: AppColors.textSecondary, ), - ), - ), - ), - ], - - if (actions.isNotEmpty) ...[ - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (int i = 0; i < actions.length; i++) ...[ - if (i > 0) const SizedBox(width: 10), - Flexible( - child: _buildActionButton( - actions[i], - context, - ref, - agentColors, - ), - ), - ], ], ), ), ], - - // ── 底部提示 ── - const SizedBox(height: 16), + ), + Image.asset( + artworkPath, + width: double.infinity, + height: 206, + fit: BoxFit.cover, + ), + if (agent == ActiveAgent.consultation) ...[ + const SizedBox(height: 14), Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), + width: double.infinity, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: agentColors.bg, + color: const Color(0xFFF0F4FF), borderRadius: BorderRadius.circular(14), - border: Border.all( - color: agentColors.accent.withValues(alpha: 0.14), - ), ), - child: Row( - children: [ - Icon( - Icons.lightbulb_outline, - size: 18, - color: agentColors.accent, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _agentTip(agent), - style: TextStyle( - fontSize: 15, - color: AppColors.textPrimary.withAlpha(180), - fontWeight: FontWeight.w500, - ), - ), - ), - ], + child: const Text( + '小脉会陪您一步步梳理症状。请先告诉我哪里不舒服,或说说您现在最担心的问题。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), ), ), ), ], - ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (int i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(width: 10), + Flexible( + child: _buildActionButton( + actions[i], + context, + ref, + agentColors, + ), + ), + ], + ], + ), + ), + ], + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: agentColors.bg, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: agentColors.accent.withValues(alpha: 0.14), + ), + ), + child: Row( + children: [ + Icon( + Icons.lightbulb_outline, + size: 18, + color: agentColors.accent, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _agentTip(agent), + style: TextStyle( + fontSize: 15, + color: AppColors.textPrimary.withAlpha(180), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ], ), ), ); @@ -403,6 +350,8 @@ class ChatMessagesView extends ConsumerWidget { } else if (a.action == 'pickFoodCamera' || a.action == 'pickFoodGallery') { ref.read(dietActionProvider.notifier).trigger(a.action!); + } else if (a.action == 'openReportUpload') { + pushRoute(ref, 'reports', params: {'openUpload': 'true'}); } else if (a.route != null) { if (a.route == 'camera' || a.route == 'gallery') { ref.read(cameraActionProvider.notifier).trigger(a.route!); @@ -525,7 +474,7 @@ class ChatMessagesView extends ConsumerWidget { } else if (isExercise) { // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 title = '运动计划确认'; - titleIcon = LucideIcons.footprints; + titleIcon = AppModuleVisuals.exercise.icon; final exName = (meta['value'] ?? meta['name'] ?? @@ -587,373 +536,369 @@ class ChatMessagesView extends ConsumerWidget { } } - return _AnimatedCardEntry( - child: Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 16), - constraints: BoxConstraints(maxWidth: screenWidth * 0.95), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(28), - boxShadow: [ - BoxShadow( - color: visual.color.withValues(alpha: 0.10), - blurRadius: 28, - offset: const Offset(0, 14), - ), - BoxShadow( - color: const Color(0xFF101828).withValues(alpha: 0.06), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // ── 标题区域 ── - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Colors.white, visual.lightColor], - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: visual.color.withValues(alpha: 0.18), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Icon(titleIcon, size: 28, color: Colors.white), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 6), - Row( - children: [ - Icon( - Icons.access_time, - size: 15, - color: AppColors.iconColor, - ), - const SizedBox(width: 4), - Text( - recordTime, - style: const TextStyle( - fontSize: 15, - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - ), - ], + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 16), + constraints: BoxConstraints(maxWidth: screenWidth * 0.95), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: visual.color.withValues(alpha: 0.10), + blurRadius: 28, + offset: const Offset(0, 14), + ), + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 标题区域 ── + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.white, visual.lightColor], ), ), - - // ── 主要数据展示区(大卡片) ── - Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: Container( - padding: const EdgeInsets.all(1.4), - decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(20), - ), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), + gradient: visual.gradient, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: visual.color.withValues(alpha: 0.18), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - child: Row( + child: Icon(titleIcon, size: 28, color: Colors.white), + ), + const SizedBox(width: 14), + Expanded( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 68, - height: 68, - decoration: BoxDecoration( - color: visual.lightColor, - borderRadius: BorderRadius.circular(18), - ), - child: Center( - child: isHealth - ? Icon( - _getMetricIconData(backendType), - size: 34, - color: visual.color, - ) - : Icon( - isMedication - ? Icons.medication_liquid_outlined - : LucideIcons.footprints, - size: 36, - color: visual.color, - ), + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - mainLabel, - style: const TextStyle( - fontSize: 17, - color: AppColors.textSecondary, - ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.access_time, + size: 15, + color: AppColors.iconColor, + ), + const SizedBox(width: 4), + Text( + recordTime, + style: const TextStyle( + fontSize: 15, + color: AppColors.textSecondary, ), - if (mainValue.isNotEmpty) ...[ - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: mainValue, - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w800, - color: abnormal - ? AppColors.error - : AppColors.textPrimary, - letterSpacing: -0.5, - ), - ), - if (mainUnit.isNotEmpty) - TextSpan( - text: ' $mainUnit', - style: TextStyle( - fontSize: 19, - color: abnormal - ? AppColors.error - : visual.color, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ], - ), + ), + ], ), ], ), ), + ], + ), + ), + + // ── 主要数据展示区(大卡片) ── + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: Container( + padding: const EdgeInsets.all(1.4), + decoration: BoxDecoration( + gradient: visual.gradient, + borderRadius: BorderRadius.circular(20), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: visual.lightColor, + borderRadius: BorderRadius.circular(18), + ), + child: Center( + child: isHealth + ? Icon( + _getMetricIconData(backendType), + size: 34, + color: visual.color, + ) + : Icon( + isMedication + ? Icons.medication_liquid_outlined + : AppModuleVisuals.exercise.icon, + size: 36, + color: visual.color, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mainLabel, + style: const TextStyle( + fontSize: 17, + color: AppColors.textSecondary, + ), + ), + if (mainValue.isNotEmpty) ...[ + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: mainValue, + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w800, + color: abnormal + ? AppColors.error + : AppColors.textPrimary, + letterSpacing: -0.5, + ), + ), + if (mainUnit.isNotEmpty) + TextSpan( + text: ' $mainUnit', + style: TextStyle( + fontSize: 19, + color: abnormal + ? AppColors.error + : visual.color, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ), ), ), + ), - // ── 异常提示 ── - if (abnormal) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - color: AppColors.error.withAlpha(10), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.error.withAlpha(30)), - ), - child: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.error.withAlpha(15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.warning_amber_rounded, - size: 20, - color: AppColors.error, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '数值偏高,建议关注并咨询医生', - style: TextStyle( - fontSize: 16, - color: AppColors.error, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - ], - - // ── 详细字段列表(可点击修改) ── - if (fields.isNotEmpty) ...[ - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.border), - ), - child: Column( - children: [ - for (int i = 0; i < fields.length; i++) ...[ - if (i > 0) - Container( - height: 1, - color: AppColors.borderLight, - margin: const EdgeInsets.symmetric( - horizontal: 16, - ), - ), - _buildFieldRow(fields[i]), - ], - ], - ), - ), - ), - ], - - // ── 确认按钮(白色底+渐变边框) ── - const SizedBox(height: 18), + // ── 异常提示 ── + if (abnormal) ...[ + const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, - height: 56, - padding: const EdgeInsets.all(1.4), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: visual.color.withValues(alpha: 0.18), - blurRadius: 18, - offset: const Offset(0, 8), + color: AppColors.error.withAlpha(10), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.error.withAlpha(30)), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.error.withAlpha(15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.warning_amber_rounded, + size: 20, + color: AppColors.error, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '数值偏高,建议关注并咨询医生', + style: TextStyle( + fontSize: 16, + color: AppColors.error, + fontWeight: FontWeight.w500, + ), + ), ), ], ), - child: msg.confirmed || msg.isReadOnly - ? Container( - decoration: BoxDecoration( - gradient: AppColors.successButtonGradient, - borderRadius: BorderRadius.circular(18), + ), + ), + ], + + // ── 详细字段列表(可点击修改) ── + if (fields.isNotEmpty) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + for (int i = 0; i < fields.length; i++) ...[ + if (i > 0) + Container( + height: 1, + color: AppColors.borderLight, + margin: const EdgeInsets.symmetric(horizontal: 16), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.check_circle_outline, - size: 22, + _buildFieldRow(fields[i]), + ], + ], + ), + ), + ), + ], + + // ── 确认按钮(白色底+渐变边框) ── + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + height: 56, + padding: const EdgeInsets.all(1.4), + decoration: BoxDecoration( + gradient: visual.gradient, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: visual.color.withValues(alpha: 0.18), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: msg.confirmed || msg.isReadOnly + ? Container( + decoration: BoxDecoration( + gradient: AppColors.successButtonGradient, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_outline, + size: 22, + color: Colors.white, + ), + const SizedBox(width: 8), + Text( + msg.isReadOnly ? '历史记录' : '录入成功', + style: const TextStyle( + fontSize: 19, + fontWeight: FontWeight.w700, color: Colors.white, ), - const SizedBox(width: 8), - Text( - msg.isReadOnly ? '历史记录' : '录入成功', - style: const TextStyle( - fontSize: 19, - fontWeight: FontWeight.w700, - color: Colors.white, + ), + ], + ), + ) + : Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + final error = await ref + .read(chatProvider.notifier) + .confirmMessage(msg.id); + if (error != null && context.mounted) { + AppToast.show( + context, + error, + type: AppToastType.error, + ); + } + }, + borderRadius: BorderRadius.circular(18), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + gradient: visual.gradient, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.check_rounded, + size: 19, + color: Colors.white, + ), ), - ), - ], - ), - ) - : Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - final error = await ref - .read(chatProvider.notifier) - .confirmMessage(msg.id); - if (error != null && context.mounted) { - AppToast.show( - context, - error, - type: AppToastType.error, - ); - } - }, - borderRadius: BorderRadius.circular(18), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.check_rounded, - size: 19, - color: Colors.white, - ), + const SizedBox(width: 10), + const Text( + '确认录入', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, ), - const SizedBox(width: 10), - const Text( - '确认录入', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - ], - ), + ), + ], ), ), ), - ), + ), ), - const SizedBox(height: 20), - ], - ), + ), + const SizedBox(height: 20), + ], ), ), ); @@ -996,27 +941,10 @@ class ChatMessagesView extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(1.4), - decoration: BoxDecoration( - gradient: AppColors.actionOutlineGradient, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(20), - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), - boxShadow: [ - BoxShadow( - color: AppTheme.primary.withAlpha(12), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), child: Container( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: const BoxDecoration( - color: AppColors.cardBackground, + color: Color(0xFFF8F8FF), borderRadius: BorderRadius.only( topLeft: Radius.circular(3), topRight: Radius.circular(18.6), @@ -1073,29 +1001,11 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width * 0.82, ), - padding: EdgeInsets.all(isUser ? 0 : 1.4), - decoration: isUser - ? null - : BoxDecoration( - gradient: AppColors.actionOutlineGradient, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(20), - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), - boxShadow: [ - BoxShadow( - color: AppTheme.primary.withAlpha(12), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), + padding: EdgeInsets.zero, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isUser ? const Color(0xFF5F56FF) : AppColors.cardBackground, + color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFF8F8FF), borderRadius: BorderRadius.only( topLeft: Radius.circular(isUser ? 18.6 : 3), topRight: Radius.circular(isUser ? 3 : 18.6), @@ -1198,31 +1108,7 @@ class ChatMessagesView extends ConsumerWidget { ), ), - if (!isUser && msg.content.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Row( - children: [ - const CircleAvatar( - radius: 10, - backgroundColor: AppTheme.primaryLight, - child: Icon( - Icons.chat_bubble_outline, - size: 17, - color: AppTheme.primary, - ), - ), - const SizedBox(width: 6), - const Text( - '内容由 AI 生成', - style: TextStyle( - fontSize: 14, - color: AppColors.textHint, - ), - ), - ], - ), - ), + if (!isUser && msg.content.isNotEmpty) const AiGeneratedNote(), ], ), ), @@ -1436,10 +1322,14 @@ class ChatMessagesView extends ConsumerWidget { ActiveAgent.consultation => ( LucideIcons.messageCircle, 'AI问诊', - 'AI智能问诊,描述症状获取建议', + '现在由小脉为您进行预问诊,您可以放心说出身体的不适和疑问', ), ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'), - ActiveAgent.exercise => (LucideIcons.footprints, '运动', '制定运动计划,打卡记录进度'), + ActiveAgent.exercise => ( + Icons.directions_run_outlined, + '运动', + '制定运动计划,打卡记录进度', + ), _ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'), }; } @@ -1448,7 +1338,7 @@ class ChatMessagesView extends ConsumerWidget { ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"', ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"', ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"', - ActiveAgent.consultation => '描述您的症状和不适感,如"最近总是头晕胸闷"', + ActiveAgent.consultation => '小脉会根据您的回答逐步追问,并给出观察或就医建议', ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读', ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"', _ => '直接描述您的需求,AI 会自动为您记录和分析', @@ -1474,14 +1364,106 @@ class ChatMessagesView extends ConsumerWidget { } Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) { - final health = ref.watch(latestHealthProvider); - final reminders = ref.watch(medicationReminderProvider); - return health.when( - data: (data) => _taskCardBubble(context, ref, data, reminders), - loading: () => - _taskCardBubble(context, ref, {}, const AsyncValue.loading()), - error: (_, e) => - _taskCardBubble(context, ref, {}, const AsyncValue.loading()), + final snapshotAsync = ref.watch(todayHealthSnapshotProvider); + final snapshot = snapshotAsync.value; + if (snapshot == null) { + return _taskCardPlaceholder( + snapshotAsync.hasError ? '今日健康暂时无法获取' : '正在获取今日健康', + isError: snapshotAsync.hasError, + onRetry: () => ref.invalidate(todayHealthSnapshotProvider), + ); + } + return _taskCardBubble( + context, + ref, + snapshot.health, + AsyncValue.data(snapshot.reminders), + AsyncValue.data(snapshot.exercisePlan), + ); + } + + Widget _taskCardPlaceholder( + String message, { + required bool isError, + required VoidCallback onRetry, + }) { + final now = DateTime.now(); + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + boxShadow: AppColors.cardShadow, + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 13, 16, 11), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)], + ), + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Row( + children: [ + const Text( + '今日健康', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + Text( + '${now.month}月${now.day}日', + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + InkWell( + onTap: isError ? onRetry : null, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 11), + child: Row( + children: [ + SizedBox.square( + dimension: 28, + child: isError + ? const Icon( + LucideIcons.refreshCw, + size: 18, + color: AppColors.textSecondary, + ) + : const CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isError ? '$message,点击重试' : message, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + ), + ), + ], + ), ); } @@ -1490,6 +1472,7 @@ class ChatMessagesView extends ConsumerWidget { WidgetRef ref, Map healthData, AsyncValue>> reminders, + AsyncValue?> exercisePlan, ) { final now = DateTime.now(); final tasks = []; @@ -1583,7 +1566,7 @@ class ChatMessagesView extends ConsumerWidget { tasks.add( _taskRow( context, - LucideIcons.triangleAlert, + AppModuleVisuals.healthOverviewIcon, '健康指标', trailing: trailing, status: 'warning', @@ -1597,7 +1580,7 @@ class ChatMessagesView extends ConsumerWidget { tasks.add( _taskRow( context, - LucideIcons.circleCheck, + AppModuleVisuals.healthOverviewIcon, '健康指标', trailing: '指标正常', status: 'done', @@ -1611,7 +1594,6 @@ class ChatMessagesView extends ConsumerWidget { // ── 2. 运动 ── final exIconColor = AppModuleVisuals.exercise.color; final exIconBg = AppModuleVisuals.exercise.lightColor; - final exercisePlan = ref.watch(currentExercisePlanProvider); var hasExercise = false; exercisePlan.when( data: (plan) { @@ -1636,7 +1618,7 @@ class ChatMessagesView extends ConsumerWidget { tasks.add( _taskRow( context, - LucideIcons.footprints, + AppModuleVisuals.exercise.icon, '$name $dur分钟', status: done ? 'done' @@ -1654,7 +1636,7 @@ class ChatMessagesView extends ConsumerWidget { tasks.add( _taskRow( context, - LucideIcons.footprints, + AppModuleVisuals.exercise.icon, '运动', trailing: '正在加载运动计划', status: 'pending', @@ -1669,7 +1651,7 @@ class ChatMessagesView extends ConsumerWidget { tasks.add( _taskRow( context, - LucideIcons.footprints, + AppModuleVisuals.exercise.icon, '运动', trailing: '运动计划加载失败', status: 'overdue', @@ -1684,7 +1666,7 @@ class ChatMessagesView extends ConsumerWidget { tasks.add( _taskRow( context, - LucideIcons.footprints, + AppModuleVisuals.exercise.icon, '运动', trailing: '暂无今日运动计划', status: 'pending', @@ -1823,14 +1805,14 @@ class ChatMessagesView extends ConsumerWidget { ), ), Padding( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.fromLTRB(14, 5, 14, 7), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (tasks.isEmpty) _taskRow( context, - Icons.check_circle, + LucideIcons.circleCheck, '今日暂无待办', status: 'done', ) @@ -1861,55 +1843,61 @@ class ChatMessagesView extends ConsumerWidget { 'overdue': AppTheme.error, }; final icons = { - 'done': Icons.check_circle, - 'warning': Icons.warning, - 'pending': Icons.circle_outlined, - 'overdue': Icons.error, + 'done': LucideIcons.circleCheck, + 'warning': LucideIcons.triangleAlert, + 'pending': LucideIcons.circle, + 'overdue': LucideIcons.circleAlert, }; - final isOverdue = status == 'overdue'; final ic = iconColor ?? AppTheme.primary; final ibg = iconBg ?? AppTheme.primaryLight; + final textColor = switch (status) { + 'overdue' => AppColors.error, + 'warning' => AppColors.warning, + _ => AppColors.textPrimary, + }; return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: ibg, - borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.symmetric(vertical: 1), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: AppRadius.smBorder, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ibg, + borderRadius: AppRadius.smBorder, + ), + child: Icon(icon, size: 18, color: ic), ), - child: Icon(icon, size: 18, color: ic), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - trailing ?? label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - color: AppColors.textPrimary, + const SizedBox(width: 10), + Expanded( + child: Text( + trailing ?? label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: status == 'overdue' || status == 'warning' + ? FontWeight.w600 + : FontWeight.w500, + color: textColor, + ), ), ), - ), - Icon( - icons[status] ?? Icons.circle_outlined, - size: 21, - color: colors[status] ?? Colors.grey, - ), - ], + Icon( + icons[status] ?? Icons.circle_outlined, + size: 20, + color: colors[status] ?? Colors.grey, + ), + ], + ), ), ), ), @@ -2064,7 +2052,7 @@ final _agentActions = >{ ActiveAgent.health: [ _AgentAction( label: '健康概览', - icon: Icons.trending_up, + icon: AppModuleVisuals.healthOverviewIcon, isWide: true, route: 'trend', ), @@ -2104,10 +2092,10 @@ final _agentActions = >{ label: '上传报告', icon: Icons.upload_file_outlined, isWide: true, - route: 'reports', + action: 'openReportUpload', ), _AgentAction( - label: '查看历史', + label: '查看报告', icon: Icons.history_outlined, isWide: true, route: 'reports', diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index 762c206..88e9c84 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../core/api_client.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; @@ -22,9 +25,6 @@ class MedicationCheckInPage extends ConsumerStatefulWidget { } class _MedicationCheckInPageState extends ConsumerState { - static const _visual = AppModuleVisuals.medication; - static const _softGreen = Color(0xFFEAF8EF); - Future>>? _future; final Set _busyDoses = {}; @@ -34,11 +34,9 @@ class _MedicationCheckInPageState extends ConsumerState { _load(); } - void _load() { - setState(() { - _future = ref.read(medicationReminderProvider.future); - }); - } + void _load() => setState(() { + _future = ref.read(medicationReminderProvider.future); + }); Future _refresh() async { ref.invalidate(medicationReminderProvider); @@ -48,26 +46,32 @@ class _MedicationCheckInPageState extends ConsumerState { Future _toggleDose(String medId, String time, bool taken) async { final doseKey = '$medId-$time'; - if (_busyDoses.contains(doseKey)) return; - + if (medId.isEmpty || time.isEmpty || _busyDoses.contains(doseKey)) return; setState(() => _busyDoses.add(doseKey)); try { final api = ref.read(apiClientProvider); if (taken) { await api.delete('/api/medications/$medId/confirm-dose/$time'); } else { - await api.post( + final response = await api.post( '/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'}, ); + final data = response.data is Map ? response.data['data'] : null; + if (data is Map && data['success'] == false) { + throw const ApiException('该次服药已经打卡'); + } } ref.invalidate(medicationReminderProvider); ref.invalidate(medicationListProvider); _load(); - } catch (e) { - debugPrint('[MedCheckIn] 打卡失败: $e'); + } catch (error) { if (mounted) { - AppToast.show(context, '打卡失败,请稍后重试', type: AppToastType.error); + AppToast.show( + context, + error is ApiException ? error.message : '打卡失败,请稍后重试', + type: AppToastType.error, + ); } } finally { if (mounted) setState(() => _busyDoses.remove(doseKey)); @@ -83,59 +87,86 @@ class _MedicationCheckInPageState extends ConsumerState { onPressed: () => popRoute(ref), ), title: const Text('服药打卡'), - centerTitle: true, ), body: FutureBuilder>>( future: _future, - builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting && - !snap.hasData) { - return const Center( - child: CircularProgressIndicator(color: AppTheme.primary), - ); + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); } - if (snap.hasError) { + if (snapshot.hasError) { return AppErrorState(title: '用药信息加载失败', onRetry: _load); } - - final reminders = _filterReminders(snap.data ?? []); + final reminders = _filterReminders(snapshot.data ?? const []); if (reminders.isEmpty) { return RefreshIndicator( onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox(height: 92), + children: [ + const SizedBox(height: 92), AppEmptyState( - icon: Icons.task_alt_outlined, - title: '今天的用药已完成', - subtitle: '下拉可刷新最新用药提醒', + icon: AppModuleVisuals.medication.icon, + title: '今天没有服药安排', + subtitle: '今日可以安心休息,下拉可刷新最新计划', + iconColor: AppColors.medication, ), ], ), ); } - - final grouped = _groupByMedication(reminders); - final total = reminders.length; - final taken = reminders.where(_isTaken).length; - final pending = total - taken; - + final sorted = [...reminders] + ..sort( + (a, b) => _formatTime( + a['scheduledTime']?.toString() ?? '', + ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), + ); + final taken = sorted.where(_isTaken).length; return RefreshIndicator( onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + padding: AppSpacing.page, children: [ - _CheckInSummary(total: total, taken: taken, pending: pending), - const SizedBox(height: 14), - ...grouped.entries.map( - (entry) => _MedicationDoseCard( - name: entry.key, - doses: entry.value, - busyDoses: _busyDoses, - onToggle: _toggleDose, + _DoseProgressHeader(total: sorted.length, taken: taken), + const SizedBox(height: 18), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, ), + child: Column( + children: List.generate(sorted.length, (index) { + final dose = sorted[index]; + final key = + '${dose['id']?.toString() ?? ''}-${dose['scheduledTime']?.toString() ?? ''}'; + return _DoseTimelineRow( + dose: dose, + isFirst: index == 0, + isLast: index == sorted.length - 1, + isBusy: _busyDoses.contains(key), + onToggle: _toggleDose, + ); + }), + ), + ), + const SizedBox(height: 18), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.shield, + size: 16, + color: AppColors.textHint, + ), + SizedBox(width: 6), + Text( + '请按医嘱服药,如有不适及时咨询医生', + style: TextStyle(fontSize: 12, color: AppColors.textHint), + ), + ], ), ], ), @@ -148,210 +179,88 @@ class _MedicationCheckInPageState extends ConsumerState { List> _filterReminders( List> reminders, ) { - if (widget.medId == null) return reminders; - return reminders.where((r) => r['id']?.toString() == widget.medId).toList(); - } - - Map>> _groupByMedication( - List> reminders, - ) { - final grouped = >>{}; - for (final reminder in reminders) { - final name = reminder['name']?.toString().trim(); - grouped - .putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () { - return >[]; - }) - .add(reminder); - } - - for (final doses in grouped.values) { - doses.sort( - (a, b) => _formatTime( - a['scheduledTime']?.toString() ?? '', - ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), - ); - } - return grouped; + if (widget.medId == null || widget.medId!.isEmpty) return reminders; + return reminders + .where((item) => item['id']?.toString() == widget.medId) + .toList(); } } -class _CheckInSummary extends StatelessWidget { +Map>> groupMedicationReminders( + List> reminders, +) { + final grouped = >>{}; + for (var index = 0; index < reminders.length; index++) { + final reminder = reminders[index]; + final medicationId = reminder['id']?.toString().trim(); + final key = medicationId?.isNotEmpty == true + ? medicationId! + : 'unknown-$index'; + grouped.putIfAbsent(key, () => >[]).add(reminder); + } + for (final doses in grouped.values) { + doses.sort( + (a, b) => _formatTime( + a['scheduledTime']?.toString() ?? '', + ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), + ); + } + return grouped; +} + +class _DoseProgressHeader extends StatelessWidget { final int total; final int taken; - final int pending; - const _CheckInSummary({ - required this.total, - required this.taken, - required this.pending, - }); + const _DoseProgressHeader({required this.total, required this.taken}); @override Widget build(BuildContext context) { + final now = DateTime.now(); final progress = total == 0 ? 0.0 : taken / total; - return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 15), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], + borderRadius: AppRadius.lgBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - gradient: _MedicationCheckInPageState._visual.gradient, - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.medication_outlined, - color: Colors.white, - size: 24, - ), - ), - const SizedBox(width: 12), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '今日服药进度', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), + '${now.month}月${now.day}日 · 今日用药', + style: AppTextStyles.sectionTitle, ), - SizedBox(height: 2), + const SizedBox(height: 3), Text( - '按提醒时间逐项确认,保持用药节奏', - style: TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), + '$taken / $total 已完成', + style: AppTextStyles.listSubtitle, ), ], ), ), Text( '${(progress * 100).round()}%', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w900, - color: _MedicationCheckInPageState._visual.color, + style: AppTextStyles.summaryTitle.copyWith( + color: AppColors.medication, ), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), ClipRRect( - borderRadius: BorderRadius.circular(999), + borderRadius: AppRadius.pillBorder, child: LinearProgressIndicator( - minHeight: 9, + minHeight: 5, value: progress, backgroundColor: AppColors.cardInner, - valueColor: AlwaysStoppedAnimation( - _MedicationCheckInPageState._visual.color, - ), - ), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _SummaryStat( - label: '待完成', - value: '$pending', - icon: Icons.schedule_outlined, - color: AppColors.warning, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SummaryStat( - label: '已打卡', - value: '$taken', - icon: Icons.check_circle_outline, - color: AppTheme.success, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _SummaryStat( - label: '总次数', - value: '$total', - icon: Icons.format_list_numbered_outlined, - color: _MedicationCheckInPageState._visual.color, - ), - ), - ], - ), - ], - ), - ); - } -} - -class _SummaryStat extends StatelessWidget { - final String label; - final String value; - final IconData icon; - final Color color; - - const _SummaryStat({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(minHeight: 58), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withValues(alpha: 0.14)), - ), - child: Row( - children: [ - Icon(icon, color: color, size: 17), - const SizedBox(width: 7), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: AppColors.textSecondary, - ), - ), - ], + valueColor: const AlwaysStoppedAnimation(AppColors.medication), ), ), ], @@ -360,138 +269,16 @@ class _SummaryStat extends StatelessWidget { } } -class _MedicationDoseCard extends StatelessWidget { - final String name; - final List> doses; - final Set busyDoses; - final Future Function(String medId, String time, bool taken) onToggle; - - const _MedicationDoseCard({ - required this.name, - required this.doses, - required this.busyDoses, - required this.onToggle, - }); - - @override - Widget build(BuildContext context) { - final dosage = doses.first['dosage']?.toString().trim() ?? ''; - final takenCount = doses.where(_isTaken).length; - final allTaken = takenCount == doses.length; - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: allTaken - ? AppTheme.success.withValues(alpha: 0.26) - : AppColors.border, - width: 1.1, - ), - boxShadow: [AppTheme.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: allTaken - ? _MedicationCheckInPageState._softGreen - : _MedicationCheckInPageState._visual.lightColor, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.borderLight), - ), - child: Icon( - allTaken ? Icons.done_all_rounded : Icons.medication_liquid, - color: allTaken - ? AppTheme.success - : _MedicationCheckInPageState._visual.color, - size: 24, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - if (dosage.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - dosage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - color: AppColors.textSecondary, - ), - ), - ], - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), - decoration: BoxDecoration( - color: allTaken - ? _MedicationCheckInPageState._softGreen - : AppColors.cardInner, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - '$takenCount/${doses.length}', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w800, - color: allTaken - ? AppTheme.success - : AppColors.textSecondary, - ), - ), - ), - ], - ), - const SizedBox(height: 14), - for (var i = 0; i < doses.length; i++) ...[ - _DoseRow( - dose: doses[i], - isLast: i == doses.length - 1, - isBusy: busyDoses.contains( - '${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}', - ), - onToggle: onToggle, - ), - ], - ], - ), - ); - } -} - -class _DoseRow extends StatelessWidget { +class _DoseTimelineRow extends StatelessWidget { final Map dose; + final bool isFirst; final bool isLast; final bool isBusy; - final Future Function(String medId, String time, bool taken) onToggle; + final Future Function(String, String, bool) onToggle; - const _DoseRow({ + const _DoseTimelineRow({ required this.dose, + required this.isFirst, required this.isLast, required this.isBusy, required this.onToggle, @@ -499,171 +286,175 @@ class _DoseRow extends StatelessWidget { @override Widget build(BuildContext context) { - final time = dose['scheduledTime']?.toString() ?? ''; final medId = dose['id']?.toString() ?? ''; - final isTaken = _isTaken(dose); - final displayTime = _formatTime(time); - final statusColor = isTaken ? AppTheme.success : AppColors.warning; + final rawTime = dose['scheduledTime']?.toString() ?? ''; + final time = _formatTime(rawTime); + final name = dose['name']?.toString().trim().isNotEmpty == true + ? dose['name'].toString().trim() + : '未命名药品'; + final dosage = dose['dosage']?.toString().trim() ?? ''; + final status = dose['status']?.toString().toLowerCase() ?? 'scheduled'; + final taken = status == 'taken'; + final scheduled = status == 'scheduled'; + final color = _statusColor(status); - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 92), + child: Stack( children: [ - SizedBox( - width: 28, - child: Column( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( children: [ - Container( - width: 22, - height: 22, - decoration: BoxDecoration( - color: isTaken ? AppTheme.success : Colors.white, - shape: BoxShape.circle, - border: Border.all( - color: isTaken ? AppTheme.success : AppColors.border, - width: 2, - ), + SizedBox( + width: 54, + child: Text( + time, + style: AppTextStyles.listTitle.copyWith(fontSize: 18), ), - child: isTaken - ? const Icon(Icons.check, size: 14, color: Colors.white) - : null, ), - if (!isLast) - Expanded( - child: Container( - width: 2, - margin: const EdgeInsets.symmetric(vertical: 4), - color: isTaken - ? AppTheme.success.withValues(alpha: 0.34) - : AppColors.borderLight, - ), + SizedBox( + width: 28, + height: 92, + child: Stack( + alignment: Alignment.center, + children: [ + if (!isFirst) + Positioned( + top: 0, + bottom: 46, + child: Container(width: 2, color: AppColors.divider), + ), + if (!isLast) + Positioned( + top: 46, + bottom: 0, + child: Container(width: 2, color: AppColors.divider), + ), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: taken ? color : Colors.white, + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + ), + child: taken + ? const Icon( + Icons.check, + size: 13, + color: Colors.white, + ) + : null, + ), + ], ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.listTitle.copyWith(fontSize: 16), + ), + const SizedBox(height: 3), + Text( + [ + dosage, + _statusLabel(status), + ].where((text) => text.isNotEmpty).join(' · '), + style: AppTextStyles.listSubtitle.copyWith( + fontSize: 13, + color: color, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 36, + child: OutlinedButton( + onPressed: isBusy || scheduled + ? null + : () => onToggle(medId, rawTime, taken), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 13), + backgroundColor: taken + ? AppColors.successLight + : Colors.white, + foregroundColor: taken + ? AppColors.successText + : AppColors.medication, + disabledForegroundColor: AppColors.textHint, + side: BorderSide( + color: scheduled + ? AppColors.borderLight + : (taken + ? AppColors.successText + : AppColors.medication), + ), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.smBorder, + ), + ), + child: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + scheduled ? '未到时间' : (taken ? '撤销' : '打卡'), + style: AppTextStyles.miniButton, + ), + ), + ), ], ), ), - const SizedBox(width: 10), - Expanded( - child: Padding( - padding: EdgeInsets.only(bottom: isLast ? 0 : 12), - child: Container( - padding: const EdgeInsets.fromLTRB(12, 10, 10, 10), - decoration: BoxDecoration( - color: isTaken - ? _MedicationCheckInPageState._softGreen - : const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isTaken - ? AppTheme.success.withValues(alpha: 0.18) - : AppColors.borderLight, - ), - ), - child: Row( - children: [ - Icon(Icons.access_time, size: 18, color: statusColor), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayTime, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: isTaken ? AppTheme.textSub : AppTheme.text, - ), - ), - const SizedBox(height: 1), - Text( - isTaken ? '已完成打卡' : '等待确认服用', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: isTaken - ? AppTheme.success - : AppColors.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - SizedBox( - height: 36, - child: FilledButton.icon( - onPressed: isBusy || medId.isEmpty || time.isEmpty - ? null - : () => onToggle(medId, time, isTaken), - style: FilledButton.styleFrom( - backgroundColor: isTaken - ? Colors.white - : _MedicationCheckInPageState._visual.color, - foregroundColor: isTaken - ? AppTheme.success - : Colors.white, - disabledBackgroundColor: AppColors.cardInner, - disabledForegroundColor: AppColors.textHint, - padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 36), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide( - color: isTaken - ? AppTheme.success.withValues(alpha: 0.34) - : Colors.transparent, - ), - ), - ), - icon: isBusy - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textHint, - ), - ) - : Icon( - isTaken - ? Icons.undo_rounded - : Icons.check_rounded, - size: 17, - ), - label: Text( - isTaken ? '撤销' : '打卡', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - ), - ), - ), - ), - ], - ), + if (!isLast) + const Positioned( + left: 96, + right: 0, + bottom: 0, + child: Divider( + height: 1, + thickness: 0.7, + color: AppColors.divider, ), ), - ), ], ), ); } } -bool _isTaken(Map dose) { - return dose['status']?.toString().toLowerCase() == 'taken'; -} +bool _isTaken(Map dose) => + dose['status']?.toString().toLowerCase() == 'taken'; String _formatTime(String raw) { final value = raw.trim(); - if (value.length >= 5 && value[2] == ':') return value.substring(0, 5); - final parsed = DateTime.tryParse(value); - if (parsed == null) return value; - final hour = parsed.hour.toString().padLeft(2, '0'); - final minute = parsed.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; + return value.length >= 5 ? value.substring(0, 5) : value; } + +String _statusLabel(String status) => switch (status) { + 'taken' => '已完成', + 'overdue' => '已超时', + 'upcoming' => '待完成', + 'skipped' => '已跳过', + _ => '未到时间', +}; + +Color _statusColor(String status) => switch (status) { + 'taken' => AppColors.successText, + 'overdue' => AppColors.errorText, + 'upcoming' => AppColors.blueMeasure, + 'skipped' => AppColors.textHint, + _ => AppColors.textHint, +}; diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 2c3b774..e13409c 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -1,12 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../core/api_client.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; +import '../../widgets/app_error_state.dart'; import '../../widgets/app_toast.dart'; +import '../../widgets/common_widgets.dart'; +import '../care_plan_ui_logic.dart'; +import 'medication_ui_logic.dart'; class MedicationEditPage extends ConsumerStatefulWidget { final String? id; @@ -18,117 +24,163 @@ class MedicationEditPage extends ConsumerStatefulWidget { } class _MedicationEditPageState extends ConsumerState { - final _nameCtrl = TextEditingController(); - final _dosageCtrl = TextEditingController(); - final _notesCtrl = TextEditingController(); - int _timesPerDay = 2; + final _nameController = TextEditingController(); + final _dosageController = TextEditingController(); + final _notesController = TextEditingController(); List _times = [ const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0), ]; - DateTime _start = DateTime.now(); - DateTime? _end; + DateTime _startDate = DateTime.now(); + DateTime? _endDate; + bool _saving = false; bool _loading = false; + String? _loadError; + + bool get _editing => widget.id?.isNotEmpty == true; @override void initState() { super.initState(); - if (widget.id != null) _load(); + if (_editing) _load(); } @override void dispose() { - _nameCtrl.dispose(); - _dosageCtrl.dispose(); - _notesCtrl.dispose(); + _nameController.dispose(); + _dosageController.dispose(); + _notesController.dispose(); super.dispose(); } Future _load() async { - final srv = ref.read(medicationServiceProvider); - final list = await srv.getList(); - final m = list.cast?>().firstWhere( - (x) => x?['id']?.toString() == widget.id, - orElse: () => null, - ); - if (m == null || !mounted) return; - _nameCtrl.text = m['name']?.toString() ?? ''; - _dosageCtrl.text = m['dosage']?.toString() ?? ''; - _notesCtrl.text = m['notes']?.toString() ?? ''; - final tod = - (m['timeOfDay'] as List?)?.map((t) { - final parts = t.toString().split(':'); - return TimeOfDay( - hour: int.tryParse(parts[0]) ?? 8, - minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0, - ); - }).toList() ?? - [ - const TimeOfDay(hour: 8, minute: 0), - const TimeOfDay(hour: 18, minute: 0), - ]; - _timesPerDay = tod.length; - _times = tod; - if (m['startDate'] != null) { - _start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now(); + setState(() { + _loading = true; + _loadError = null; + }); + try { + final medications = await ref.read(medicationServiceProvider).getList(); + final medication = medications.cast?>().firstWhere( + (item) => item?['id']?.toString() == widget.id, + orElse: () => null, + ); + if (medication == null) throw const ApiException('该用药记录不存在'); + _nameController.text = medication['name']?.toString() ?? ''; + _dosageController.text = medication['dosage']?.toString() ?? ''; + _notesController.text = medication['notes']?.toString() ?? ''; + final loadedTimes = + (medication['timeOfDay'] as List?) + ?.map((value) => _parseTime(value)) + .whereType() + .toList() ?? + []; + if (loadedTimes.isNotEmpty) _times = loadedTimes; + _startDate = + DateTime.tryParse(medication['startDate']?.toString() ?? '') ?? + DateTime.now(); + _endDate = DateTime.tryParse(medication['endDate']?.toString() ?? ''); + } catch (error) { + _loadError = error is ApiException ? error.message : '用药信息加载失败'; + } finally { + if (mounted) setState(() => _loading = false); } - if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString()); - setState(() {}); } Future _save() async { - final name = _nameCtrl.text.trim(); - if (name.isEmpty) { - AppToast.show(context, '请输入药品名称', type: AppToastType.warning); + final timeValues = _times.map(_timeValue).toList(); + final validation = validateMedicationForm( + name: _nameController.text, + dosage: _dosageController.text, + times: timeValues, + startDate: _startDate, + endDate: _endDate, + ); + if (validation != null) { + AppToast.show(context, validation, type: AppToastType.warning); return; } - setState(() => _loading = true); - final data = { - 'name': name, - 'dosage': _dosageCtrl.text.trim(), - 'frequency': 'Daily', - 'notes': _notesCtrl.text.trim(), - 'timeOfDay': _times - .map( - (t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}', - ) - .toList(), - 'startDate': - '${_start.year}-${_start.month.toString().padLeft(2, '0')}-${_start.day.toString().padLeft(2, '0')}', - if (_end != null) - 'endDate': - '${_end!.year}-${_end!.month.toString().padLeft(2, '0')}-${_end!.day.toString().padLeft(2, '0')}', + setState(() => _saving = true); + final payload = { + 'name': _nameController.text.trim(), + 'dosage': _dosageController.text.trim(), + 'frequency': _frequencyForTimes(_times.length), + 'notes': _notesController.text.trim(), + 'timeOfDay': timeValues, + 'startDate': _dateValue(_startDate), + 'endDate': _endDate == null ? null : _dateValue(_endDate!), 'source': 'Manual', }; - final srv = ref.read(medicationServiceProvider); try { - if (widget.id != null) { - await srv.update(widget.id!, data); + final service = ref.read(medicationServiceProvider); + if (_editing) { + await service.update(widget.id!, payload); } else { - await srv.create(data); + await service.create(payload); } ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); - if (mounted) popRoute(ref); - } catch (_) { if (mounted) { - AppToast.show(context, '保存失败', type: AppToastType.error); + AppToast.show( + context, + _editing ? '用药已更新' : '用药已添加', + type: AppToastType.success, + ); + popRoute(ref); } + } catch (error) { + if (mounted) { + AppToast.show( + context, + error is ApiException ? error.message : '保存失败,请稍后重试', + type: AppToastType.error, + ); + } + } finally { + if (mounted) setState(() => _saving = false); } - if (mounted) setState(() => _loading = false); } - void _updateTimes(int n) { - setState(() { - _timesPerDay = n; - while (_times.length < n) { - _times.add(const TimeOfDay(hour: 12, minute: 0)); + Future _deleteMedication() async { + if (!_editing || _saving) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除用药'), + content: Text('确定删除“${_nameController.text.trim()}”吗?相关服药记录也将一并删除。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _saving = true); + try { + await ref.read(medicationServiceProvider).deleteMedication(widget.id!); + ref.invalidate(medicationListProvider); + ref.invalidate(medicationReminderProvider); + if (mounted) { + AppToast.show(context, '用药已删除', type: AppToastType.success); + popRoute(ref); } - while (_times.length > n) { - _times.removeLast(); + } catch (error) { + if (mounted) { + AppToast.show( + context, + error is ApiException ? error.message : '删除失败,请稍后重试', + type: AppToastType.error, + ); } - }); + } finally { + if (mounted) setState(() => _saving = false); + } } @override @@ -139,282 +191,371 @@ class _MedicationEditPageState extends ConsumerState { icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), - title: Text(widget.id != null ? '编辑用药' : '添加用药'), + title: Text(_editing ? '编辑用药' : '添加用药'), ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - Row( - children: [ - Expanded( - flex: 3, - child: _field('药品名称', _nameCtrl, hint: '如:阿司匹林'), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: _field('剂量', _dosageCtrl, hint: '如:100mg'), - ), - ], - ), - const SizedBox(height: 16), - _label('每日服药次数'), - const SizedBox(height: 8), - GestureDetector( - onTap: () async { - final n = await showAppCountPicker( - context, - initialValue: _timesPerDay, - min: 1, - max: 4, - label: '次', - ); - if (n != null) _updateTimes(n); - }, - child: _PickerBox( - child: Text('$_timesPerDay 次/天', style: AppTextStyles.formValue), - ), - ), - const SizedBox(height: 16), - _label('服药时间'), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: List.generate( - _timesPerDay, - (i) => GestureDetector( - onTap: () async { - final t = await showAppTimePicker( - context, - initialTime: _times[i], - ); - if (t != null) setState(() => _times[i] = t); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.border, width: 1), - ), - child: Text( - '${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}', - style: AppTextStyles.formValue, - ), + bottomNavigationBar: _loading || _loadError != null + ? null + : SafeArea( + top: false, + child: Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: AppGradientOutlineButton( + label: _editing ? '保存修改' : '添加用药', + onPressed: _saving ? null : _save, + loading: _saving, ), ), ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _dateField( - '开始日期', - _start, - (d) => setState(() => _start = d), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _loadError != null + ? AppErrorState(title: _loadError!, onRetry: _load) + : ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + children: [ + _FormSection( + title: '药品信息', + children: [ + _TextFieldRow( + label: '药品名称', + controller: _nameController, + hint: '请输入药品名称', + ), + const _SectionDivider(), + _TextFieldRow( + label: '剂量', + controller: _dosageController, + hint: '例如 100 mg', + ), + ], ), - ), - const SizedBox(width: 12), - Expanded( - child: _dateFieldOpt( - '结束日期(可选)', - _end, - (d) => setState(() => _end = d), - ), - ), - ], - ), - const SizedBox(height: 16), - _field('备注', _notesCtrl, hint: '如:饭后服用、睡前'), - const SizedBox(height: 32), - _GradientOutlineButton( - onPressed: _loading ? null : _save, - label: _loading ? '保存中...' : '保存', - ), - const SizedBox(height: 20), - ], - ), - ); - } - - Widget _label(String text) => Text(text, style: AppTextStyles.formLabel); - - 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: AppTheme.surface, - border: _inputBorder(AppColors.border), - enabledBorder: _inputBorder(AppColors.border), - focusedBorder: _inputBorder(AppColors.primary), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - ), - style: AppTextStyles.formValue, - ), - ], - ); - - 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 showAppDatePicker( - context, - initialDate: val, - firstDate: DateTime(2024), - lastDate: DateTime(2030), - ); - if (d != null) cb(d); - }, - child: _PickerBox( - child: Text(_displayDate(val), style: AppTextStyles.formValue), - ), - ), - ], - ); - - 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 showAppDatePicker( - context, - initialDate: val ?? DateTime.now(), - firstDate: DateTime(2024), - lastDate: DateTime(2030), - ); - if (d != null) cb(d); - }, - child: _PickerBox( - child: Row( - children: [ - Expanded( - child: Text( - val != null ? _displayDate(val) : '不设置', - style: AppTextStyles.formValue.copyWith( - color: val != null ? null : AppTheme.textHint, - ), - ), - ), - if (val != null) - GestureDetector( - onTap: () => cb(null), - child: const Icon( - Icons.close, - size: 19, - color: AppColors.textHint, - ), - ), - ], - ), - ), - ), - ], - ); - - OutlineInputBorder _inputBorder(Color color) => OutlineInputBorder( - borderRadius: AppRadius.mdBorder, - borderSide: BorderSide(color: color), - ); -} - -class _PickerBox extends StatelessWidget { - final Widget child; - - const _PickerBox({required this.child}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.border), - ), - child: child, - ); - } -} - -class _GradientOutlineButton extends StatelessWidget { - final VoidCallback? onPressed; - final String label; - - const _GradientOutlineButton({required this.onPressed, required this.label}); - - @override - Widget build(BuildContext context) { - final enabled = onPressed != null; - return Container( - width: double.infinity, - decoration: BoxDecoration( - gradient: enabled ? AppColors.actionOutlineGradient : null, - color: enabled ? null : AppColors.border, - borderRadius: AppRadius.lgBorder, - ), - padding: const EdgeInsets.all(1.4), - child: Material( - color: Colors.white, - borderRadius: AppRadius.lgBorder, - child: InkWell( - onTap: onPressed, - borderRadius: AppRadius.lgBorder, - child: SizedBox( - height: 46, - child: Center( - child: enabled - ? ShaderMask( - shaderCallback: (bounds) => - AppColors.actionOutlineGradient.createShader(bounds), - child: Text( - label, - style: AppTextStyles.button.copyWith( - color: Colors.white, - ), - ), - ) - : Text( - label, - style: AppTextStyles.button.copyWith( - color: AppColors.textHint, + const SizedBox(height: 16), + _FormSection( + title: '服用安排', + children: [ + _PickerRow( + label: '每日次数', + value: '${_times.length}次', + onTap: _pickCount, + ), + const _SectionDivider(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 11, 12, 11), + child: Row( + children: [ + const Text('服药时间', style: AppTextStyles.formLabel), + const SizedBox(width: 12), + Expanded( + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: List.generate( + _times.length, + (index) => _TimeButton( + value: _timeValue(_times[index]), + onTap: () => _pickTime(index), + ), + ), + ), + ), + ], ), ), + ], + ), + const SizedBox(height: 16), + _FormSection( + title: '用药周期', + children: [ + _PickerRow( + label: '开始日期', + value: _displayDate(_startDate), + onTap: _pickStartDate, + ), + const _SectionDivider(), + _PickerRow( + label: '结束日期', + value: _endDate == null ? '长期' : _displayDate(_endDate!), + onTap: _pickEndDate, + onClear: _endDate == null + ? null + : () => setState(() => _endDate = null), + ), + ], + ), + const SizedBox(height: 16), + _FormSection( + title: '备注', + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _notesController, + minLines: 3, + maxLines: 5, + maxLength: 200, + decoration: const InputDecoration( + hintText: '可填写饭前、饭后或其他用药要求(选填)', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + if (_editing) ...[ + const SizedBox(height: 16), + Material( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _saving ? null : _deleteMedication, + child: const SizedBox( + height: 54, + child: Center( + child: Text( + '删除用药', + style: TextStyle( + color: AppColors.errorText, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ), + ], + ], + ), + ); + } + + Future _pickCount() async { + final count = await showAppCountPicker( + context, + initialValue: _times.length, + min: 1, + max: 4, + label: '次', + ); + if (count == null || !mounted) return; + setState(() { + while (_times.length < count) { + final hour = (8 + _times.length * 5).clamp(0, 23); + _times.add(TimeOfDay(hour: hour, minute: 0)); + } + while (_times.length > count) { + _times.removeLast(); + } + }); + } + + Future _pickTime(int index) async { + final value = await showAppTimePicker(context, initialTime: _times[index]); + if (value != null && mounted) setState(() => _times[index] = value); + } + + Future _pickStartDate() async { + final value = await showAppDatePicker( + context, + initialDate: _startDate, + firstDate: DateTime(2020), + lastDate: DateTime(2035), + ); + if (value != null && mounted) setState(() => _startDate = value); + } + + Future _pickEndDate() async { + final value = await showAppDatePicker( + context, + initialDate: _endDate ?? _startDate, + firstDate: DateTime(2020), + lastDate: DateTime(2035), + ); + if (value != null && mounted) setState(() => _endDate = value); + } +} + +class _FormSection extends StatelessWidget { + final String title; + final List children; + + const _FormSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Text( + title, + style: AppTextStyles.sectionTitle.copyWith(fontSize: 16), + ), + ), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column(children: children), + ), + ], + ); + } +} + +class _TextFieldRow extends StatelessWidget { + final String label; + final TextEditingController controller; + final String hint; + + const _TextFieldRow({ + required this.label, + required this.controller, + required this.hint, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + SizedBox( + width: 82, + child: Text(label, style: AppTextStyles.formLabel), + ), + Expanded( + child: TextField( + controller: controller, + textAlign: TextAlign.right, + decoration: InputDecoration( + hintText: hint, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + style: AppTextStyles.formValue, ), ), + ], + ), + ); + } +} + +class _PickerRow extends StatelessWidget { + final String label; + final String value; + final VoidCallback onTap; + final VoidCallback? onClear; + + const _PickerRow({ + required this.label, + required this.value, + required this.onTap, + this.onClear, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 15, 10, 15), + child: Row( + children: [ + Expanded(child: Text(label, style: AppTextStyles.formLabel)), + Text(value, style: AppTextStyles.formValue), + if (onClear != null) + IconButton( + tooltip: '清除结束日期', + visualDensity: VisualDensity.compact, + onPressed: onClear, + icon: const Icon(Icons.close, size: 18), + ) + else + const Padding( + padding: EdgeInsets.only(left: 6), + child: Icon( + LucideIcons.chevronRight, + color: AppColors.textHint, + size: 20, + ), + ), + ], ), ), ); } } -String _displayDate(DateTime date) { - return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; +class _TimeButton extends StatelessWidget { + final String value; + final VoidCallback onTap; + + const _TimeButton({required this.value, required this.onTap}); + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onTap, + icon: const Icon(LucideIcons.clock, size: 18), + label: Text(value), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: BorderSide(color: AppColors.primary.withValues(alpha: 0.38)), + shape: RoundedRectangleBorder(borderRadius: AppRadius.smBorder), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + ), + ); + } } + +class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(left: 16), + child: Divider(height: 1, thickness: 0.7, color: AppColors.divider), + ); + } +} + +TimeOfDay? _parseTime(Object value) { + final parts = value.toString().split(':'); + if (parts.length < 2) return null; + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || minute == null) return null; + return TimeOfDay(hour: hour, minute: minute); +} + +String _timeValue(TimeOfDay value) => + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; + +String _dateValue(DateTime value) => + '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; + +String _displayDate(DateTime value) => formatMedicationFormDate(value); + +String _frequencyForTimes(int count) => switch (count) { + 2 => 'TwiceDaily', + 3 => 'ThreeTimesDaily', + _ => 'Daily', +}; diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index b0d2fc7..750ba5b 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../core/api_client.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; @@ -8,20 +11,21 @@ import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/app_empty_state.dart'; 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'; +import '../../widgets/common_widgets.dart'; +import '../care_plan_ui_logic.dart'; +import 'medication_ui_logic.dart'; class MedicationListPage extends ConsumerStatefulWidget { const MedicationListPage({super.key}); + @override ConsumerState createState() => _MedicationListPageState(); } class _MedicationListPageState extends ConsumerState { - static const _medVisual = AppModuleVisuals.medication; Future>>? _future; + final Set _deleting = {}; @override void initState() { @@ -29,22 +33,53 @@ class _MedicationListPageState extends ConsumerState { _load(); } - void _load() { - setState(() { - _future = ref.read(medicationServiceProvider).getMedications(''); - }); + void _load() => setState(() { + _future = ref.read(medicationServiceProvider).getMedications(''); + }); + + Future _refresh() async { + _load(); + await _future; } - Future _delete(String id) async { + Future _confirmDelete(String id, String name) async { + if (id.isEmpty || _deleting.contains(id)) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除用药'), + content: Text('确定删除“$name”吗?相关服药记录也将一并删除。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _deleting.add(id)); try { await ref.read(medicationServiceProvider).deleteMedication(id); ref.invalidate(medicationListProvider); + ref.invalidate(medicationReminderProvider); _load(); - if (mounted) AppToast.show(context, '已删除', type: AppToastType.success); - } catch (_) { + if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success); + } catch (error) { if (mounted) { - AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error); + AppToast.show( + context, + error is ApiException ? error.message : '删除失败,请稍后重试', + type: AppToastType.error, + ); } + } finally { + if (mounted) setState(() => _deleting.remove(id)); } } @@ -58,83 +93,67 @@ class _MedicationListPageState extends ConsumerState { ), title: const Text('用药管理'), ), - floatingActionButton: _GradientFab( - gradient: _medVisual.gradient, - onTap: () => pushRoute(ref, 'medicationEdit'), + floatingActionButton: AppCreateFab( + tooltip: '添加用药', + onPressed: () => pushRoute(ref, 'medicationEdit'), ), body: AppFutureView>>( future: _future, onRetry: _load, errorTitle: '用药信息加载失败', - onData: (ctx, list) { - if (list.isEmpty) { + onData: (_, medications) { + if (medications.isEmpty) { return AppEmptyState( - icon: _medVisual.icon, + icon: AppModuleVisuals.medication.icon, title: '暂无用药', - subtitle: '点击右下角➕添加药品', + subtitle: '点击右下角添加药品', + iconColor: AppColors.medication, ); } - final activeCount = list.where((m) => m['isActive'] == true).length; - final reminderCount = list.where((m) { - final times = m['timeOfDay'] as List?; - return times != null && times.isNotEmpty; - }).length; - return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), - children: [ - EnterpriseHeader( - title: '今日用药概览', - subtitle: '集中管理服药计划、剂量和每日打卡状态', - icon: _medVisual.icon, - color: _medVisual.color, - accent: AppColors.primary, - showIcon: false, - stats: [ - EnterpriseStat( - label: '药品总数', - value: '${list.length} 个', - icon: Icons.inventory_2_outlined, - ), - EnterpriseStat( - label: '服用中', - value: '$activeCount 个', - icon: Icons.check_circle_outline, - ), - EnterpriseStat( - label: '有提醒', - value: '$reminderCount 个', - icon: Icons.alarm_outlined, - ), - ], - ), - const SizedBox(height: 10), - _MedicationListGroup( - children: List.generate(list.length, (i) { - final m = list[i]; - 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 SwipeDeleteTile( - key: Key(id), - onDelete: () => _delete(id), - onTap: () => - pushRoute(ref, 'medCheckIn', params: {'id': id}), - margin: EdgeInsets.zero, - child: _MedicationRow( - name: m['name']?.toString() ?? '', - subtitle: '${m['dosage'] ?? ''} $times', - isActive: isActive, - showDivider: i < list.length - 1, - visual: _medVisual, + final groups = >>{ + for (final phase in CarePlanPhase.values) phase: [], + }; + for (final medication in medications) { + final phase = resolveCarePlanPhase( + enabled: medication['isActive'] == true, + startDate: medication['startDate']?.toString(), + endDate: medication['endDate']?.toString(), + ); + groups[phase]!.add(medication); + } + final active = groups[CarePlanPhase.active]!; + final doseCount = active.fold( + 0, + (sum, item) => sum + ((item['timeOfDay'] as List?)?.length ?? 0), + ); + return RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: AppSpacing.pageWithFab, + children: [ + _MedicationOverview( + activeCount: active.length, + doseCount: doseCount, + nextReminder: _nextReminder(active), + ), + const SizedBox(height: 18), + for (final phase in CarePlanPhase.values) + if (groups[phase]!.isNotEmpty) ...[ + _MedicationPhaseGroup( + phase: phase, + medications: groups[phase]!, + deleting: _deleting, + onOpen: (id) => + pushRoute(ref, 'medCheckIn', params: {'id': id}), + onEdit: (id) => + pushRoute(ref, 'medicationEdit', params: {'id': id}), + onDelete: _confirmDelete, ), - ); - }), - ), - ], + const SizedBox(height: 18), + ], + ], + ), ); }, ), @@ -142,104 +161,223 @@ class _MedicationListPageState extends ConsumerState { } } -class _GradientFab extends StatelessWidget { - final Gradient gradient; - final VoidCallback onTap; +class _MedicationOverview extends StatelessWidget { + final int activeCount; + final int doseCount; + final String? nextReminder; - const _GradientFab({required this.gradient, required this.onTap}); - - @override - Widget build(BuildContext context) { - return Container( - width: 56, - height: 56, - decoration: BoxDecoration( - gradient: gradient, - borderRadius: AppRadius.lgBorder, - boxShadow: [ - BoxShadow( - color: AppColors.medication.withValues(alpha: 0.22), - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], - ), - child: Material( - color: Colors.transparent, - borderRadius: AppRadius.lgBorder, - child: InkWell( - onTap: onTap, - borderRadius: AppRadius.lgBorder, - child: const Icon(Icons.add, size: 28, color: Colors.white), - ), - ), - ); - } -} - -class _MedicationListGroup extends StatelessWidget { - final List children; - - const _MedicationListGroup({required this.children}); - - @override - Widget build(BuildContext context) { - return Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.xlBorder, - ), - child: Column(children: children), - ); - } -} - -class _MedicationRow extends StatelessWidget { - final String name; - final String subtitle; - final bool isActive; - final bool showDivider; - final AppModuleVisual visual; - - const _MedicationRow({ - required this.name, - required this.subtitle, - required this.isActive, - required this.showDivider, - required this.visual, + const _MedicationOverview({ + required this.activeCount, + required this.doseCount, + required this.nextReminder, }); @override Widget build(BuildContext context) { return Container( - color: Colors.white, + padding: AppSpacing.panel, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text('用药概览', style: AppTextStyles.sectionTitle), + ), + Text( + nextReminder == null ? '今日已无待服' : '下次 $nextReminder', + style: AppTextStyles.listSubtitle.copyWith( + color: nextReminder == null + ? AppColors.successText + : AppColors.textSecondary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _OverviewValue( + value: '$activeCount种', + label: '正在服用', + color: AppColors.textPrimary, + ), + ), + Container(width: 1, height: 46, color: AppColors.divider), + Expanded( + child: _OverviewValue( + value: '$doseCount次', + label: '今日安排', + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _OverviewValue extends StatelessWidget { + final String value; + final String label; + final Color color; + + const _OverviewValue({ + required this.value, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text(value, style: AppTextStyles.summaryTitle.copyWith(color: color)), + const SizedBox(height: 2), + Text(label, style: AppTextStyles.listSubtitle.copyWith(fontSize: 13)), + ], + ); + } +} + +class _MedicationPhaseGroup extends StatelessWidget { + final CarePlanPhase phase; + final List> medications; + final Set deleting; + final ValueChanged onOpen; + final ValueChanged onEdit; + final Future Function(String, String) onDelete; + + const _MedicationPhaseGroup({ + required this.phase, + required this.medications, + required this.deleting, + required this.onOpen, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final color = _phaseColor(phase); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2, bottom: 9), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + '${_phaseLabel(phase)}(${medications.length})', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: color, + ), + ), + ], + ), + ), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + children: List.generate(medications.length, (index) { + final medication = medications[index]; + final id = medication['id']?.toString() ?? ''; + final name = + medication['name']?.toString().trim().isNotEmpty == true + ? medication['name'].toString().trim() + : '未命名药品'; + final isDeleting = deleting.contains(id); + return SwipeDeleteTile( + key: ValueKey(id), + margin: EdgeInsets.zero, + borderRadius: BorderRadius.zero, + enabled: !isDeleting, + onDelete: () => onDelete(id, name), + child: _MedicationRow( + medication: medication, + phase: phase, + deleting: isDeleting, + showDivider: index < medications.length - 1, + onOpen: () => onOpen(id), + onEdit: () => onEdit(id), + ), + ); + }), + ), + ), + ], + ); + } +} + +class _MedicationRow extends StatelessWidget { + final Map medication; + final CarePlanPhase phase; + final bool deleting; + final bool showDivider; + final VoidCallback onOpen; + final VoidCallback onEdit; + + const _MedicationRow({ + required this.medication, + required this.phase, + required this.deleting, + required this.showDivider, + required this.onOpen, + required this.onEdit, + }); + + @override + Widget build(BuildContext context) { + final name = medication['name']?.toString() ?? '未命名药品'; + final dosage = medication['dosage']?.toString().trim() ?? ''; + final times = + (medication['timeOfDay'] as List?) + ?.map((time) => _formatTime(time.toString())) + .join('、') ?? + ''; + return InkWell( + onTap: deleting ? null : onOpen, + child: Stack( children: [ Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.sLg, - vertical: 13, - ), + padding: const EdgeInsets.fromLTRB(16, 12, 6, 12), child: Row( children: [ Container( - width: 44, - height: 44, + width: 40, + height: 40, decoration: BoxDecoration( - gradient: isActive - ? visual.gradient - : AppColors.surfaceGradient, - borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.borderLight), + color: _phaseColor(phase).withValues(alpha: 0.10), + borderRadius: AppRadius.smBorder, ), child: Icon( - visual.icon, - size: 25, - color: isActive ? Colors.white : AppTheme.textSub, + AppModuleVisuals.medication.icon, + color: _phaseColor(phase), + size: 22, ), ), - const SizedBox(width: AppTheme.sMd), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -252,45 +390,66 @@ class _MedicationRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTextStyles.listTitle.copyWith( - color: isActive - ? AppTheme.text - : AppTheme.textSub, + fontSize: 17, ), ), ), - if (!isActive) ...[ - const SizedBox(width: 6), - AppStatusBadge(label: '已停', color: visual.color), + if (dosage.isNotEmpty) ...[ + const SizedBox(width: 8), + Text(dosage, style: AppTextStyles.tag), ], ], ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( - subtitle, + times.isEmpty ? '未设置服药时间' : times, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTextStyles.listSubtitle.copyWith( - color: AppTheme.textSub, + fontSize: 13, + ), + ), + const SizedBox(height: 2), + Text( + _periodText(medication), + style: AppTextStyles.listSubtitle.copyWith( + fontSize: 12, ), ), ], ), ), - const Icon( - Icons.chevron_right, - size: 21, - color: AppColors.textHint, - ), + if (deleting) + const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + tooltip: '编辑用药', + onPressed: onEdit, + icon: const Icon( + LucideIcons.pencil, + size: 21, + color: AppColors.textSecondary, + ), + ), ], ), ), if (showDivider) - const Padding( - padding: EdgeInsets.only(left: 74), + const Positioned( + left: 68, + right: 0, + bottom: 0, child: Divider( height: 1, thickness: 0.7, - color: Color(0xFFE8ECF2), + color: AppColors.divider, ), ), ], @@ -298,3 +457,40 @@ class _MedicationRow extends StatelessWidget { ); } } + +String? _nextReminder(List> medications) { + final now = TimeOfDay.now(); + final nowMinutes = now.hour * 60 + now.minute; + final times = + medications + .expand((item) => (item['timeOfDay'] as List?) ?? const []) + .map((value) => _formatTime(value.toString())) + .where((value) => value.length == 5) + .toList() + ..sort(); + for (final time in times) { + final parts = time.split(':'); + final minutes = + (int.tryParse(parts[0]) ?? 0) * 60 + (int.tryParse(parts[1]) ?? 0); + if (minutes >= nowMinutes) return time; + } + return null; +} + +String _periodText(Map medication) { + return formatMedicationPeriod(medication['startDate'], medication['endDate']); +} + +String _formatTime(String raw) => raw.length >= 5 ? raw.substring(0, 5) : raw; + +String _phaseLabel(CarePlanPhase phase) => switch (phase) { + CarePlanPhase.active => '正在服用', + CarePlanPhase.upcoming => '即将开始', + CarePlanPhase.ended => '已结束', +}; + +Color _phaseColor(CarePlanPhase phase) => switch (phase) { + CarePlanPhase.active => AppColors.successText, + CarePlanPhase.upcoming => AppColors.blueMeasure, + CarePlanPhase.ended => AppColors.textHint, +}; diff --git a/health_app/lib/pages/medication/medication_ui_logic.dart b/health_app/lib/pages/medication/medication_ui_logic.dart new file mode 100644 index 0000000..6803b32 --- /dev/null +++ b/health_app/lib/pages/medication/medication_ui_logic.dart @@ -0,0 +1,33 @@ +String formatMedicationPeriod( + Object? startValue, + Object? endValue, { + DateTime? now, +}) { + final start = DateTime.tryParse(startValue?.toString() ?? ''); + final end = DateTime.tryParse(endValue?.toString() ?? ''); + if (start == null && end == null) return '长期'; + final reference = now ?? DateTime.now(); + if (start == null) return '-- - ${_compactMedicationDate(end!, reference)}'; + final startText = _compactMedicationDate(start, reference, other: end); + if (end == null) return '$startText - 长期'; + final endText = _compactMedicationDate(end, reference, other: start); + return '$startText - $endText'; +} + +String _compactMedicationDate( + DateTime date, + DateTime reference, { + DateTime? other, +}) { + final showYear = + date.year != reference.year || (other != null && other.year != date.year); + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + return showYear ? '${date.year}.$month.$day' : '$month.$day'; +} + +String formatMedicationFormDate(DateTime value) { + final month = value.month.toString().padLeft(2, '0'); + final day = value.day.toString().padLeft(2, '0'); + return '${value.year}-$month-$day'; +} diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index 4f4ae9d..3505b23 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -5,10 +5,27 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; -import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../services/in_app_notification_service.dart'; +import '../../widgets/app_empty_state.dart'; +import '../../widgets/common_widgets.dart'; + +Future acknowledgeBeforeNotificationOpen({ + required Future Function() acknowledge, + VoidCallback? onAcknowledged, + required VoidCallback open, +}) async { + var acknowledged = true; + try { + await acknowledge(); + onAcknowledged?.call(); + } catch (_) { + acknowledged = false; + } + open(); + return acknowledged; +} class NotificationCenterPage extends ConsumerStatefulWidget { const NotificationCenterPage({super.key}); @@ -61,24 +78,40 @@ class _NotificationCenterPageState Future _open(InAppNotification item) async { if (!item.isRead) { - await _service.acknowledge(item.id); - if (!mounted) return; - setState(() { - final current = _history; - if (current == null) return; - _history = InAppNotificationHistory( - unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount), - items: current.items - .map( - (value) => - value.id == item.id ? value.copyWith(isRead: true) : value, - ) - .toList(), - ); - }); - ref.invalidate(notificationUnreadCountProvider); + await acknowledgeBeforeNotificationOpen( + acknowledge: () => _service.acknowledge(item.id), + onAcknowledged: () { + if (!mounted) return; + setState(() { + final current = _history; + if (current == null) return; + _history = InAppNotificationHistory( + unreadCount: (current.unreadCount - 1).clamp( + 0, + current.unreadCount, + ), + items: current.items + .map( + (value) => value.id == item.id + ? value.copyWith(isRead: true) + : value, + ) + .toList(), + ); + }); + ref.invalidate(notificationUnreadCountProvider); + }, + open: () { + if (mounted) _openTarget(item); + }, + ); + return; } + _openTarget(item); + } + + void _openTarget(InAppNotification item) { if (!mounted || item.actionType == null) return; switch (item.actionType) { case 'medication': @@ -149,11 +182,11 @@ class _NotificationCenterPageState final unread = _history?.unreadCount ?? 0; return Scaffold( - backgroundColor: const Color(0xFFF6F8FC), + backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: Colors.white, elevation: 0, - scrolledUnderElevation: 0.5, + scrolledUnderElevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), @@ -194,10 +227,15 @@ class _NotificationCenterPageState const SizedBox(width: 4), ], ), - body: RefreshIndicator( - onRefresh: _load, - color: AppColors.primary, - child: _buildBody(items, unread), + body: ColoredBox( + color: AppColors.background, + child: SizedBox.expand( + child: RefreshIndicator( + onRefresh: _load, + color: AppColors.primary, + child: _buildBody(items, unread), + ), + ), ), ); } @@ -319,13 +357,6 @@ class _NotificationGroup extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: [ - BoxShadow( - color: const Color(0xFF101828).withValues(alpha: 0.035), - blurRadius: 14, - offset: const Offset(0, 6), - ), - ], ), child: Column(children: children), ); @@ -339,27 +370,38 @@ class _UnreadHint extends StatelessWidget { @override Widget build(BuildContext context) => Container( - margin: const EdgeInsets.fromLTRB(0, 2, 0, 14), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + margin: const EdgeInsets.fromLTRB(0, 2, 0, 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( - color: AppColors.notificationLight, + color: Colors.white, borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.notificationBorder), ), child: Row( children: [ const Icon( LucideIcons.bellRing, - size: 18, - color: AppColors.notification, + size: 17, + color: AppColors.textSecondary, ), - const SizedBox(width: 9), - Text( - '你有 $unreadCount 条未读消息', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.notification, + const SizedBox(width: 8), + RichText( + text: TextSpan( + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + children: [ + const TextSpan(text: '还有 '), + TextSpan( + text: '$unreadCount', + style: const TextStyle( + fontWeight: FontWeight.w800, + color: AppColors.errorText, + ), + ), + const TextSpan(text: ' 条未读消息'), + ], ), ), ], @@ -465,27 +507,24 @@ class _NotificationRow extends StatelessWidget { child: InkWell( onTap: onTap, child: Container( - constraints: const BoxConstraints(minHeight: 94), + constraints: const BoxConstraints(minHeight: 82), color: Colors.white, child: Column( children: [ Padding( - padding: const EdgeInsets.fromLTRB(14, 13, 12, 12), + padding: const EdgeInsets.fromLTRB(14, 10, 10, 10), child: Row( children: [ Container( - width: 48, - height: 48, + width: 40, + height: 40, decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: AppRadius.mdBorder, - border: Border.all( - color: visual.borderColor.withValues(alpha: 0.85), - ), + color: visual.lightColor, + borderRadius: AppRadius.smBorder, ), - child: Icon(visual.icon, size: 24, color: Colors.white), + child: Icon(visual.icon, size: 21, color: visual.color), ), - const SizedBox(width: 13), + const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -493,10 +532,26 @@ class _NotificationRow extends StatelessWidget { children: [ Row( children: [ + Expanded( + child: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: item.isRead + ? FontWeight.w700 + : FontWeight.w800, + color: AppColors.textPrimary, + height: 1.2, + ), + ), + ), + const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( - horizontal: 7, - vertical: 3, + horizontal: 6, + vertical: 2, ), decoration: BoxDecoration( color: visual.lightColor, @@ -505,45 +560,22 @@ class _NotificationRow extends StatelessWidget { child: Text( visual.label, style: TextStyle( - fontSize: 12, + fontSize: 11, fontWeight: FontWeight.w800, color: visual.color, ), ), ), - const SizedBox(width: 8), - Text( - _formatTime(item.createdAt), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: AppColors.textHint, - ), - ), ], ), - const SizedBox(height: 5), - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: item.isRead - ? FontWeight.w700 - : FontWeight.w800, - color: AppColors.textPrimary, - height: 1.2, - ), - ), const SizedBox(height: 4), Text( item.message, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 14, - height: 1.25, + fontSize: 13, + height: 1.2, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), @@ -551,20 +583,36 @@ class _NotificationRow extends StatelessWidget { ], ), ), - const SizedBox(width: 10), + const SizedBox(width: 8), SizedBox( - width: 12, - child: Center( - child: item.isRead - ? const SizedBox.shrink() - : Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.error, - shape: BoxShape.circle, - ), + width: 68, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (item.isRead) + const SizedBox(height: 8) + else + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.error, + shape: BoxShape.circle, ), + ), + const SizedBox(height: 7), + Text( + _formatTime(item.createdAt), + maxLines: 1, + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textHint, + ), + ), + ], ), ), ], @@ -572,7 +620,7 @@ class _NotificationRow extends StatelessWidget { ), if (showDivider) const Padding( - padding: EdgeInsets.only(left: 75), + padding: EdgeInsets.only(left: 65), child: Divider( height: 1, thickness: 0.7, @@ -597,8 +645,8 @@ class _NotificationRow extends StatelessWidget { local.day == now.day) { return time; } - if (local.year == now.year) return '${local.month}/${local.day} $time'; - return '${local.year}/${local.month}/${local.day} $time'; + if (local.year == now.year) return '${local.month}-${local.day} $time'; + return '${local.year}-${local.month}-${local.day} $time'; } } @@ -606,26 +654,15 @@ class _NotificationVisual { final IconData icon; final Color color; final Color lightColor; - final Color borderColor; - final LinearGradient gradient; final String label; - const _NotificationVisual( - this.icon, - this.color, - this.lightColor, - this.borderColor, - this.gradient, - this.label, - ); + const _NotificationVisual(this.icon, this.color, this.lightColor, this.label); factory _NotificationVisual.fromModule(AppModuleVisual visual) { return _NotificationVisual( visual.icon, visual.color, visual.lightColor, - visual.borderColor, - visual.gradient, visual.label, ); } @@ -639,14 +676,8 @@ class _NotificationVisual { return item.severity == 'critical' ? const _NotificationVisual( LucideIcons.triangleAlert, - Color(0xFFEF4444), - Color(0xFFFEE2E2), - Color(0xFFFECACA), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)], - ), + AppColors.errorText, + AppColors.errorLight, '紧急', ) : _NotificationVisual.fromModule(AppModuleVisuals.health); @@ -654,14 +685,8 @@ class _NotificationVisual { return item.severity == 'error' ? const _NotificationVisual( LucideIcons.fileWarning, - Color(0xFFEF4444), - Color(0xFFFEE2E2), - Color(0xFFFECACA), - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)], - ), + AppColors.errorText, + AppColors.errorLight, '报告', ) : _NotificationVisual.fromModule(AppModuleVisuals.report); @@ -693,62 +718,23 @@ class _MessageState extends StatelessWidget { @override Widget build(BuildContext context) => ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(28, 120, 28, 32), + padding: const EdgeInsets.fromLTRB(28, 100, 28, 32), children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderLight), - boxShadow: [AppTheme.shadowLight], - ), - child: Column( - children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: AppColors.primaryLight, - borderRadius: BorderRadius.circular(18), - ), - child: Icon(icon, size: 32, color: AppColors.primary), - ), - const SizedBox(height: 20), - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - height: 1.5, - color: AppColors.textSecondary, - ), - ), - if (buttonText != null) ...[ - const SizedBox(height: 22), - FilledButton( - onPressed: onPressed, - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 28, - vertical: 12, - ), + AppEmptyState( + icon: icon, + iconColor: onPressed == null ? AppColors.notification : AppColors.error, + title: title, + subtitle: message, + padding: const EdgeInsets.all(24), + action: buttonText == null + ? null + : SizedBox( + width: 140, + child: AppGradientOutlineButton( + label: buttonText!, + onPressed: onPressed, ), - child: Text(buttonText!), ), - ], - ], - ), ), ], ); diff --git a/health_app/lib/pages/profile/profile_edit_page.dart b/health_app/lib/pages/profile/profile_edit_page.dart new file mode 100644 index 0000000..9a76bff --- /dev/null +++ b/health_app/lib/pages/profile/profile_edit_page.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; +import '../../core/app_theme.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/data_providers.dart'; +import '../../widgets/app_error_state.dart'; +import '../../widgets/app_toast.dart'; +import '../../widgets/common_widgets.dart'; + +class ProfileEditPage extends ConsumerStatefulWidget { + const ProfileEditPage({super.key}); + + @override + ConsumerState createState() => _ProfileEditPageState(); +} + +class _ProfileEditPageState extends ConsumerState { + final _nameController = TextEditingController(); + bool _loading = true; + bool _saving = false; + String? _error; + String _gender = ''; + DateTime? _birthDate; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final profile = await ref.read(userServiceProvider).getProfile(); + if (!mounted) return; + setState(() { + _nameController.text = profile?['name']?.toString() ?? ''; + _gender = profile?['gender']?.toString() ?? ''; + _birthDate = DateTime.tryParse(profile?['birthDate']?.toString() ?? ''); + _loading = false; + _error = null; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _loading = false; + _error = '个人资料加载失败'; + }); + } + } + + Future _save() async { + final name = _nameController.text.trim(); + if (name.isEmpty) { + AppToast.show(context, '请填写姓名', type: AppToastType.warning); + return; + } + setState(() => _saving = true); + try { + await ref + .read(userServiceProvider) + .updateProfile( + name: name, + gender: _gender.isEmpty ? null : _gender, + birthDate: _birthDate == null ? null : _formatDate(_birthDate!), + ); + await ref.read(authProvider.notifier).refreshProfile(); + if (!mounted) return; + AppToast.show(context, '个人资料已保存', type: AppToastType.success); + popRoute(ref); + } catch (_) { + if (mounted) { + AppToast.show(context, '保存失败,请稍后重试', type: AppToastType.error); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _pickBirthDate() async { + final now = DateTime.now(); + final selected = await showDatePicker( + context: context, + initialDate: _birthDate ?? DateTime(now.year - 30), + firstDate: DateTime(1900), + lastDate: now, + ); + if (selected != null && mounted) setState(() => _birthDate = selected); + } + + @override + Widget build(BuildContext context) { + return GradientScaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19), + onPressed: _saving ? null : () => popRoute(ref), + ), + title: const Text('基本资料'), + centerTitle: true, + ), + bottomNavigationBar: _loading || _error != null + ? null + : SafeArea( + minimum: const EdgeInsets.fromLTRB(18, 8, 18, 14), + child: AppGradientOutlineButton( + label: _saving ? '保存中...' : '保存', + loading: _saving, + onPressed: _saving ? null : _save, + ), + ), + body: _loading + ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) + : _error != null + ? AppErrorState(title: _error!, subtitle: '请检查网络后重试', onRetry: _load) + : ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(18, 12, 18, 28), + children: [ + _FormPanel( + children: [ + const _FieldLabel('姓名'), + const SizedBox(height: 8), + TextField( + controller: _nameController, + maxLength: 30, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + hintText: '请输入姓名', + counterText: '', + ), + ), + const SizedBox(height: 18), + const _FieldLabel('性别'), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'Male', label: Text('男')), + ButtonSegment(value: 'Female', label: Text('女')), + ButtonSegment(value: 'Other', label: Text('其他')), + ], + selected: _gender.isEmpty ? const {} : {_gender}, + emptySelectionAllowed: true, + showSelectedIcon: false, + onSelectionChanged: (value) { + setState( + () => _gender = value.isEmpty ? '' : value.first, + ); + }, + ), + const SizedBox(height: 18), + const _FieldLabel('出生日期'), + const SizedBox(height: 8), + InkWell( + onTap: _pickBirthDate, + borderRadius: AppRadius.mdBorder, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + Expanded( + child: Text( + _birthDate == null + ? '请选择出生日期' + : _formatDate(_birthDate!), + style: TextStyle( + fontSize: 15, + color: _birthDate == null + ? AppColors.textHint + : AppColors.textPrimary, + ), + ), + ), + const Icon( + Icons.calendar_today_outlined, + size: 19, + color: AppColors.primary, + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 16), + _ReadOnlyPhone(phone: ref.read(authProvider).user?.phone ?? ''), + ], + ), + ); + } + + String _formatDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; +} + +class _FormPanel extends StatelessWidget { + final List children; + const _FormPanel({required this.children}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); +} + +class _FieldLabel extends StatelessWidget { + final String text; + const _FieldLabel(this.text); + + @override + Widget build(BuildContext context) => Text( + text, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ); +} + +class _ReadOnlyPhone extends StatelessWidget { + final String phone; + const _ReadOnlyPhone({required this.phone}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + const Icon(Icons.phone_iphone_rounded, color: AppColors.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('手机号', style: AppTextStyles.listTitle), + const SizedBox(height: 4), + Text( + phone.isEmpty ? '未绑定' : phone, + style: AppTextStyles.listSubtitle, + ), + ], + ), + ), + const Text( + '手机号不可修改', + style: TextStyle(fontSize: 12, color: AppColors.textHint), + ), + ], + ), + ); +} diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index b8633a0..e0456f0 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -13,10 +13,11 @@ class ProfilePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final auth = ref.watch(authProvider); - final user = auth.user; - final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称'; - final phone = user?.phone ?? ''; + final user = ref.watch(authProvider.select((state) => state.user)); + final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称'; + final phone = user?.phone.trim().isNotEmpty == true + ? user!.phone + : '未绑定手机号'; return GradientScaffold( appBar: AppBar( @@ -29,23 +30,48 @@ class ProfilePage extends ConsumerWidget { ), body: SafeArea( child: ListView( - padding: const EdgeInsets.fromLTRB(18, 16, 18, 32), + padding: const EdgeInsets.fromLTRB(18, 12, 18, 32), children: [ - _AccountCard( + _AccountSummary( name: name, - phone: phone.isNotEmpty ? phone : '未绑定手机号', + phone: phone, avatarUrl: user?.avatarUrl, ), - const SizedBox(height: 14), - _ActionTile( - icon: AppModuleVisuals.health.icon, - title: '健康档案', - subtitle: '维护个人资料、病史、手术和过敏信息', - visual: AppModuleVisuals.health, - onTap: () => pushRoute(ref, 'healthArchive'), + const SizedBox(height: 20), + const _SectionTitle('资料管理'), + const SizedBox(height: 9), + _SettingsGroup( + children: [ + _ActionRow( + icon: Icons.badge_outlined, + iconColor: AppColors.primary, + title: '基本资料', + subtitle: '姓名、性别和出生日期', + onTap: () => pushRoute(ref, 'profileEdit'), + ), + _ActionRow( + icon: AppModuleVisuals.health.icon, + iconColor: AppModuleVisuals.health.color, + title: '健康档案', + subtitle: '疾病、手术、过敏和生活习惯', + onTap: () => pushRoute(ref, 'healthArchive'), + ), + ], + ), + const SizedBox(height: 20), + const _SectionTitle('账号操作'), + const SizedBox(height: 9), + _SettingsGroup( + children: [ + _ActionRow( + icon: Icons.logout_rounded, + iconColor: AppColors.textSecondary, + title: '退出登录', + showChevron: false, + onTap: () => _logout(context, ref), + ), + ], ), - const SizedBox(height: 14), - _LogoutButton(onPressed: () => _logout(context, ref)), ], ), ), @@ -53,37 +79,37 @@ class ProfilePage extends ConsumerWidget { } Future _logout(BuildContext context, WidgetRef ref) async { - final ok = await showDialog( + final confirmed = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (dialogContext) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), title: const Text('退出登录'), - content: const Text('确定退出当前账号?'), + content: const Text('确定退出当前账号吗?'), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), + onPressed: () => Navigator.pop(dialogContext, false), child: const Text('取消'), ), TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('确定', style: TextStyle(color: AppColors.error)), + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), + child: const Text('退出'), ), ], ), ); - if (ok == true) { - await ref.read(authProvider.notifier).logout(); - goRoute(ref, 'login'); - } + if (confirmed != true || !context.mounted) return; + await ref.read(authProvider.notifier).logout(); + goRoute(ref, 'login'); } } -class _AccountCard extends StatelessWidget { +class _AccountSummary extends StatelessWidget { final String name; final String phone; final String? avatarUrl; - const _AccountCard({ + const _AccountSummary({ required this.name, required this.phone, required this.avatarUrl, @@ -91,17 +117,31 @@ class _AccountCard extends StatelessWidget { @override Widget build(BuildContext context) => Container( - padding: AppSpacing.panel, + padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.lgBorder, - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: AppShadows.soft, ), child: Row( children: [ - _Avatar(avatarUrl: avatarUrl), - const SizedBox(width: 14), + Container( + width: 60, + height: 60, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AppModuleVisuals.health.lightColor, + borderRadius: AppRadius.lgBorder, + ), + child: avatarUrl?.isNotEmpty == true + ? Image.network( + avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const _AvatarFallback(), + ) + : const _AvatarFallback(), + ), + const SizedBox(width: 15), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -113,11 +153,11 @@ class _AccountCard extends StatelessWidget { style: AppTextStyles.summaryTitle.copyWith(fontSize: 20), ), const SizedBox(height: 5), - Text( - phone, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.listSubtitle, + Text(phone, style: AppTextStyles.listSubtitle), + const SizedBox(height: 5), + const Text( + '头像由账号系统统一管理', + style: TextStyle(fontSize: 12, color: AppColors.textHint), ), ], ), @@ -127,103 +167,124 @@ class _AccountCard extends StatelessWidget { ); } -class _Avatar extends StatelessWidget { - final String? avatarUrl; - - const _Avatar({required this.avatarUrl}); +class _AvatarFallback extends StatelessWidget { + const _AvatarFallback(); @override - Widget build(BuildContext context) => Container( - width: 58, - height: 58, - decoration: BoxDecoration( - gradient: AppModuleVisuals.health.gradient, - borderRadius: AppRadius.lgBorder, - ), - clipBehavior: Clip.antiAlias, - child: avatarUrl != null && avatarUrl!.isNotEmpty - ? Image.network(avatarUrl!, fit: BoxFit.cover) - : const Icon(Icons.person_rounded, color: Colors.white, size: 34), + Widget build(BuildContext context) => Icon( + Icons.person_rounded, + color: AppModuleVisuals.health.color, + size: 34, ); } -class _ActionTile extends StatelessWidget { +class _SectionTitle extends StatelessWidget { + final String text; + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text( + text, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textSecondary, + ), + ), + ); +} + +class _SettingsGroup extends StatelessWidget { + final List children; + const _SettingsGroup({required this.children}); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i != children.length - 1) + const Padding( + padding: EdgeInsets.only(left: 62), + child: Divider(height: 1, color: AppColors.borderLight), + ), + ], + ], + ), + ); +} + +class _ActionRow extends StatelessWidget { final IconData icon; + final Color iconColor; final String title; - final String subtitle; - final AppModuleVisual visual; + final String? subtitle; + final bool showChevron; final VoidCallback onTap; - const _ActionTile({ + const _ActionRow({ required this.icon, + required this.iconColor, required this.title, - required this.subtitle, - required this.visual, + this.subtitle, + this.showChevron = true, required this.onTap, }); @override Widget build(BuildContext context) => Material( - color: Colors.white, - borderRadius: AppRadius.lgBorder, + color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: AppRadius.lgBorder, - child: Container( - padding: const EdgeInsets.all(15), - decoration: BoxDecoration( - borderRadius: AppRadius.lgBorder, - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: AppShadows.soft, - ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14), child: Row( children: [ Container( - width: 46, - height: 46, + width: 36, + height: 36, decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: AppRadius.mdBorder, + color: iconColor.withValues(alpha: 0.10), + borderRadius: AppRadius.smBorder, ), - child: Icon(icon, color: Colors.white, size: 24), + child: Icon(icon, color: iconColor, size: 20), ), - const SizedBox(width: 13), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: AppTextStyles.listTitle), - const SizedBox(height: 4), Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.listSubtitle, + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + Text(subtitle!, style: AppTextStyles.listSubtitle), + ], ], ), ), - const Icon(Icons.chevron_right_rounded, color: AppColors.textHint), + if (showChevron) + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + ), ], ), ), ), ); } - -class _LogoutButton extends StatelessWidget { - final VoidCallback onPressed; - - const _LogoutButton({required this.onPressed}); - - @override - Widget build(BuildContext context) => TextButton( - onPressed: onPressed, - style: TextButton.styleFrom( - foregroundColor: AppColors.error, - padding: const EdgeInsets.symmetric(vertical: 14), - textStyle: AppTextStyles.button, - ), - child: const Text('退出登录'), - ); -} diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 6697b8e..90e75b1 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/app_colors.dart'; import '../core/app_design_tokens.dart'; import '../core/app_module_visuals.dart'; @@ -9,6 +10,7 @@ import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; import '../widgets/common_widgets.dart'; +import '../widgets/app_empty_state.dart'; import '../widgets/app_error_state.dart'; import '../widgets/app_future_view.dart'; import '../widgets/app_toast.dart'; @@ -206,9 +208,8 @@ class _DietRecordListPageState extends ConsumerState { child: Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( - gradient: isSel ? DietPalette.gradient : null, color: isSel - ? null + ? AppColors.primary : (isToday ? DietPalette.primarySoft : Colors.white), borderRadius: AppRadius.mdBorder, border: Border.all( @@ -218,7 +219,6 @@ class _DietRecordListPageState extends ConsumerState { ? const Color(0xFFC9D0FF) : AppColors.borderLight), ), - boxShadow: isSel ? AppColors.cardShadowLight : null, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -428,13 +428,8 @@ class _DietDaySummary extends StatelessWidget { return Container( padding: const EdgeInsets.fromLTRB(16, 15, 16, 14), decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFF0F3FF), Color(0xFFFFFFFF)], - ), + color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: AppColors.cardShadowLight, ), child: Row( children: [ @@ -466,13 +461,13 @@ class _DietDaySummary extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - gradient: DietPalette.gradient, + color: DietPalette.primarySoft, borderRadius: AppRadius.mdBorder, ), - child: const Icon( - Icons.restaurant_menu, + child: Icon( + AppModuleVisuals.diet.icon, size: 22, - color: Colors.white, + color: DietPalette.primary, ), ), ], @@ -487,19 +482,48 @@ class _DietEmptyDay extends StatelessWidget { @override Widget build(BuildContext context) => Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.mdBorder, ), - child: const Text( - '当天暂无记录', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.textHint, - ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: DietPalette.primarySoft, + shape: BoxShape.circle, + ), + child: Icon( + AppModuleVisuals.diet.icon, + size: 21, + color: DietPalette.primary, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '当天没有饮食记录', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 3), + Text( + '记录后会在这里汇总热量和食物明细', + style: TextStyle(fontSize: 13, color: AppColors.textHint), + ), + ], + ), + ), + ], ), ); } @@ -522,7 +546,6 @@ class _DietTrendPanel extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -562,9 +585,8 @@ class _Toggle extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - gradient: a ? DietPalette.gradient : null, - color: a ? null : Colors.white, - borderRadius: BorderRadius.circular(12), + color: a ? AppColors.primary : Colors.white, + borderRadius: AppRadius.mdBorder, border: Border.all(color: a ? Colors.transparent : AppColors.border), ), child: Text( @@ -618,8 +640,7 @@ class _TrendChart extends StatelessWidget { Container( height: h, decoration: BoxDecoration( - gradient: data[i] > 0 ? DietPalette.gradient : null, - color: data[i] > 0 ? null : AppColors.border, + color: data[i] > 0 ? DietPalette.primary : AppColors.border, borderRadius: BorderRadius.circular(2), ), ), @@ -646,16 +667,17 @@ class _SwipeAction extends StatelessWidget { @override Widget build(BuildContext c) => Dismissible( key: itemKey, - direction: DismissDirection.endToStart, + direction: DismissDirection.horizontal, confirmDismiss: (direction) async { - // 右滑编辑:不消除,弹回,编辑由 onUpdate 触发 - if (direction == DismissDirection.startToEnd) return false; - // 左滑删除:放行,onDismissed 会触发 onDelete + if (dietRecordSwipeAction(direction) == DietRecordSwipeAction.edit) { + onEdit(); + return false; + } return true; }, background: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), + borderRadius: AppRadius.mdBorder, color: const Color(0xFF3B82F6), ), alignment: Alignment.centerLeft, @@ -664,7 +686,7 @@ class _SwipeAction extends StatelessWidget { ), secondaryBackground: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), + borderRadius: AppRadius.mdBorder, color: AppColors.error, ), alignment: Alignment.centerRight, @@ -672,22 +694,46 @@ class _SwipeAction extends StatelessWidget { child: const Icon(Icons.delete, color: Colors.white), ), onDismissed: (_) => onDelete(), - onUpdate: (details) { - if (details.reached && details.direction == DismissDirection.startToEnd) { - onEdit(); - } - }, child: child, ); } /// 饮食记录详情页 -class DietRecordDetailPage extends ConsumerWidget { +class DietRecordDetailPage extends ConsumerStatefulWidget { final String id; const DietRecordDetailPage({super.key, required this.id}); + @override - Widget build(BuildContext context, WidgetRef ref) { - final service = ref.watch(dietServiceProvider); + ConsumerState createState() => + _DietRecordDetailPageState(); +} + +class _DietRecordDetailPageState extends ConsumerState { + late Future>> _recordsFuture; + + @override + void initState() { + super.initState(); + _recordsFuture = _loadRecords(); + } + + Future>> _loadRecords() => + ref.read(dietServiceProvider).getRecords(); + + void _reload() => setState(() => _recordsFuture = _loadRecords()); + + String _formatRecordedAt(Object? value) { + final date = DateTime.tryParse(value?.toString() ?? '')?.toLocal(); + if (date == null) return ''; + final now = DateTime.now(); + final prefix = date.year == now.year + ? '${date.month}-${date.day}' + : '${date.year}-${date.month}-${date.day}'; + return '$prefix ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { return GradientScaffold( appBar: AppBar( leading: IconButton( @@ -697,18 +743,32 @@ class DietRecordDetailPage extends ConsumerWidget { title: const Text('饮食详情'), ), body: FutureBuilder>>( - future: service.getRecords(), + future: _recordsFuture, builder: (ctx, snap) { if (!snap.hasData) { + if (snap.hasError) { + return AppErrorState( + title: '饮食记录加载失败', + subtitle: '请检查网络后重新进入', + onRetry: _reload, + ); + } return const Center( child: CircularProgressIndicator(color: AppTheme.primary), ); } final d = snap.data!.firstWhere( - (r) => r['id']?.toString() == id, + (r) => r['id']?.toString() == widget.id, orElse: () => {}, ); - if (d.isEmpty) return const Center(child: Text('记录不存在')); + if (d.isEmpty) { + return AppEmptyState( + icon: AppModuleVisuals.diet.icon, + iconColor: DietPalette.primary, + title: '这条饮食记录不存在', + subtitle: '记录可能已经被删除', + ); + } final items = (d['foodItems'] as List?)?.cast>() ?? []; final totalCal = (d['totalCalories'] as num?)?.toInt() ?? 0; @@ -729,7 +789,6 @@ class DietRecordDetailPage extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, - boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -749,7 +808,7 @@ class DietRecordDetailPage extends ConsumerWidget { ), ), Text( - d['recordedAt']?.toString() ?? '', + _formatRecordedAt(d['recordedAt']), style: const TextStyle( fontSize: 13, color: AppColors.textHint, @@ -811,6 +870,14 @@ class DietRecordDetailPage extends ConsumerWidget { color: Colors.white, child: Column( children: [ + if (items.isEmpty) + const Padding( + padding: EdgeInsets.all(18), + child: Text( + '这条记录没有食物明细', + style: TextStyle(color: AppColors.textHint), + ), + ), for (var i = 0; i < items.length; i++) ...[ Padding( padding: const EdgeInsets.symmetric( @@ -954,7 +1021,9 @@ class _ExercisePlanPageState extends ConsumerState { onRetry: _load, errorTitle: '运动计划加载失败', onData: (ctx, plans) { - if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建'); + if (plans.isEmpty) { + return _empty(context, '运动计划', '暂无计划,点击右下角新建'); + } final todayKey = _todayKey(); final todayItems = plans .expand( @@ -1396,7 +1465,7 @@ class _ExerciseCheckInSummary extends StatelessWidget { label: '待完成', value: '$pending', icon: Icons.schedule_outlined, - color: AppColors.warning, + color: AppColors.warningText, ), ), const SizedBox(width: 8), @@ -1405,7 +1474,7 @@ class _ExerciseCheckInSummary extends StatelessWidget { label: '已打卡', value: '$doneToday', icon: Icons.check_circle_outline, - color: AppTheme.success, + color: AppColors.successText, ), ), const SizedBox(width: 8), @@ -1680,8 +1749,8 @@ class _ExerciseHeroCard extends StatelessWidget { _ExercisePlanDetailPageState._exerciseVisual.gradient, borderRadius: BorderRadius.circular(12), ), - child: const Icon( - Icons.directions_run, + child: Icon( + _ExercisePlanDetailPageState._exerciseVisual.icon, color: Colors.white, size: 24, ), @@ -1935,7 +2004,9 @@ class _ExerciseDayTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Icon( - isCompleted ? Icons.check : Icons.fitness_center, + isCompleted + ? Icons.check + : _ExercisePlanDetailPageState._exerciseVisual.icon, color: isCompleted ? AppTheme.success : _ExercisePlanDetailPageState._exerciseVisual.color, @@ -2059,36 +2130,80 @@ class _ExercisePlanCreatePageState title: const Text('新建计划'), ), body: ListView( - padding: const EdgeInsets.all(16), + padding: AppSpacing.page, children: [ - _field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'), - const SizedBox(height: 16), - _field('每天时长(分钟)', _durationCtrl, hint: '30', number: true), - const SizedBox(height: 16), - _field('坚持天数', _daysCtrl, hint: '7', number: true), - const SizedBox(height: 16), - _dateField('开始日期', _start, (d) => setState(() => _start = d)), - const SizedBox(height: 16), - _timeField(), - 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(AppTheme.rMd), + Container( + padding: AppSpacing.panel, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppModuleVisuals.exercise.lightColor, + borderRadius: AppRadius.smBorder, + ), + child: Icon( + AppModuleVisuals.exercise.icon, + color: AppModuleVisuals.exercise.color, + size: 23, + ), ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - child: const Text( - '保存计划', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), - ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('制定运动计划', style: AppTextStyles.sectionTitle), + SizedBox(height: 3), + Text('设置运动内容、周期和每日提醒', style: AppTextStyles.listSubtitle), + ], + ), + ), + ], ), ), + const SizedBox(height: 14), + Container( + padding: AppSpacing.panel, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + children: [ + _field('运动名称', _nameCtrl, hint: '如:散步、慢跑、太极'), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _field( + '每天时长(分钟)', + _durationCtrl, + hint: '30', + number: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _field('坚持天数', _daysCtrl, hint: '7', number: true), + ), + ], + ), + const SizedBox(height: 16), + _dateField('开始日期', _start, (d) => setState(() => _start = d)), + const SizedBox(height: 16), + _timeField(), + ], + ), + ), + const SizedBox(height: 18), + AppGradientOutlineButton(label: '保存计划', onPressed: _save), + const SizedBox(height: 18), ], ), ); @@ -2105,7 +2220,11 @@ class _ExercisePlanCreatePageState children: [ Text( label, - style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), const SizedBox(height: 6), TextField( @@ -2114,13 +2233,17 @@ class _ExercisePlanCreatePageState decoration: InputDecoration( hintText: hint, filled: true, - fillColor: Colors.white, + fillColor: AppColors.cardInner, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: AppColors.border), + borderRadius: AppRadius.mdBorder, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: AppRadius.mdBorder, + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide( color: AppColors.primary, width: 1.5, @@ -2143,7 +2266,11 @@ class _ExercisePlanCreatePageState children: [ Text( label, - style: const TextStyle(fontSize: 14, color: AppColors.textSecondary), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), const SizedBox(height: 6), GestureDetector( @@ -2160,12 +2287,23 @@ class _ExercisePlanCreatePageState width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: AppColors.cardInner, + borderRadius: AppRadius.mdBorder, ), - child: Text( - _displayDate(val), - style: const TextStyle(fontSize: 16), + child: Row( + children: [ + Expanded( + child: Text( + _displayDate(val), + style: const TextStyle(fontSize: 16), + ), + ), + const Icon( + LucideIcons.calendarDays, + size: 19, + color: AppColors.primaryDark, + ), + ], ), ), ), @@ -2181,7 +2319,11 @@ class _ExercisePlanCreatePageState children: [ const Text( '每日提醒时间', - style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), const SizedBox(height: 6), GestureDetector( @@ -2198,10 +2340,21 @@ class _ExercisePlanCreatePageState width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: AppColors.cardInner, + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + Expanded( + child: Text(value, style: const TextStyle(fontSize: 16)), + ), + const Icon( + LucideIcons.clock, + size: 19, + color: AppColors.primaryDark, + ), + ], ), - child: Text(value, style: const TextStyle(fontSize: 16)), ), ), ], @@ -2407,6 +2560,17 @@ class _FollowUpItem extends StatelessWidget { } /// 健康档案(可编辑) +List normalizeArchiveItems(String input) { + final values = input + .split(RegExp(r'[、,,;;\n]')) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toSet() + .toList(); + if (values.length > 1) values.remove('无'); + return values; +} + class HealthArchivePage extends ConsumerStatefulWidget { const HealthArchivePage({super.key}); @override @@ -2425,6 +2589,11 @@ class _HealthArchivePageState extends ConsumerState { final _familyCtrl = TextEditingController(); bool _loading = true; bool _saving = false; + String? _loadError; + String? _surgeryHistoryStatus; + String? _updatedAt; + String _savedSignature = ''; + int _nextSurgeryId = 0; @override void initState() { @@ -2447,20 +2616,27 @@ class _HealthArchivePageState extends ConsumerState { Future _load() async { final srv = ref.read(userServiceProvider); try { - final p = await srv.getProfile(); - if (p != null && mounted) { + final results = await Future.wait([ + srv.getProfile(), + srv.getHealthArchive(), + ]); + final p = results[0]; + final a = results[1]; + if (!mounted) return; + if (p != null) { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; _birthDate = p['birthDate'] ?? ''; } - final a = await srv.getHealthArchive(); - if (a != null && mounted) { + _surgeries.clear(); + if (a != null) { _diagnosisCtrl.text = a['diagnosis'] ?? ''; final surgeries = a['surgeries'] as List?; if (surgeries != null && surgeries.isNotEmpty) { _surgeries.addAll( surgeries.whereType().map( (item) => { + 'localId': '${_nextSurgeryId++}', 'type': item['type']?.toString() ?? '', 'date': item['date']?.toString() ?? '', }, @@ -2470,25 +2646,45 @@ class _HealthArchivePageState extends ConsumerState { final st = a['surgeryType'] as String?; final sd = a['surgeryDate'] as String?; if (st != null && st.isNotEmpty) { - _surgeries.add({'type': st, 'date': sd ?? ''}); + _surgeries.add({ + 'localId': '${_nextSurgeryId++}', + 'type': st, + 'date': sd ?? '', + }); } } _allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? ''; _chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? ''; _dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? ''; _familyCtrl.text = a['familyHistory'] ?? ''; + _surgeryHistoryStatus = a['surgeryHistoryStatus']?.toString(); + _updatedAt = a['updatedAt']?.toString(); } - if (mounted) setState(() => _loading = false); + _surgeryHistoryStatus ??= _surgeries.isNotEmpty ? '有' : null; + _savedSignature = _formSignature(); + setState(() { + _loading = false; + _loadError = null; + }); } catch (_) { - if (mounted) setState(() => _loading = false); + if (mounted) { + setState(() { + _loading = false; + _loadError = '健康档案加载失败'; + }); + } } } - Future _pickDate(void Function(String) onPicked) async { + Future _pickDate( + void Function(String) onPicked, { + String current = '', + }) async { final now = DateTime.now(); + final parsedCurrent = DateTime.tryParse(current); final d = await showAppDatePicker( context, - initialDate: DateTime(now.year - 30), + initialDate: parsedCurrent ?? DateTime(now.year - 30), firstDate: DateTime(1900), lastDate: now, ); @@ -2499,13 +2695,72 @@ class _HealthArchivePageState extends ConsumerState { } } - void _addSurgery() => - setState(() => _surgeries.add({'type': '', 'date': ''})); + void _addSurgery() => setState(() { + _surgeryHistoryStatus = '有'; + _surgeries.add({'localId': '${_nextSurgeryId++}', 'type': '', 'date': ''}); + }); void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i)); void _setGender(String value) => setState(() => _genderCtrl.text = value); + void _setSurgeryHistoryStatus(String value) { + setState(() { + _surgeryHistoryStatus = value; + if (value == '无') _surgeries.clear(); + if (value == '有' && _surgeries.isEmpty) { + _surgeries.add({ + 'localId': '${_nextSurgeryId++}', + 'type': '', + 'date': '', + }); + } + }); + } + + int get _completionPercent { + final values = [ + _nameCtrl.text.trim(), + _genderCtrl.text.trim(), + _birthDate, + _diagnosisCtrl.text.trim(), + _allergiesCtrl.text.trim(), + _chronicCtrl.text.trim(), + _dietCtrl.text.trim(), + _familyCtrl.text.trim(), + _surgeryHistoryStatus ?? '', + ]; + final completed = values.where((value) => value.isNotEmpty).length; + return (completed * 100 / values.length).round(); + } + + String _formSignature() => [ + _nameCtrl.text.trim(), + _genderCtrl.text, + _birthDate, + _diagnosisCtrl.text.trim(), + _allergiesCtrl.text.trim(), + _chronicCtrl.text.trim(), + _dietCtrl.text.trim(), + _familyCtrl.text.trim(), + _surgeryHistoryStatus ?? '', + ..._surgeries.expand( + (s) => [(s['type'] ?? '').trim(), (s['date'] ?? '').trim()], + ), + ].join('|'); + + bool get _hasUnsavedChanges => _formSignature() != _savedSignature; + Future _save() async { + if (_nameCtrl.text.trim().isEmpty) { + AppToast.show(context, '请填写姓名', type: AppToastType.warning); + return; + } + if (_surgeryHistoryStatus == '有' && + _surgeries.any((s) => (s['type'] ?? '').trim().isEmpty)) { + AppToast.show(context, '请填写手术名称', type: AppToastType.warning); + return; + } setState(() => _saving = true); + var profileSaved = false; try { final srv = ref.read(userServiceProvider); await srv.updateProfile( @@ -2513,6 +2768,7 @@ class _HealthArchivePageState extends ConsumerState { gender: _genderCtrl.text, birthDate: _birthDate, ); + profileSaved = true; final surgeries = _surgeries .where((s) => (s['type'] ?? '').trim().isNotEmpty) .map( @@ -2530,30 +2786,61 @@ class _HealthArchivePageState extends ConsumerState { 'surgeryType': firstSurgery?['type'], 'surgeryDate': firstSurgery?['date'], 'surgeries': surgeries, - 'allergies': _allergiesCtrl.text - .split('、') - .where((s) => s.isNotEmpty) - .toList(), - 'chronicDiseases': _chronicCtrl.text - .split('、') - .where((s) => s.isNotEmpty) - .toList(), - 'dietRestrictions': _dietCtrl.text - .split('、') - .where((s) => s.isNotEmpty) - .toList(), + 'allergies': normalizeArchiveItems(_allergiesCtrl.text), + 'chronicDiseases': normalizeArchiveItems(_chronicCtrl.text), + 'dietRestrictions': normalizeArchiveItems(_dietCtrl.text), 'familyHistory': _familyCtrl.text, + 'surgeryHistoryStatus': _surgeryHistoryStatus, }); await ref.read(authProvider.notifier).refreshProfile(); + _savedSignature = _formSignature(); + _updatedAt = DateTime.now().toIso8601String(); + if (mounted) { + AppToast.show(context, '健康档案已保存', type: AppToastType.success); + setState(() {}); + } } catch (_) { if (mounted) { - AppToast.show(context, '保存失败,请重试', type: AppToastType.error); + AppToast.show( + context, + profileSaved ? '个人资料已保存,健康信息保存失败' : '保存失败,请稍后重试', + type: AppToastType.error, + ); } } finally { if (mounted) setState(() => _saving = false); } } + Future _handleBack() async { + if (!_hasUnsavedChanges) { + popRoute(ref); + return; + } + final leave = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder), + title: const Text('放弃修改?'), + content: const Text('当前健康档案还有未保存的修改。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('继续编辑'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text( + '放弃修改', + style: TextStyle(color: AppColors.errorText), + ), + ), + ], + ), + ); + if (leave == true && mounted) popRoute(ref); + } + @override Widget build(BuildContext context) { if (_loading) { @@ -2568,92 +2855,120 @@ class _HealthArchivePageState extends ConsumerState { ), leading: IconButton( icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), - onPressed: () => popRoute(ref), + onPressed: _handleBack, ), ), body: const Center(child: CircularProgressIndicator()), ); } + if (_loadError != null) { + return GradientScaffold( + appBar: AppBar( + title: const Text('健康档案'), + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19), + onPressed: () => popRoute(ref), + ), + ), + body: AppErrorState( + title: _loadError!, + subtitle: '为避免覆盖原有档案,加载成功前不能编辑', + onRetry: () { + setState(() { + _loading = true; + _loadError = null; + }); + _load(); + }, + ), + ); + } return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, elevation: 0, surfaceTintColor: Colors.transparent, - title: const Text( - '健康档案', - style: TextStyle(color: AppColors.textPrimary), - ), + title: const Text('健康档案'), + centerTitle: true, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), - onPressed: () => popRoute(ref), + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19), + onPressed: _handleBack, ), - actions: [ - _saving - ? const Padding( - padding: EdgeInsets.only(right: 16), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : TextButton( - onPressed: _save, - style: TextButton.styleFrom( - foregroundColor: AppColors.textPrimary, - ), - child: const Text( - '保存', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), - ), - ), - ], ), + bottomNavigationBar: _ArchiveSaveBar(saving: _saving, onSave: _save), body: ListView( - padding: const EdgeInsets.all(16), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), children: [ - _Section('个人资料', Icons.person_outline, [ + _ArchiveSummary( + completion: _completionPercent, + updatedAt: _updatedAt, + ), + const SizedBox(height: 16), + _Section('个人资料', LucideIcons.userRound, [ _F('姓名', _nameCtrl, hint: '请输入姓名'), const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - flex: 5, child: _GenderF( value: _genderCtrl.text, onChanged: _setGender, ), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( - flex: 6, child: _DateF( '出生日期', _birthDate, - () => _pickDate((v) => setState(() => _birthDate = v)), + () => _pickDate( + (v) => setState(() => _birthDate = v), + current: _birthDate, + ), ), ), ], ), ]), - const SizedBox(height: 12), - _Section('医疗信息', Icons.local_hospital_outlined, [ - _F('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), + const SizedBox(height: 16), + _Section('医疗经历', LucideIcons.clipboardPlus, [ + _F( + '主要诊断', + _diagnosisCtrl, + hint: '如:冠心病', + onFillNone: () => _diagnosisCtrl.text = '无', + ), const SizedBox(height: 14), Row( children: [ const Text( '手术史', style: TextStyle( - fontSize: 14, - color: AppColors.textSecondary, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), - const Spacer(), - _AddBtn(() => _addSurgery()), ], ), + const SizedBox(height: 8), + _BinaryChoice( + value: _surgeryHistoryStatus, + leftLabel: '无手术史', + leftValue: '无', + rightLabel: '有手术史', + rightValue: '有', + onChanged: _setSurgeryHistoryStatus, + ), + if (_surgeryHistoryStatus == '有') ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: _AddBtn(_addSurgery), + ), + ], ..._surgeries.asMap().entries.map((e) { final i = e.key; final s = e.value; @@ -2667,6 +2982,7 @@ class _HealthArchivePageState extends ConsumerState { s['type'] ?? '', (v) => s['type'] = v, hint: '如: 心脏搭桥', + key: ValueKey(s['localId']), ), ), const SizedBox(width: 8), @@ -2674,7 +2990,10 @@ class _HealthArchivePageState extends ConsumerState { child: _DateF2( '日期', s['date'] ?? '', - () => _pickDate((v) => setState(() => s['date'] = v)), + () => _pickDate( + (v) => setState(() => s['date'] = v), + current: s['date'] ?? '', + ), hint: '点击选择', ), ), @@ -2690,7 +3009,7 @@ class _HealthArchivePageState extends ConsumerState { child: const Icon( Icons.close, size: 16, - color: AppColors.error, + color: AppColors.errorText, ), ), ), @@ -2699,40 +3018,134 @@ class _HealthArchivePageState extends ConsumerState { ); }), ]), - const SizedBox(height: 12), - _Section('病史与限制', Icons.warning_amber_outlined, [ - _F('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'), - const SizedBox(height: 12), - _F('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'), - const SizedBox(height: 12), - _F('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'), - const SizedBox(height: 12), - _F('家族病史', _familyCtrl, hint: '如: 父亲冠心病'), + const SizedBox(height: 16), + _Section('健康风险', LucideIcons.shieldAlert, [ + _F( + '过敏史', + _allergiesCtrl, + hint: '多个项目用顿号分隔,如:青霉素、海鲜', + onFillNone: () => _allergiesCtrl.text = '无', + ), + const SizedBox(height: 14), + _F( + '慢性病史', + _chronicCtrl, + hint: '多个项目用顿号分隔,如:高血压、高血脂', + onFillNone: () => _chronicCtrl.text = '无', + ), + const SizedBox(height: 14), + _F( + '饮食限制', + _dietCtrl, + hint: '多个项目用顿号分隔,如:低盐、低脂', + onFillNone: () => _dietCtrl.text = '无', + ), + const SizedBox(height: 14), + _F( + '家族病史', + _familyCtrl, + hint: '如:父亲患有冠心病', + onFillNone: () => _familyCtrl.text = '无', + ), ]), - const SizedBox(height: 40), ], ), ); } } +class _ArchiveSummary extends StatelessWidget { + final int completion; + final String? updatedAt; + + const _ArchiveSummary({required this.completion, required this.updatedAt}); + + String get _updatedLabel { + final date = DateTime.tryParse(updatedAt ?? '')?.toLocal(); + if (date == null) return '尚未保存健康档案'; + return '更新于 ${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Row( + children: [ + SizedBox( + width: 58, + height: 58, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: completion / 100, + strokeWidth: 5, + color: AppColors.primary, + backgroundColor: AppColors.primaryLight, + ), + Text( + '$completion%', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('档案完整度', style: AppTextStyles.listTitle), + const SizedBox(height: 5), + Text(_updatedLabel, style: AppTextStyles.listSubtitle), + ], + ), + ), + ], + ), + ); +} + +class _ArchiveSaveBar extends StatelessWidget { + final bool saving; + final VoidCallback onSave; + + const _ArchiveSaveBar({required this.saving, required this.onSave}); + + @override + Widget build(BuildContext context) => SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + color: Colors.white, + child: AppGradientOutlineButton( + label: '保存修改', + loading: saving, + onPressed: saving ? null : onSave, + ), + ), + ); +} + class _Section extends StatelessWidget { final String title; final IconData icon; final List fields; const _Section(this.title, this.icon, this.fields); - Color get _color => switch (title) { - '个人资料' => const Color(0xFF2563EB), - '医疗信息' => const Color(0xFF7C5CFF), - '病史与限制' => const Color(0xFFF59E0B), - _ => AppColors.primary, - }; + Color get _color => AppColors.primary; @override Widget build(BuildContext c) => Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), decoration: BoxDecoration( color: Colors.white, - borderRadius: AppRadius.mdBorder, + borderRadius: AppRadius.lgBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2740,26 +3153,26 @@ class _Section extends StatelessWidget { Row( children: [ Container( - width: 30, - height: 30, + width: 28, + height: 28, decoration: BoxDecoration( color: _color.withValues(alpha: 0.10), - borderRadius: AppRadius.mdBorder, + borderRadius: AppRadius.smBorder, ), - child: Icon(icon, size: 18, color: _color), + child: Icon(icon, size: 17, color: _color), ), - const SizedBox(width: 10), + const SizedBox(width: 9), Text( title, style: const TextStyle( - fontSize: 17, + fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 11), ...fields, ], ), @@ -2770,39 +3183,59 @@ class _F extends StatelessWidget { final String label; final TextEditingController ctrl; final String? hint; - const _F(this.label, this.ctrl, {this.hint}); + final VoidCallback? onFillNone; + const _F(this.label, this.ctrl, {this.hint, this.onFillNone}); @override Widget build(BuildContext c) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: const TextStyle(fontSize: 14, color: AppColors.textSecondary), + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + if (onFillNone != null) + TextButton( + onPressed: onFillNone, + style: TextButton.styleFrom( + minimumSize: const Size(0, 30), + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: AppColors.primary, + ), + child: const Text('填无'), + ), + ], ), - const SizedBox(height: 6), + const SizedBox(height: 5), TextField( controller: ctrl, - style: const TextStyle(fontSize: 16), + style: const TextStyle(fontSize: 15), decoration: InputDecoration( hintText: hint, - hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint), + hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), filled: true, - fillColor: const Color(0xFFF8FAFC), + fillColor: AppColors.cardInner, contentPadding: const EdgeInsets.symmetric( horizontal: 14, - vertical: 12, + vertical: 11, ), border: OutlineInputBorder( borderRadius: AppRadius.mdBorder, - borderSide: const BorderSide(color: AppColors.borderLight), + borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: AppRadius.mdBorder, - borderSide: const BorderSide(color: AppColors.borderLight), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: AppRadius.mdBorder, - borderSide: const BorderSide(color: Color(0xFF60A5FA)), + borderSide: const BorderSide(color: AppColors.primary), ), ), ), @@ -2823,16 +3256,19 @@ class _GenderF extends StatelessWidget { children: [ const Text( '性别', - style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), const SizedBox(height: 6), Container( - height: 48, + height: 44, padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), + color: AppColors.cardInner, borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.borderLight), ), child: Row( children: [ @@ -2847,6 +3283,85 @@ class _GenderF extends StatelessWidget { } } +class _BinaryChoice extends StatelessWidget { + final String? value; + final String leftLabel; + final String leftValue; + final String rightLabel; + final String rightValue; + final ValueChanged onChanged; + + const _BinaryChoice({ + required this.value, + required this.leftLabel, + required this.leftValue, + required this.rightLabel, + required this.rightValue, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) => Container( + height: 48, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.cardInner, + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + Expanded( + child: _ChoiceItem( + label: leftLabel, + selected: value == leftValue, + onTap: () => onChanged(leftValue), + ), + ), + const SizedBox(width: 4), + Expanded( + child: _ChoiceItem( + label: rightLabel, + selected: value == rightValue, + onTap: () => onChanged(rightValue), + ), + ), + ], + ), + ); +} + +class _ChoiceItem extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _ChoiceItem({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => Material( + color: selected ? AppColors.primary : Colors.transparent, + borderRadius: AppRadius.smBorder, + child: InkWell( + onTap: onTap, + borderRadius: AppRadius.smBorder, + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: selected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); +} + class _GenderChip extends StatelessWidget { final String label; final bool selected; @@ -2861,14 +3376,14 @@ class _GenderChip extends StatelessWidget { child: Container( alignment: Alignment.center, decoration: BoxDecoration( - color: selected ? const Color(0xFF2563EB) : Colors.transparent, - borderRadius: AppRadius.mdBorder, + color: selected ? AppColors.primary : Colors.transparent, + borderRadius: AppRadius.smBorder, ), child: Text( label, style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, + fontSize: 14, + fontWeight: FontWeight.w700, color: selected ? Colors.white : AppColors.textSecondary, ), ), @@ -2882,7 +3397,7 @@ class _F2 extends StatelessWidget { final String value; final void Function(String) chg; final String? hint; - const _F2(this.label, this.value, this.chg, {this.hint}); + const _F2(this.label, this.value, this.chg, {this.hint, super.key}); @override Widget build(BuildContext c) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2892,30 +3407,30 @@ class _F2 extends StatelessWidget { style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), ), const SizedBox(height: 4), - TextField( - controller: TextEditingController(text: value), + TextFormField( + initialValue: value, onChanged: chg, style: const TextStyle(fontSize: 14), decoration: InputDecoration( hintText: hint, hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), filled: true, - fillColor: const Color(0xFFF8FAFC), + fillColor: AppColors.cardInner, contentPadding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), border: OutlineInputBorder( borderRadius: AppRadius.mdBorder, - borderSide: const BorderSide(color: AppColors.borderLight), + borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: AppRadius.mdBorder, - borderSide: const BorderSide(color: AppColors.borderLight), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: AppRadius.mdBorder, - borderSide: const BorderSide(color: Color(0xFF60A5FA)), + borderSide: const BorderSide(color: AppColors.primary), ), ), ), @@ -2934,32 +3449,39 @@ class _DateF extends StatelessWidget { children: [ Text( label, - style: const TextStyle(fontSize: 14, color: AppColors.textSecondary), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), const SizedBox(height: 6), GestureDetector( onTap: onTap, child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 14), + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), + color: AppColors.cardInner, borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.borderLight), ), alignment: Alignment.centerLeft, child: Row( children: [ - Text( - value.isEmpty ? '点击选择日期' : _slashDate(value), - style: TextStyle( - fontSize: 16, - color: value.isEmpty - ? AppColors.textHint - : AppColors.textPrimary, + Expanded( + child: Text( + value.isEmpty ? '选择日期' : _slashDate(value), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: value.isEmpty + ? AppColors.textHint + : AppColors.textPrimary, + ), ), ), - const Spacer(), + const SizedBox(width: 4), const Icon( Icons.calendar_today_outlined, size: 18, @@ -2994,9 +3516,8 @@ class _DateF2 extends StatelessWidget { height: 44, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), + color: AppColors.cardInner, borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.borderLight), ), alignment: Alignment.centerLeft, child: Row( @@ -3026,12 +3547,12 @@ class _DateF2 extends StatelessWidget { String _slashDate(String value) { final date = value.trim(); - if (date.length >= 10) return date.substring(0, 10).replaceAll('-', '/'); - return date.replaceAll('-', '/'); + if (date.length >= 10) return date.substring(0, 10); + return date; } String _displayDate(DateTime date) { - return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } class _AddBtn extends StatelessWidget { @@ -3044,18 +3565,18 @@ class _AddBtn extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + color: AppColors.primarySoft, borderRadius: AppRadius.mdBorder, - border: Border.all(color: const Color(0xFFBFDBFE)), + border: Border.all(color: AppColors.primaryLight), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.add, size: 14, color: Color(0xFF7C5CFF)), + Icon(Icons.add, size: 14, color: AppColors.primaryDark), SizedBox(width: 2), Text( '添加', - style: TextStyle(fontSize: 13, color: Color(0xFF7C5CFF)), + style: TextStyle(fontSize: 13, color: AppColors.primaryDark), ), ], ), @@ -3065,6 +3586,12 @@ class _AddBtn extends StatelessWidget { } /// 健康日历 +DateTime calendarDateForMonth(DateTime month, DateTime selectedDate) { + final lastDay = DateTime(month.year, month.month + 1, 0).day; + final day = selectedDate.day > lastDay ? lastDay : selectedDate.day; + return DateTime(month.year, month.month, day); +} + class HealthCalendarPage extends ConsumerStatefulWidget { const HealthCalendarPage({super.key}); @override @@ -3073,9 +3600,16 @@ class HealthCalendarPage extends ConsumerStatefulWidget { class _HealthCalendarPageState extends ConsumerState { DateTime _currentMonth = DateTime.now(); - DateTime? _selectedDate; + DateTime? _selectedDate = DateTime.now(); Map>> _events = {}; Map? _selectedDayData; + final Map> _dayCache = {}; + bool _loadingMonth = true; + bool _loadingDay = false; + String? _monthError; + String? _dayError; + int _monthRequestId = 0; + int _dayRequestId = 0; static const _medColor = AppColors.medication; static const _exerciseColor = AppColors.exercise; @@ -3086,13 +3620,19 @@ class _HealthCalendarPageState extends ConsumerState { void initState() { super.initState(); _loadMonth(); - _selectDate(DateTime.now()); } String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; Future _loadMonth() async { + final requestId = ++_monthRequestId; + final requestedMonth = DateTime(_currentMonth.year, _currentMonth.month); + setState(() { + _loadingMonth = true; + _monthError = null; + _events = {}; + }); try { final api = ref.read(apiClientProvider); final res = await api.get( @@ -3108,41 +3648,111 @@ class _HealthCalendarPageState extends ConsumerState { final map = item as Map; final types = (map['events'] as List?)?.cast() ?? []; events[map['date'] as String] = types - .map((t) => {'type': t}) + .map((type) => {'type': type}) .toList(); } - if (mounted) setState(() => _events = events); - // 如果有选中的日期,刷新详情 - if (_selectedDate != null) _selectDate(_selectedDate!); - } catch (_) {} - } - - void _selectDate(DateTime d) { - setState(() { - _selectedDate = d; - _selectedDayData = null; - }); - final key = _dateKey(d); - final details = _events[key]; - if (details != null && details.isNotEmpty) { - // 从后端拉详细数据 - _loadDayDetail(d); + if (!mounted || + requestId != _monthRequestId || + requestedMonth.year != _currentMonth.year || + requestedMonth.month != _currentMonth.month) { + return; + } + setState(() { + _events = events; + _loadingMonth = false; + }); + final selectedDate = _selectedDate; + if (selectedDate != null) _selectDate(selectedDate, force: true); + } catch (_) { + if (!mounted || requestId != _monthRequestId) return; + setState(() { + _loadingMonth = false; + _monthError = '本月计划加载失败'; + _loadingDay = false; + }); } } + void _selectDate(DateTime d, {bool force = false}) { + final key = _dateKey(d); + final cached = _dayCache[key]; + setState(() { + _selectedDate = d; + _selectedDayData = cached; + _dayError = null; + _loadingDay = false; + }); + if (cached != null && !force) return; + final details = _events[key]; + if (details != null && details.isNotEmpty) { + _loadDayDetail(d); + } else { + setState(() => _selectedDayData = _emptyDayData()); + } + } + + Map _emptyDayData() => { + 'medications': >[], + 'exercises': >[], + 'followUps': >[], + }; + Future _loadDayDetail(DateTime d) async { + final requestId = ++_dayRequestId; + final key = _dateKey(d); + setState(() { + _loadingDay = true; + _dayError = null; + _selectedDayData = null; + }); try { final api = ref.read(apiClientProvider); final res = await api.get( '/api/calendar/day', queryParameters: {'date': _dateKey(d)}, ); - if (mounted) { - setState( - () => _selectedDayData = res.data['data'] as Map?, - ); + if (!mounted || + requestId != _dayRequestId || + _selectedDate == null || + _dateKey(_selectedDate!) != key) { + return; } - } catch (_) {} + final data = res.data['data'] as Map? ?? _emptyDayData(); + _dayCache[key] = data; + setState(() { + _selectedDayData = data; + _loadingDay = false; + }); + } catch (_) { + if (!mounted || + requestId != _dayRequestId || + _selectedDate == null || + _dateKey(_selectedDate!) != key) { + return; + } + setState(() { + _loadingDay = false; + _dayError = '当天计划加载失败'; + }); + } + } + + void _changeMonth(int offset) { + final nextMonth = DateTime( + _currentMonth.year, + _currentMonth.month + offset, + ); + final selectedDate = _selectedDate ?? _currentMonth; + final nextDate = calendarDateForMonth(nextMonth, selectedDate); + _dayRequestId++; + setState(() { + _currentMonth = nextMonth; + _selectedDate = nextDate; + _selectedDayData = null; + _dayError = null; + _loadingDay = false; + }); + _loadMonth(); } @override @@ -3169,9 +3779,7 @@ class _HealthCalendarPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(10, 8, 10, 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], + borderRadius: AppRadius.xlBorder, ), child: Column( children: [ @@ -3179,6 +3787,24 @@ class _HealthCalendarPageState extends ConsumerState { _buildWeekdayHeader(), const SizedBox(height: 6), _buildCalendarGrid(), + if (_loadingMonth) + const Padding( + padding: EdgeInsets.fromLTRB(8, 8, 8, 0), + child: LinearProgressIndicator( + minHeight: 2, + color: _calendarColor, + backgroundColor: AppColors.calendarLight, + ), + ), + if (_monthError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: TextButton.icon( + onPressed: _loadMonth, + icon: const Icon(Icons.refresh, size: 17), + label: Text('$_monthError,点击重试'), + ), + ), ], ), ), @@ -3188,12 +3814,10 @@ class _HealthCalendarPageState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _Legend('用药提醒', _medColor), - const SizedBox(width: 10), _Legend('运动计划', _exerciseColor), - const SizedBox(width: 10), _Legend('复查随访', _followupColor), ], ), @@ -3218,13 +3842,7 @@ class _HealthCalendarPageState extends ConsumerState { foregroundColor: _calendarColor, ), onPressed: () { - setState( - () => _currentMonth = DateTime( - _currentMonth.year, - _currentMonth.month - 1, - ), - ); - _loadMonth(); + _changeMonth(-1); }, ), Text( @@ -3238,13 +3856,7 @@ class _HealthCalendarPageState extends ConsumerState { foregroundColor: _calendarColor, ), onPressed: () { - setState( - () => _currentMonth = DateTime( - _currentMonth.year, - _currentMonth.month + 1, - ), - ); - _loadMonth(); + _changeMonth(1); }, ), ], @@ -3309,7 +3921,7 @@ class _HealthCalendarPageState extends ConsumerState { : (isToday ? _calendarColor.withValues(alpha: 0.08) : Colors.white), - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.mdBorder, border: isSel ? Border.all(color: _calendarColor, width: 1.5) : (isToday @@ -3327,9 +3939,7 @@ class _HealthCalendarPageState extends ConsumerState { style: TextStyle( fontSize: 16, color: isSel ? Colors.white : AppColors.textPrimary, - fontWeight: isSel || isToday - ? FontWeight.w600 - : FontWeight.normal, + fontWeight: FontWeight.w600, ), ), if (evs.isNotEmpty) @@ -3365,7 +3975,7 @@ class _HealthCalendarPageState extends ConsumerState { 'exercise' => _exerciseColor, 'followup' => _followupColor, 'health' => AppColors.health, - 'diet' => AppColors.diet, + 'diet' => DietPalette.primary, 'report' => AppColors.report, 'device' => AppColors.device, _ => AppColors.textHint, @@ -3377,44 +3987,33 @@ class _HealthCalendarPageState extends ConsumerState { child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)), ); } - if (_selectedDayData == null) { - final evs = _events[_dateKey(_selectedDate!)] ?? []; - if (evs.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.event_available, - size: 40, - color: AppColors.textHint, - ), - const SizedBox(height: 8), - Text( - '${_selectedDate!.month}月${_selectedDate!.day}日无事', - style: const TextStyle(color: AppColors.textHint), - ), - ], - ), - ); - } + if (_loadingDay) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + SizedBox(height: 12), + Text('正在加载当天计划', style: TextStyle(color: AppColors.textSecondary)), + ], + ), + ); + } + if (_dayError != null) { + return AppErrorState( + title: _dayError!, + subtitle: '请检查网络后重新加载', + onRetry: () => _loadDayDetail(_selectedDate!), + ); } final data = _selectedDayData; if (data == null) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.event_available, size: 40, color: AppColors.textHint), - const SizedBox(height: 8), - Text( - '${_selectedDate!.month}月${_selectedDate!.day}日无事', - style: const TextStyle(color: AppColors.textHint), - ), - ], - ), - ); + return const SizedBox.shrink(); } final meds = @@ -3425,55 +4024,66 @@ class _HealthCalendarPageState extends ConsumerState { (data['followUps'] as List?)?.cast>() ?? []; if (meds.isEmpty && exs.isEmpty && fups.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.event_available, size: 40, color: AppColors.textHint), - const SizedBox(height: 8), - Text( - '${_selectedDate!.month}月${_selectedDate!.day}日无事', - style: const TextStyle(color: AppColors.textHint), - ), - ], - ), + return AppEmptyState( + icon: AppModuleVisuals.calendar.icon, + iconColor: AppModuleVisuals.calendar.color, + title: '${_selectedDate!.month}月${_selectedDate!.day}日没有健康安排', + subtitle: '当天的用药、运动和复查计划会显示在这里', ); } return ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), children: [ - // 用药 + Text( + '${_selectedDate!.month}月${_selectedDate!.day}日计划', + style: AppTextStyles.sectionTitle, + ), + const SizedBox(height: 10), if (meds.isNotEmpty) ...[ - _SectionTitle('用药提醒', _medColor, Icons.medication), - ...meds.map( - (m) => _EventCard( - '${m['name'] ?? ''} ${m['dosage'] ?? ''}', - '${m['timeOfDay'] ?? ''}', - _medColor, - ), + _PlanGroup( + title: '用药提醒', + color: _medColor, + icon: AppModuleVisuals.medication.icon, + items: meds + .map((m) => _PlanItem(title: '${m['name'] ?? '未命名药品'}')) + .toList(), ), + const SizedBox(height: 12), ], - // 运动 if (exs.isNotEmpty) ...[ - _SectionTitle('运动计划', _exerciseColor, Icons.fitness_center), - ...exs.map( - (e) => _EventCard( - '${e['type'] ?? ''}', - '${e['duration'] ?? 0}分钟', - _exerciseColor, - ), + _PlanGroup( + title: '运动计划', + color: _exerciseColor, + icon: AppModuleVisuals.exercise.icon, + items: exs + .map( + (e) => _PlanItem( + title: '${e['type'] ?? '运动计划'}', + trailing: '${e['duration'] ?? 0}分钟', + ), + ) + .toList(), ), + const SizedBox(height: 12), ], - // 随访 if (fups.isNotEmpty) ...[ - _SectionTitle('复查随访', _followupColor, Icons.event_note), - ...fups.map( - (f) => _EventCard( - f['title'] ?? '', - f['doctorName'] ?? '', - _followupColor, - ), + _PlanGroup( + title: '复查随访', + color: _followupColor, + icon: AppModuleVisuals.followup.icon, + items: fups.map((f) { + final doctor = '${f['doctorName'] ?? ''}'.trim(); + final department = '${f['department'] ?? ''}'.trim(); + final detail = [ + doctor, + department, + ].where((value) => value.isNotEmpty).join(' · '); + return _PlanItem( + title: '${f['title'] ?? '复查随访'}', + trailing: detail, + ); + }).toList(), ), ], ], @@ -3481,77 +4091,135 @@ class _HealthCalendarPageState extends ConsumerState { } } -class _SectionTitle extends StatelessWidget { +class _PlanItem { + final String title; + final String trailing; + + const _PlanItem({required this.title, this.trailing = ''}); +} + +class _PlanGroup extends StatelessWidget { final String title; final Color color; final IconData icon; - const _SectionTitle(this.title, this.color, this.icon); + final List<_PlanItem> items; + + const _PlanGroup({ + required this.title, + required this.color, + required this.icon, + required this.items, + }); + @override - Widget build(BuildContext c) => Padding( - padding: const EdgeInsets.only(top: 12, bottom: 6), - child: Row( + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.lgBorder, + ), + child: Column( children: [ - Container( - width: 6, - height: 20, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2), + Padding( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.11), + borderRadius: AppRadius.smBorder, + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], ), ), - const SizedBox(width: 8), - Icon(icon, size: 18, color: color), - const SizedBox(width: 6), - Text( - title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, + for (var index = 0; index < items.length; index++) + _PlanRow( + item: items[index], color: color, + showDivider: index < items.length - 1, ), - ), ], ), ); } -class _EventCard extends StatelessWidget { - final String title; - final String sub; +class _PlanRow extends StatelessWidget { + final _PlanItem item; final Color color; - const _EventCard(this.title, this.sub, this.color); + final bool showDivider; + + const _PlanRow({ + required this.item, + required this.color, + required this.showDivider, + }); + @override - Widget build(BuildContext c) => Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: color.withValues(alpha: 0.18), width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 14), child: Row( children: [ Container( - width: 34, - height: 34, + width: 8, + height: 8, decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), + color: color.withValues(alpha: 0.78), + shape: BoxShape.circle, ), - child: Icon(Icons.event_available, size: 18, color: color), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( - child: Text( - title, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + child: Container( + constraints: const BoxConstraints(minHeight: 48), + decoration: BoxDecoration( + border: showDivider + ? const Border( + bottom: BorderSide(color: AppColors.divider, width: 0.7), + ) + : null, + ), + padding: const EdgeInsets.fromLTRB(0, 12, 14, 12), + child: Row( + children: [ + Expanded( + child: Text( + item.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + if (item.trailing.isNotEmpty) ...[ + const SizedBox(width: 12), + Flexible( + child: Text( + item.trailing, + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ), + ], + ], + ), ), ), - Text( - sub, - style: const TextStyle(fontSize: 12, color: AppColors.textHint), - ), ], ), ); @@ -3565,29 +4233,17 @@ class _Legend extends StatelessWidget { Widget build(BuildContext c) => Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.12)), - ), - child: Row( - children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 5), - Text( - label, - style: TextStyle( - fontSize: 12, - color: color, - fontWeight: FontWeight.w700, - ), - ), - ], + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, ), ), ], @@ -4015,9 +4671,9 @@ class StaticTextPage extends ConsumerWidget { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, elevation: 0, - scrolledUnderElevation: 0.5, + scrolledUnderElevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( icon: const Icon(Icons.chevron_left, color: AppColors.textPrimary), diff --git a/health_app/lib/pages/report/ai_analysis_page.dart b/health_app/lib/pages/report/ai_analysis_page.dart index bb6154b..f3983a6 100644 --- a/health_app/lib/pages/report/ai_analysis_page.dart +++ b/health_app/lib/pages/report/ai_analysis_page.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../widgets/ai_content.dart'; +import '../../widgets/app_error_state.dart'; +import '../../widgets/common_widgets.dart'; import 'report_pages.dart'; /// AI 解读页:从服务器获取真实报告数据 @@ -26,14 +29,18 @@ class _AiAnalysisPageState extends ConsumerState { @override Widget build(BuildContext context) { - final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis)); - final reports = ref.watch(reportProvider.select((s) => s.reports)); + final reportState = ref.watch(reportProvider); + final analysis = reportState.currentAnalysis; + final reports = reportState.reports; final reportItem = reports.where((r) => r.id == widget.id).firstOrNull; - if (analysis == null) { + if (reportState.isLoadingDetail) { return GradientScaffold( appBar: AppBar( - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), title: const Text('报告解读'), ), body: const Center( @@ -42,10 +49,30 @@ class _AiAnalysisPageState extends ConsumerState { ); } + if (analysis == null) { + return GradientScaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), + title: const Text('报告解读'), + ), + body: AppErrorState( + title: '报告详情加载失败', + subtitle: reportState.detailError ?? '暂时无法读取这份报告', + onRetry: () => + ref.read(reportProvider.notifier).fetchReportDetail(widget.id), + ), + ); + } + final displayType = _displayName(analysis.reportType); final aiStatus = reportItem?.aiStatus ?? analysis.aiStatus; final reviewStatus = reportItem?.reviewStatus ?? analysis.reviewStatus; final fileUrl = reportItem?.fileUrl ?? analysis.fileUrl; + final fileType = reportItem?.type ?? analysis.fileType; + final isPdf = isPdfReport(fileType, fileUrl ?? ''); final isReviewed = reviewStatus == 'Reviewed'; final isAnalyzing = aiStatus == 'Analyzing'; final isFailed = aiStatus == 'Failed'; @@ -73,6 +100,10 @@ class _AiAnalysisPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (reportState.detailError != null) ...[ + _detailErrorBanner(reportState.detailError!), + const SizedBox(height: 12), + ], if (fileUrl != null && fileUrl.isNotEmpty) ...[ SizedBox( width: double.infinity, @@ -80,16 +111,26 @@ class _AiAnalysisPageState extends ConsumerState { onPressed: () => pushRoute( ref, 'reportOriginal', - params: {'url': fileUrl, 'title': displayType}, + params: { + 'url': fileUrl, + 'title': displayType, + 'fileType': fileType, + }, + ), + icon: Icon( + isPdf + ? Icons.picture_as_pdf_outlined + : Icons.image_outlined, + size: 18, ), - icon: const Icon(Icons.image_outlined, size: 18), label: const Text('查看原始报告'), style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primary, - side: const BorderSide(color: AppColors.primaryLight), + foregroundColor: AppColors.textPrimary, + backgroundColor: Colors.white, + side: const BorderSide(color: AppColors.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.mdBorder, ), ), ), @@ -99,17 +140,26 @@ class _AiAnalysisPageState extends ConsumerState { if (isAnalyzing || isFailed) ...[ _analysisStateCard( isFailed - ? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试') - : 'AI 正在分析报告,请稍后刷新查看', + ? (analysis.summary.isNotEmpty + ? analysis.summary + : 'AI 分析失败,请重新上传或稍后重试') + : (reportState.pollingTimedOut + ? '分析时间比预期更长,您可以稍后返回此页查看' + : 'AI 正在分析报告,请稍后刷新查看'), isFailed: isFailed, + busy: reportState.reanalyzingReportId == widget.id, onRetry: isFailed - ? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id) + ? () => ref + .read(reportProvider.notifier) + .reanalyzeReport(widget.id) : null, ), const SizedBox(height: 20), ], // 指标分析 - if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[ + if (!isAnalyzing && + !isFailed && + analysis.indicators.isNotEmpty) ...[ _sectionTitle('指标分析'), const SizedBox(height: 8), Container( @@ -147,8 +197,8 @@ class _AiAnalysisPageState extends ConsumerState { // 医生审核意见 _sectionTitle('医生审核意见'), const SizedBox(height: 8), - if (isReviewed) - _doctorReviewCard(reportItem!) + if (isReviewed && reportItem != null) + _doctorReviewCard(reportItem) else Container( padding: const EdgeInsets.all(16), @@ -171,7 +221,10 @@ class _AiAnalysisPageState extends ConsumerState { Spacer(), Text( 'AI 预解读', - style: TextStyle(fontSize: 14, color: AppColors.textHint), + style: TextStyle( + fontSize: 14, + color: AppColors.textHint, + ), ), ], ), @@ -184,19 +237,16 @@ class _AiAnalysisPageState extends ConsumerState { ); } - BoxDecoration _cardDeco() => BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, - ); + BoxDecoration _cardDeco() => + BoxDecoration(color: Colors.white, borderRadius: AppRadius.lgBorder); Widget _analysisStateCard( String message, { required bool isFailed, + bool busy = false, VoidCallback? onRetry, }) { - final color = isFailed ? AppColors.error : AppColors.primary; + final color = isFailed ? AppColors.errorText : AppColors.primary; return Container( padding: const EdgeInsets.all(16), decoration: _cardDeco(), @@ -217,7 +267,9 @@ class _AiAnalysisPageState extends ConsumerState { style: TextStyle( fontSize: 16, height: 1.6, - color: isFailed ? AppColors.error : AppColors.textPrimary, + color: isFailed + ? AppColors.errorText + : AppColors.textPrimary, fontWeight: FontWeight.w600, ), ), @@ -228,18 +280,10 @@ class _AiAnalysisPageState extends ConsumerState { const SizedBox(height: 14), SizedBox( width: double.infinity, - child: ElevatedButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('重新分析'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), + child: AppGradientOutlineButton( + label: busy ? '正在重新分析...' : '重新分析', + loading: busy, + onPressed: busy ? null : onRetry, ), ), ], @@ -248,6 +292,32 @@ class _AiAnalysisPageState extends ConsumerState { ); } + Widget _detailErrorBanner(String message) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: AppColors.errorText, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: const TextStyle( + color: AppColors.errorText, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + Widget _sectionTitle(String text) => Row( children: [ Container( @@ -272,9 +342,9 @@ class _AiAnalysisPageState extends ConsumerState { Widget _indicatorRow(Indicator ind) { final (color, icon) = switch (ind.status) { - 'high' => (AppColors.error, Icons.arrow_upward), - 'low' => (AppColors.warning, Icons.arrow_downward), - _ => (AppColors.success, Icons.check_circle), + 'high' => (AppColors.errorText, Icons.arrow_upward), + 'low' => (AppColors.warningText, Icons.arrow_downward), + _ => (AppColors.successText, Icons.check_circle), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -341,7 +411,7 @@ class _AiAnalysisPageState extends ConsumerState { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.cardInner, - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.mdBorder, ), child: Text( report.doctorComment!, @@ -360,7 +430,7 @@ class _AiAnalysisPageState extends ConsumerState { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.cardInner, - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.mdBorder, ), child: Text( report.doctorRecommendation!, diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 3144213..8b11755 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -6,15 +6,22 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; -import '../../core/api_client.dart' show baseUrl; +import '../../core/api_client.dart' show ApiException, baseUrl; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/common_widgets.dart'; import '../../widgets/enterprise_widgets.dart'; +import '../../widgets/app_error_state.dart'; +import '../../widgets/app_empty_state.dart'; +import '../../widgets/app_toast.dart'; + +const _reportPageColor = AppColors.report; +const _reportPageSoft = Color(0xFFF0F0FF); final reportProvider = NotifierProvider( ReportNotifier.new, @@ -27,12 +34,25 @@ Duration? reportAnalysisPollDelay(int attempt) { return const Duration(seconds: 12); } +bool isPdfReport(String fileType, String url) { + return fileType.toLowerCase() == 'pdf' || + Uri.tryParse(url)?.path.toLowerCase().endsWith('.pdf') == true; +} + class ReportState { final List reports; final String? uploadingImage; final bool isAnalyzing; final ReportAnalysis? currentAnalysis; final String? uploadError; + final bool isLoadingReports; + final bool isRefreshingReports; + final String? reportsError; + final String? deletingReportId; + final bool isLoadingDetail; + final String? detailError; + final String? reanalyzingReportId; + final bool pollingTimedOut; ReportState({ this.reports = const [], @@ -40,6 +60,14 @@ class ReportState { this.isAnalyzing = false, this.currentAnalysis, this.uploadError, + this.isLoadingReports = true, + this.isRefreshingReports = false, + this.reportsError, + this.deletingReportId, + this.isLoadingDetail = false, + this.detailError, + this.reanalyzingReportId, + this.pollingTimedOut = false, }); ReportState copyWith({ @@ -48,9 +76,21 @@ class ReportState { bool? isAnalyzing, ReportAnalysis? currentAnalysis, String? uploadError, + bool? isLoadingReports, + bool? isRefreshingReports, + String? reportsError, + String? deletingReportId, + bool? isLoadingDetail, + String? detailError, + String? reanalyzingReportId, + bool? pollingTimedOut, bool clearUploadingImage = false, bool clearCurrentAnalysis = false, bool clearUploadError = false, + bool clearReportsError = false, + bool clearDeletingReportId = false, + bool clearDetailError = false, + bool clearReanalyzingReportId = false, }) { return ReportState( reports: reports ?? this.reports, @@ -62,6 +102,20 @@ class ReportState { ? null : currentAnalysis ?? this.currentAnalysis, uploadError: clearUploadError ? null : uploadError ?? this.uploadError, + isLoadingReports: isLoadingReports ?? this.isLoadingReports, + isRefreshingReports: isRefreshingReports ?? this.isRefreshingReports, + reportsError: clearReportsError + ? null + : reportsError ?? this.reportsError, + deletingReportId: clearDeletingReportId + ? null + : deletingReportId ?? this.deletingReportId, + isLoadingDetail: isLoadingDetail ?? this.isLoadingDetail, + detailError: clearDetailError ? null : detailError ?? this.detailError, + reanalyzingReportId: clearReanalyzingReportId + ? null + : reanalyzingReportId ?? this.reanalyzingReportId, + pollingTimedOut: pollingTimedOut ?? this.pollingTimedOut, ); } } @@ -111,6 +165,7 @@ class ReportAnalysis { final String aiStatus; final String reviewStatus; final String? fileUrl; + final String fileType; ReportAnalysis({ required this.reportId, @@ -121,6 +176,7 @@ class ReportAnalysis { this.aiStatus = 'Analyzing', this.reviewStatus = 'Pending', this.fileUrl, + this.fileType = 'Image', }); } @@ -151,7 +207,19 @@ class ReportNotifier extends Notifier { return ReportState(); } - Future loadReports() async { + Future loadReports({bool refresh = false}) async { + if (refresh) { + _pollTimer?.cancel(); + _pollTimer = null; + _pollAttempt = 0; + } + final initialLoad = state.reports.isEmpty && !refresh; + state = state.copyWith( + isLoadingReports: initialLoad, + isRefreshingReports: !initialLoad, + clearReportsError: true, + pollingTimedOut: refresh ? false : state.pollingTimedOut, + ); try { final api = ref.read(apiClientProvider); final res = await api.get('/api/reports'); @@ -184,10 +252,20 @@ class ReportNotifier extends Notifier { reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''), ); }).toList(); - state = state.copyWith(reports: reports); + state = state.copyWith( + reports: reports, + isLoadingReports: false, + isRefreshingReports: false, + clearReportsError: true, + ); _syncAnalysisPolling(reports); } catch (e) { debugPrint('[Report] 加载报告列表失败: $e'); + state = state.copyWith( + isLoadingReports: false, + isRefreshingReports: false, + reportsError: _errorMessage(e, '报告加载失败,请检查网络后重试'), + ); } } @@ -197,24 +275,40 @@ class ReportNotifier extends Notifier { _pollTimer?.cancel(); _pollTimer = null; _pollAttempt = 0; + if (state.pollingTimedOut) { + state = state.copyWith(pollingTimedOut: false); + } return; } if (_pollTimer != null) return; final delay = reportAnalysisPollDelay(++_pollAttempt); - if (delay == null) return; + if (delay == null) { + if (!state.pollingTimedOut) { + state = state.copyWith(pollingTimedOut: true); + } + return; + } _pollTimer = Timer(delay, () { _pollTimer = null; loadReports(); }); } - void fetchReportDetail(String reportId) async { + Future fetchReportDetail(String reportId) async { + state = state.copyWith( + isLoadingDetail: true, + clearCurrentAnalysis: true, + clearDetailError: true, + ); try { final api = ref.read(apiClientProvider); final res = await api.get('/api/reports/$reportId'); final m = (res.data['data'] as Map?) ?? {}; if (m.isEmpty) { - state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId)); + state = state.copyWith( + isLoadingDetail: false, + detailError: '没有找到这份报告,请返回后刷新列表', + ); return; } final indicators = _parseIndicators(m['aiIndicators']?.toString()); @@ -232,23 +326,21 @@ class ReportNotifier extends Notifier { aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m), reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m), fileUrl: m['fileUrl']?.toString(), + fileType: m['fileType']?.toString() ?? 'Image', + ); + state = state.copyWith( + currentAnalysis: analysis, + isLoadingDetail: false, + clearDetailError: true, + ); + } catch (e) { + state = state.copyWith( + isLoadingDetail: false, + detailError: _errorMessage(e, '报告详情加载失败,请稍后重试'), ); - state = state.copyWith(currentAnalysis: analysis); - } catch (_) { - state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId)); } } - ReportAnalysis _emptyAnalysis(String reportId) => ReportAnalysis( - reportId: reportId, - reportType: '检查报告', - indicators: [], - summary: '暂无数据,请下拉刷新重试', - status: 'AnalysisFailed', - aiStatus: 'Failed', - reviewStatus: 'Pending', - ); - String _deriveAiStatus(Map m) { final status = m['status']?.toString(); if (status == 'Analyzing') return 'Analyzing'; @@ -278,7 +370,7 @@ class ReportNotifier extends Notifier { } } - void uploadImage(String path) async { + Future uploadImage(String path) async { state = state.copyWith( uploadingImage: path, isAnalyzing: true, @@ -310,7 +402,7 @@ class ReportNotifier extends Notifier { clearUploadingImage: true, clearUploadError: true, ); - loadReports(); + await loadReports(); } catch (e) { debugPrint('[Report] 上传失败: $e'); state = state.copyWith( @@ -321,63 +413,138 @@ class ReportNotifier extends Notifier { } } - void uploadFile(String path) => uploadImage(path); + Future uploadFile(String path) => uploadImage(path); void viewAnalysis(String reportId) { fetchReportDetail(reportId); } Future deleteReport(String id) async { + if (id.isEmpty || state.deletingReportId != null) return; + state = state.copyWith(deletingReportId: id); try { await ref.read(apiClientProvider).delete('/api/reports/$id'); - loadReports(); + final remaining = state.reports + .where((report) => report.id != id) + .toList(); + state = state.copyWith(reports: remaining, clearDeletingReportId: true); + _syncAnalysisPolling(remaining); } catch (e) { debugPrint('[Report] 删除失败: $e'); + state = state.copyWith(clearDeletingReportId: true); + throw Exception(_errorMessage(e, '删除失败,请稍后重试')); } } Future reanalyzeReport(String id) async { + if (state.reanalyzingReportId != null) return; + state = state.copyWith(reanalyzingReportId: id, clearDetailError: true); try { final res = await ref .read(apiClientProvider) .post('/api/reports/$id/reanalyze'); final data = res.data; if (data is Map && data['code'] != 0) { - state = state.copyWith( - uploadError: data['message']?.toString() ?? '重新分析失败', - ); - return; + throw Exception(data['message']?.toString() ?? '重新分析失败'); } state = state.copyWith( clearCurrentAnalysis: true, clearUploadError: true, ); await loadReports(); - fetchReportDetail(id); + await fetchReportDetail(id); } catch (e) { debugPrint('[Report] 重新分析失败: $e'); - state = state.copyWith(uploadError: '重新分析失败,请稍后重试'); + state = state.copyWith(detailError: _errorMessage(e, '重新分析失败,请稍后重试')); + } finally { + state = state.copyWith(clearReanalyzingReportId: true); } } void clearAnalysis() { - state = state.copyWith(clearCurrentAnalysis: true); + state = state.copyWith( + clearCurrentAnalysis: true, + clearDetailError: true, + isLoadingDetail: false, + ); + } + + String _errorMessage(Object error, String fallback) { + if (error is ApiException && error.message.trim().isNotEmpty) { + return error.message; + } + final raw = error.toString().replaceFirst('Exception: ', '').trim(); + return raw.isEmpty ? fallback : raw; } } /// 报告列表页 -class ReportListPage extends ConsumerWidget { - const ReportListPage({super.key}); +class ReportListPage extends ConsumerStatefulWidget { + final bool openUploadOnEnter; - static const _reportVisual = AppModuleVisuals.report; - static const _reportBlue = AppColors.report; - static const _reportCyan = Color(0xFF60A5FA); + const ReportListPage({super.key, this.openUploadOnEnter = false}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ReportListPageState(); +} + +class _ReportListPageState extends ConsumerState { + static const _reportVisual = AppModuleVisuals.report; + static const _reportBlue = _reportPageColor; + static const _reportAccent = _reportPageColor; + + @override + void initState() { + super.initState(); + if (widget.openUploadOnEnter) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showUploadOptions(context, ref); + }); + } + } + + Future _confirmDelete(ReportItem report) async { + if (ref.read(reportProvider).deletingReportId != null) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除报告'), + content: Text('确定删除“${report.title}”吗?删除后无法恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: AppColors.errorText), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + try { + await ref.read(reportProvider.notifier).deleteReport(report.id); + if (mounted) { + AppToast.show(context, '报告已删除', type: AppToastType.success); + } + } catch (error) { + if (mounted) { + AppToast.show( + context, + error.toString().replaceFirst('Exception: ', ''), + type: AppToastType.error, + ); + } + } + } + + @override + Widget build(BuildContext context) { final state = ref.watch(reportProvider); - if (state.isAnalyzing) { + if (state.isLoadingReports && state.reports.isEmpty) { return GradientScaffold( appBar: AppBar( leading: IconButton( @@ -386,21 +553,25 @@ class ReportListPage extends ConsumerWidget { ), title: const Text('报告管理'), ), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(color: _reportBlue), - const SizedBox(height: 16), - Text( - state.uploadingImage == null ? 'AI 正在分析报告...' : '正在上传报告...', - style: const TextStyle( - fontSize: 16, - color: AppColors.textSecondary, - ), - ), - ], + body: const Center( + child: CircularProgressIndicator(color: _reportBlue), + ), + ); + } + + if (state.reportsError != null && state.reports.isEmpty) { + return GradientScaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), ), + title: const Text('报告管理'), + ), + body: AppErrorState( + title: '报告加载失败', + subtitle: state.reportsError, + onRetry: () => ref.read(reportProvider.notifier).loadReports(), ), ); } @@ -416,32 +587,62 @@ class ReportListPage extends ConsumerWidget { ), floatingActionButton: _buildUploadButton(context, ref), body: RefreshIndicator( - onRefresh: () async => ref.read(reportProvider.notifier).loadReports(), + onRefresh: () => + ref.read(reportProvider.notifier).loadReports(refresh: true), child: ListView( padding: const EdgeInsets.all(16), children: [ + if (state.isRefreshingReports) ...[ + const LinearProgressIndicator( + minHeight: 2, + color: _reportBlue, + backgroundColor: Colors.transparent, + ), + const SizedBox(height: 10), + ], EnterpriseHeader( title: '报告处理概览', subtitle: '上传检查报告后自动进行 AI 结构化解读', icon: _reportVisual.icon, color: _reportBlue, - accent: _reportCyan, + accent: _reportAccent, showIcon: false, stats: [ EnterpriseStat( label: '报告总数', value: '${state.reports.length} 份', - icon: Icons.folder_copy_outlined, ), EnterpriseStat( - label: '待处理', + label: 'AI 分析中', value: - '${state.reports.where((r) => r.aiStatus == 'Analyzing' || r.reviewStatus != 'Reviewed').length} 份', - icon: Icons.pending_actions_outlined, + '${state.reports.where((r) => r.aiStatus == 'Analyzing').length} 份', + ), + EnterpriseStat( + label: '待医生审核', + value: + '${state.reports.where((r) => r.reviewStatus != 'Reviewed').length} 份', ), ], ), const SizedBox(height: 10), + if (state.isAnalyzing) ...[ + _buildInfoBanner( + state.uploadingImage == null ? '报告已上传,AI 正在分析' : '正在上传报告,请稍候', + Icons.cloud_upload_outlined, + ), + const SizedBox(height: 12), + ], + if (state.pollingTimedOut) ...[ + _buildInfoBanner( + '分析时间比预期更长,可稍后下拉刷新查看结果', + Icons.schedule_outlined, + ), + const SizedBox(height: 12), + ], + if (state.reportsError != null) ...[ + _buildUploadError(state.reportsError!), + const SizedBox(height: 12), + ], if (state.uploadError != null) ...[ _buildUploadError(state.uploadError!), const SizedBox(height: 12), @@ -454,18 +655,19 @@ class ReportListPage extends ConsumerWidget { for (var i = 0; i < state.reports.length; i++) SwipeDeleteTile( key: Key(state.reports[i].id), - onDelete: () => ref - .read(reportProvider.notifier) - .deleteReport(state.reports[i].id), + onDelete: () => _confirmDelete(state.reports[i]), onTap: () => pushRoute( ref, 'aiAnalysis', params: {'id': state.reports[i].id}, ), margin: EdgeInsets.zero, + borderRadius: BorderRadius.zero, + enabled: state.deletingReportId == null, child: _buildReportRow( state.reports[i], showDivider: i < state.reports.length - 1, + deleting: state.deletingReportId == state.reports[i].id, ), ), ], @@ -480,20 +682,46 @@ class ReportListPage extends ConsumerWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.error.withValues(alpha: 0.08), + color: AppColors.errorLight, borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.error.withValues(alpha: 0.22)), + border: Border.all(color: AppColors.errorText.withValues(alpha: 0.18)), ), child: Row( children: [ - const Icon(Icons.error_outline, color: AppColors.error, size: 20), + const Icon(Icons.error_outline, color: AppColors.errorText, size: 20), const SizedBox(width: 8), Expanded( child: Text( message, style: const TextStyle( fontSize: 14, - color: AppColors.error, + color: AppColors.errorText, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildInfoBanner(String message, IconData icon) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _reportBlue.withValues(alpha: 0.07), + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + Icon(icon, color: _reportBlue, size: 20), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, fontWeight: FontWeight.w600, ), ), @@ -504,11 +732,9 @@ class ReportListPage extends ConsumerWidget { } Widget _buildUploadButton(BuildContext context, WidgetRef ref) { - return FloatingActionButton( + return AppCreateFab( + tooltip: '上传健康报告', onPressed: () => _showUploadOptions(context, ref), - backgroundColor: _reportBlue, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: const Icon(Icons.add, size: 28, color: Colors.white), ); } @@ -516,20 +742,23 @@ class ReportListPage extends ConsumerWidget { showModalBottomSheet( context: context, backgroundColor: Colors.white, + showDragHandle: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), ), builder: (ctx) => SafeArea( + top: false, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Wrap( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ListTile( - leading: const Icon( - Icons.camera_alt_outlined, - color: _reportBlue, - ), - title: const Text('拍照上传', style: TextStyle(fontSize: 17)), + const Text('上传健康报告', style: AppTextStyles.sectionTitle), + const SizedBox(height: 10), + _ReportUploadOption( + icon: LucideIcons.camera, + label: '拍照上传', onTap: () async { Navigator.pop(ctx); final picker = ImagePicker(); @@ -542,12 +771,13 @@ class ReportListPage extends ConsumerWidget { } }, ), - ListTile( - leading: const Icon( - Icons.photo_library_outlined, - color: _reportCyan, - ), - title: const Text('从相册选择', style: TextStyle(fontSize: 17)), + const Padding( + padding: EdgeInsets.only(left: 50), + child: Divider(height: 1, color: AppColors.divider), + ), + _ReportUploadOption( + icon: LucideIcons.images, + label: '从相册选择', onTap: () async { Navigator.pop(ctx); final picker = ImagePicker(); @@ -560,12 +790,13 @@ class ReportListPage extends ConsumerWidget { } }, ), - ListTile( - leading: const Icon( - Icons.picture_as_pdf_outlined, - color: _reportBlue, - ), - title: const Text('上传 PDF', style: TextStyle(fontSize: 17)), + const Padding( + padding: EdgeInsets.only(left: 50), + child: Divider(height: 1, color: AppColors.divider), + ), + _ReportUploadOption( + icon: LucideIcons.fileText, + label: '上传 PDF', onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles( @@ -587,39 +818,19 @@ class ReportListPage extends ConsumerWidget { } Widget _buildEmptyState(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: _reportBlue.withValues(alpha: 0.08), - borderRadius: AppRadius.pillBorder, - ), - child: Icon(_reportVisual.icon, size: 44, color: _reportBlue), - ), - const SizedBox(height: 20), - const Text( - '暂无检查报告', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - const Text( - '点击右下角按钮上传报告', - style: TextStyle(fontSize: 16, color: AppColors.textHint), - ), - ], - ), + return AppEmptyState( + icon: _reportVisual.icon, + iconColor: _reportVisual.color, + title: '暂无检查报告', + subtitle: '点击右下角按钮上传报告', ); } - Widget _buildReportRow(ReportItem report, {required bool showDivider}) { + Widget _buildReportRow( + ReportItem report, { + required bool showDivider, + required bool deleting, + }) { final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title; @@ -663,6 +874,8 @@ class ReportListPage extends ConsumerWidget { _formatDate(report.uploadedAt), style: AppTextStyles.listSubtitle, ), + const SizedBox(height: 6), + _buildStatusBadges(report), if (report.aiStatus == 'Failed') ...[ const SizedBox(height: 4), Text( @@ -672,7 +885,7 @@ class ReportListPage extends ConsumerWidget { style: const TextStyle( fontSize: 13, height: 1.35, - color: AppColors.error, + color: AppColors.errorText, fontWeight: FontWeight.w600, ), ), @@ -681,13 +894,18 @@ class ReportListPage extends ConsumerWidget { ), ), const SizedBox(width: 8), - _buildStatusBadge(report), - const SizedBox(width: 8), - const Icon( - Icons.chevron_right, - size: 21, - color: AppColors.textHint, - ), + if (deleting) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon( + Icons.chevron_right, + size: 21, + color: AppColors.textHint, + ), ], ), ), @@ -706,31 +924,7 @@ class ReportListPage extends ConsumerWidget { ); } - Widget _buildStatusBadge(ReportItem report) { - final (label, bg, fg) = switch (report.status) { - _ when report.reviewStatus == 'Reviewed' => ( - '已审核', - AppColors.successLight, - AppColors.success, - ), - _ when report.aiStatus == 'Analyzing' => ( - '分析中', - _reportBlue.withValues(alpha: 0.08), - _reportBlue, - ), - _ when report.aiStatus == 'Failed' => ( - '分析失败', - AppColors.error.withValues(alpha: 0.08), - AppColors.error, - ), - _ when report.aiStatus == 'Succeeded' => ( - '待审核', - AppColors.warningLight, - AppColors.warning, - ), - _ => ('分析中', _reportBlue.withValues(alpha: 0.08), _reportBlue), - }; - + Widget _statusBadge(String label, Color bg, Color fg) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -745,6 +939,30 @@ class ReportListPage extends ConsumerWidget { ); } + Widget _buildStatusBadges(ReportItem report) { + final aiBadge = switch (report.aiStatus) { + 'Succeeded' => _statusBadge( + 'AI 已分析', + AppColors.successLight, + AppColors.successText, + ), + 'Failed' => _statusBadge( + 'AI 分析失败', + AppColors.error.withValues(alpha: 0.08), + AppColors.errorText, + ), + _ => _statusBadge( + 'AI 分析中', + _reportBlue.withValues(alpha: 0.08), + _reportBlue, + ), + }; + final reviewBadge = report.reviewStatus == 'Reviewed' + ? _statusBadge('医生已审核', AppColors.successLight, AppColors.successText) + : _statusBadge('待医生审核', AppColors.warningLight, AppColors.warningText); + return Wrap(spacing: 6, runSpacing: 4, children: [aiBadge, reviewBadge]); + } + String _failureSummary(ReportItem report) { final summary = report.analysisSummary?.trim(); if (summary != null && summary.isNotEmpty) { @@ -791,43 +1009,79 @@ String _catTitle(String c) => switch (c) { _ => '检查报告', }; -class ReportOriginalPage extends ConsumerWidget { +class ReportOriginalPage extends ConsumerStatefulWidget { final String url; final String title; + final String fileType; - const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'}); + const ReportOriginalPage({ + super.key, + required this.url, + this.title = '原始报告', + this.fileType = 'Image', + }); @override - Widget build(BuildContext context, WidgetRef ref) { - final imageUrl = _absoluteUrl(url); + ConsumerState createState() => _ReportOriginalPageState(); +} + +class _ReportOriginalPageState extends ConsumerState { + int _reloadToken = 0; + + @override + Widget build(BuildContext context) { + final rawUrl = widget.url.trim(); + final fileUrl = rawUrl.isEmpty ? null : _absoluteUrl(rawUrl); return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), - title: Text(title), + title: Text(widget.title), ), - body: Center( - child: InteractiveViewer( - minScale: 0.7, - maxScale: 4, - child: Image.network( - imageUrl, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => const Padding( - padding: EdgeInsets.all(24), - child: Text( - '原始报告图片加载失败', - style: TextStyle(color: AppColors.textSecondary), - ), - ), + body: fileUrl == null + ? AppErrorState(title: '无法打开原始报告', subtitle: '报告文件地址为空,请返回后刷新列表') + : isPdfReport(widget.fileType, fileUrl) + ? _buildPdf(fileUrl) + : _buildImage(fileUrl), + ); + } + + Widget _buildImage(String imageUrl) { + return Center( + child: InteractiveViewer( + minScale: 0.7, + maxScale: 4, + child: Image.network( + imageUrl, + key: ValueKey('$imageUrl-$_reloadToken'), + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center( + child: CircularProgressIndicator(color: AppColors.report), + ); + }, + errorBuilder: (_, _, _) => AppErrorState( + title: '原始报告加载失败', + subtitle: '请检查网络后重试', + onRetry: () => setState(() => _reloadToken++), ), ), ), ); } + Widget _buildPdf(String _) { + return AppEmptyState( + icon: AppModuleVisuals.report.icon, + iconColor: AppModuleVisuals.report.color, + title: '暂不支持预览 PDF', + subtitle: '当前版本优先支持图片报告,PDF 预览将在后续版本接入', + ); + } + String _absoluteUrl(String value) { if (value.startsWith('http://') || value.startsWith('https://')) { return value; @@ -838,3 +1092,55 @@ class ReportOriginalPage extends ConsumerWidget { return '$baseUrl/$value'; } } + +class _ReportUploadOption extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _ReportUploadOption({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: AppRadius.mdBorder, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 13), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: _reportPageSoft, + borderRadius: AppRadius.smBorder, + ), + child: Icon(icon, size: 20, color: _reportPageColor), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + const Icon( + LucideIcons.chevronRight, + size: 18, + color: AppColors.textHint, + ), + ], + ), + ), + ); + } +} diff --git a/health_app/lib/pages/settings/notification_prefs_logic.dart b/health_app/lib/pages/settings/notification_prefs_logic.dart new file mode 100644 index 0000000..4c9f1c3 --- /dev/null +++ b/health_app/lib/pages/settings/notification_prefs_logic.dart @@ -0,0 +1,107 @@ +enum HealthReminderMetric { bloodPressure, heartRate, glucose, spo2, weight } + +extension HealthReminderMetricX on HealthReminderMetric { + String get label => switch (this) { + HealthReminderMetric.bloodPressure => '血压', + HealthReminderMetric.heartRate => '心率', + HealthReminderMetric.glucose => '血糖', + HealthReminderMetric.spo2 => '血氧', + HealthReminderMetric.weight => '体重', + }; + + String get backendField => switch (this) { + HealthReminderMetric.bloodPressure => 'healthRecordReminderBloodPressure', + HealthReminderMetric.heartRate => 'healthRecordReminderHeartRate', + HealthReminderMetric.glucose => 'healthRecordReminderGlucose', + HealthReminderMetric.spo2 => 'healthRecordReminderSpO2', + HealthReminderMetric.weight => 'healthRecordReminderWeight', + }; +} + +class NotificationPrefs { + final bool pushEnabled; + final bool medicationReminder; + final bool followUpReminder; + final bool doctorReply; + final bool abnormalAlert; + final bool dndEnabled; + final int dndStartMinutes; + final int dndEndMinutes; + final bool healthRecordReminder; + final Set enabledHealthMetrics; + + const NotificationPrefs({ + required this.pushEnabled, + required this.medicationReminder, + required this.followUpReminder, + required this.doctorReply, + required this.abnormalAlert, + required this.dndEnabled, + required this.dndStartMinutes, + required this.dndEndMinutes, + required this.healthRecordReminder, + required this.enabledHealthMetrics, + }); + + factory NotificationPrefs.fromJson(Map json) { + final metrics = {}; + for (final metric in HealthReminderMetric.values) { + if ((json[metric.backendField] as bool?) ?? true) metrics.add(metric); + } + return NotificationPrefs( + pushEnabled: (json['pushEnabled'] as bool?) ?? true, + medicationReminder: (json['medicationReminder'] as bool?) ?? true, + followUpReminder: (json['followUpReminder'] as bool?) ?? true, + doctorReply: (json['doctorReply'] as bool?) ?? false, + abnormalAlert: (json['abnormalAlert'] as bool?) ?? true, + dndEnabled: (json['dndEnabled'] as bool?) ?? false, + dndStartMinutes: (json['dndStartMinutes'] as num?)?.toInt() ?? 22 * 60, + dndEndMinutes: (json['dndEndMinutes'] as num?)?.toInt() ?? 8 * 60, + healthRecordReminder: (json['healthRecordReminder'] as bool?) ?? true, + enabledHealthMetrics: Set.unmodifiable(metrics), + ); + } +} + +class NotificationPrefsViewState { + final NotificationPrefs? prefs; + final bool loading; + final String? loadError; + final Set savingKeys; + + const NotificationPrefsViewState({ + this.prefs, + this.loading = false, + this.loadError, + this.savingKeys = const {}, + }); + + NotificationPrefsViewState copyWith({ + NotificationPrefs? prefs, + bool? loading, + String? loadError, + bool clearLoadError = false, + Set? savingKeys, + }) => NotificationPrefsViewState( + prefs: prefs ?? this.prefs, + loading: loading ?? this.loading, + loadError: clearLoadError ? null : loadError ?? this.loadError, + savingKeys: savingKeys ?? this.savingKeys, + ); +} + +String healthMetricSummary(Set metrics) { + if (metrics.isEmpty) return '至少选择一项'; + if (metrics.length == HealthReminderMetric.values.length) return '全部指标'; + return HealthReminderMetric.values + .where(metrics.contains) + .map((metric) => metric.label) + .join('、'); +} + +Map healthMetricUpdatePayload( + Set metrics, +) => { + for (final metric in HealthReminderMetric.values) + metric.backendField: metrics.contains(metric), +}; diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index 899db1a..f006b87 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -1,149 +1,103 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../core/api_client.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; - -// ── 通知偏好状态 ── -// 持久化到后端 /api/notification-prefs,启动时自动拉取一次。 +import '../../widgets/app_error_state.dart'; +import '../../widgets/app_toast.dart'; +import 'notification_prefs_logic.dart'; final notificationPrefsProvider = - NotifierProvider>( + NotifierProvider( NotificationPrefsNotifier.new, ); -class NotificationPrefsNotifier extends Notifier> { - static const _defaults = { - 'pushEnabled': true, - 'medication': true, - 'healthAlert': true, - 'followUp': true, - 'aiReply': false, - 'dndEnabled': false, - 'dndStartMinutes': 22 * 60, - 'dndEndMinutes': 8 * 60, - // 健康录入提醒——总开关 + 5 子项 - 'healthRecord': true, - 'healthRecord.bp': true, - 'healthRecord.hr': true, - 'healthRecord.glucose': true, - 'healthRecord.spo2': true, - 'healthRecord.weight': true, - }; - +class NotificationPrefsNotifier extends Notifier { @override - Map build() { - Future.microtask(_loadFromBackend); - return {..._defaults}; + NotificationPrefsViewState build() { + Future.microtask(load); + return const NotificationPrefsViewState(loading: true); } - Future _loadFromBackend() async { + Future load() async { + state = state.copyWith(loading: true, clearLoadError: true); try { - final api = ref.read(apiClientProvider); - final res = await api.get('/api/notification-prefs'); - final data = res.data['data'] as Map?; - 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, - 'aiReply': (data['doctorReply'] as bool?) ?? false, - 'healthRecord': (data['healthRecordReminder'] as bool?) ?? true, - 'healthRecord.bp': - (data['healthRecordReminderBloodPressure'] as bool?) ?? true, - 'healthRecord.hr': - (data['healthRecordReminderHeartRate'] as bool?) ?? true, - 'healthRecord.glucose': - (data['healthRecordReminderGlucose'] as bool?) ?? true, - 'healthRecord.spo2': - (data['healthRecordReminderSpO2'] as bool?) ?? true, - 'healthRecord.weight': - (data['healthRecordReminderWeight'] as bool?) ?? true, - }; - } catch (_) { - // 网络异常时保留默认值 - } - } - - Future toggle(String key) async { - final newValue = !(state[key] ?? false); - state = {...state, key: newValue}; - await _pushToBackend(key, newValue); - } - - Future _pushToBackend(String key, bool value) async { - // 把前端 key 映射到后端字段名 - String? backendField = switch (key) { - 'medication' => 'medicationReminder', - 'healthAlert' => 'abnormalAlert', - 'followUp' => 'followUpReminder', - 'aiReply' => 'doctorReply', - 'healthRecord' => 'healthRecordReminder', - 'healthRecord.bp' => 'healthRecordReminderBloodPressure', - 'healthRecord.hr' => 'healthRecordReminderHeartRate', - 'healthRecord.glucose' => 'healthRecordReminderGlucose', - 'healthRecord.spo2' => 'healthRecordReminderSpO2', - 'healthRecord.weight' => 'healthRecordReminderWeight', - 'pushEnabled' => 'pushEnabled', - 'dndEnabled' => 'dndEnabled', - _ => null, - }; - if (backendField == null) return; - try { - final api = ref.read(apiClientProvider); - await api.put('/api/notification-prefs', data: {backendField: value}); - } catch (_) { - // 失败时回滚(避免界面与后端不同步) - state = {...state, key: !value}; - } - } - - Future setDndStart(TimeOfDay time) async { - await _setTime('dndStartMinutes', time.hour * 60 + time.minute); - } - - 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 + final response = await ref .read(apiClientProvider) - .put('/api/notification-prefs', data: {key: minutes}); - } catch (_) { - state = {...state, key: previous}; + .get('/api/notification-prefs'); + final data = response.data['data']; + if (data is! Map) throw const ApiException('服务器返回的通知设置无效'); + state = NotificationPrefsViewState( + prefs: NotificationPrefs.fromJson(data), + ); + } catch (error) { + state = NotificationPrefsViewState(loadError: _message(error)); } } -} -// ── 页面 ── + Future updateBool({ + required String operationKey, + required String backendField, + required bool value, + }) => _save(operationKey, {backendField: value}); + + Future setDndStart(TimeOfDay time) => + _save('dndStart', {'dndStartMinutes': time.hour * 60 + time.minute}); + + Future setDndEnd(TimeOfDay time) => + _save('dndEnd', {'dndEndMinutes': time.hour * 60 + time.minute}); + + Future saveHealthMetrics(Set metrics) { + if (metrics.isEmpty) return Future.value('至少选择一项提醒指标'); + return _save('healthMetrics', healthMetricUpdatePayload(metrics)); + } + + Future _save( + String operationKey, + Map payload, + ) async { + if (state.prefs == null || state.savingKeys.contains(operationKey)) { + return null; + } + final saving = {...state.savingKeys, operationKey}; + state = state.copyWith(savingKeys: saving); + try { + final response = await ref + .read(apiClientProvider) + .put('/api/notification-prefs', data: payload); + final data = response.data['data']; + if (data is! Map) throw const ApiException('服务器未返回最新通知设置'); + state = NotificationPrefsViewState( + prefs: NotificationPrefs.fromJson(data), + savingKeys: {...saving}..remove(operationKey), + ); + return null; + } catch (error) { + state = state.copyWith(savingKeys: {...saving}..remove(operationKey)); + return _message(error); + } + } + + String _message(Object error) => + error is ApiException ? error.message : '保存失败,请检查网络后重试'; +} class NotificationPrefsPage extends ConsumerWidget { const NotificationPrefsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final prefs = ref.watch(notificationPrefsProvider); - 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')}'; + final viewState = ref.watch(notificationPrefsProvider); return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.transparent, + backgroundColor: Colors.white, elevation: 0, leading: IconButton( icon: const Icon( @@ -152,7 +106,7 @@ class NotificationPrefsPage extends ConsumerWidget { ), onPressed: () => popRoute(ref), ), - title: Text( + title: const Text( '消息通知', style: TextStyle( fontSize: 21, @@ -162,246 +116,457 @@ class NotificationPrefsPage extends ConsumerWidget { ), centerTitle: true, ), - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── 推送总开关 ── - _SettingsListSection( - title: '推送通知', - children: [ - _SwitchTile( - title: '允许推送通知', - subtitle: '关闭后将不再收到任何系统推送', - value: prefs['pushEnabled'] ?? true, - showDivider: false, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('pushEnabled'), - ), - ], + body: viewState.loading + ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) + : viewState.loadError != null + ? AppErrorState( + title: '通知设置加载失败', + subtitle: viewState.loadError!, + onRetry: () => + ref.read(notificationPrefsProvider.notifier).load(), + ) + : _PreferencesContent( + prefs: viewState.prefs!, + savingKeys: viewState.savingKeys, ), - const SizedBox(height: 24), + ); + } +} - // ── 各类通知开关 ── - _SettingsListSection( - title: '通知类型', - children: [ - _SwitchTile( - icon: Icons.medication_rounded, - iconBg: AppColors.errorLight, - iconColor: AppColors.error, - title: '用药提醒', - subtitle: '服药时间到达时提醒您', - value: prefs['medication'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('medication'), - ), - _SwitchTile( - icon: Icons.warning_amber_rounded, - iconBg: AppColors.errorLight, - iconColor: AppTheme.error, - title: '健康异常提醒', - subtitle: '检测到数据异常时及时通知', - value: prefs['healthAlert'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthAlert'), - ), - _SwitchTile( - icon: Icons.event_available_rounded, - iconBg: AppColors.successLight, - iconColor: AppColors.success, - title: '复查日期提醒', - subtitle: '复查日前一天提醒您预约', - value: prefs['followUp'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('followUp'), - ), - _SwitchTile( - icon: Icons.forum_outlined, - iconBg: AppColors.iconBg, - iconColor: AppTheme.primary, - title: 'AI 回复通知', - subtitle: 'AI 助手回复时发送通知', - value: prefs['aiReply'] ?? false, - showDivider: false, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('aiReply'), - ), - ], - ), - const SizedBox(height: 24), +class _PreferencesContent extends ConsumerWidget { + final NotificationPrefs prefs; + final Set savingKeys; - // ── 健康录入提醒 ── - _SettingsListSection( - title: '健康录入提醒', - children: [ - _SwitchTile( - icon: Icons.alarm_on_rounded, - iconBg: const Color(0xFFFEF3C7), - iconColor: const Color(0xFFD97706), - title: '每日录入提醒', - subtitle: '每天上午提醒录入健康指标,已录入则不再打扰', - value: prefs['healthRecord'] ?? true, - showDivider: prefs['healthRecord'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthRecord'), - ), - if (prefs['healthRecord'] ?? true) ...[ - _SwitchTile( - title: '血压', - value: prefs['healthRecord.bp'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthRecord.bp'), - ), - _SwitchTile( - title: '心率', - value: prefs['healthRecord.hr'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthRecord.hr'), - ), - _SwitchTile( - title: '血糖', - value: prefs['healthRecord.glucose'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthRecord.glucose'), - ), - _SwitchTile( - title: '血氧', - value: prefs['healthRecord.spo2'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthRecord.spo2'), - ), - _SwitchTile( - title: '体重', - value: prefs['healthRecord.weight'] ?? true, - showDivider: false, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthRecord.weight'), - ), - ], - ], - ), - const SizedBox(height: 24), + const _PreferencesContent({required this.prefs, required this.savingKeys}); - // ── 免打扰时段 ── - _SettingsListSection( - title: '免打扰时段', - children: [ - _SwitchTile( - title: '开启免打扰模式', - subtitle: dndOn - ? '${formatMinutes(dndStart)} - ${formatMinutes(dndEnd)} 期间静音' - : '关闭后全天接收通知', - value: dndOn, - showDivider: dndOn, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('dndEnabled'), + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(notificationPrefsProvider.notifier); + String formatMinutes(int value) => + '${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}'; + + Future save(Future operation) async { + final error = await operation; + if (error != null && context.mounted) { + AppToast.show(context, error, type: AppToastType.error); + } + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingsListSection( + title: '推送通知', + children: [ + _SwitchTile( + title: '允许推送通知', + subtitle: '关闭后将不再收到任何系统通知', + value: prefs.pushEnabled, + saving: savingKeys.contains('pushEnabled'), + showDivider: false, + onChanged: savingKeys.contains('pushEnabled') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'pushEnabled', + backendField: 'pushEnabled', + value: value, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + _SettingsListSection( + title: '通知类型', + children: [ + _SwitchTile( + icon: AppModuleVisuals.medication.icon, + iconBg: AppModuleVisuals.medication.lightColor, + iconColor: AppModuleVisuals.medication.color, + title: '用药提醒', + subtitle: '服药时间到达时提醒您', + value: prefs.medicationReminder, + saving: savingKeys.contains('medication'), + onChanged: savingKeys.contains('medication') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'medication', + backendField: 'medicationReminder', + value: value, + ), + ), + ), + _SwitchTile( + icon: LucideIcons.triangleAlert, + iconBg: AppModuleVisuals.health.lightColor, + iconColor: AppModuleVisuals.health.color, + title: '健康异常提醒', + subtitle: '检测到数据异常时及时通知', + value: prefs.abnormalAlert, + saving: savingKeys.contains('healthAlert'), + onChanged: savingKeys.contains('healthAlert') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'healthAlert', + backendField: 'abnormalAlert', + value: value, + ), + ), + ), + _SwitchTile( + icon: AppModuleVisuals.followup.icon, + iconBg: AppModuleVisuals.followup.lightColor, + iconColor: AppModuleVisuals.followup.color, + title: '复查日期提醒', + subtitle: '复查日前提醒您安排就医', + value: prefs.followUpReminder, + saving: savingKeys.contains('followUp'), + onChanged: savingKeys.contains('followUp') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'followUp', + backendField: 'followUpReminder', + value: value, + ), + ), + ), + _SwitchTile( + icon: AppModuleVisuals.ai.icon, + iconBg: AppModuleVisuals.ai.lightColor, + iconColor: AppModuleVisuals.ai.color, + title: 'AI 回复通知', + subtitle: 'AI 助手回复时发送通知', + value: prefs.doctorReply, + saving: savingKeys.contains('aiReply'), + showDivider: false, + onChanged: savingKeys.contains('aiReply') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'aiReply', + backendField: 'doctorReply', + value: value, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + _SettingsListSection( + title: '健康录入提醒', + children: [ + _SwitchTile( + icon: LucideIcons.alarmClock, + iconBg: AppModuleVisuals.health.lightColor, + iconColor: AppModuleVisuals.health.color, + title: '每日录入提醒', + subtitle: '仅提醒当天尚未录入的健康指标', + value: prefs.healthRecordReminder, + saving: savingKeys.contains('healthRecord'), + onChanged: savingKeys.contains('healthRecord') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'healthRecord', + backendField: 'healthRecordReminder', + value: value, + ), + ), + ), + _ActionTile( + icon: LucideIcons.slidersHorizontal, + title: '提醒指标', + subtitle: healthMetricSummary(prefs.enabledHealthMetrics), + enabled: + prefs.healthRecordReminder && + !savingKeys.contains('healthMetrics'), + saving: savingKeys.contains('healthMetrics'), + onTap: () => _showHealthMetricSheet( + context, + prefs.enabledHealthMetrics, + notifier.saveHealthMetrics, ), - if (dndOn) - Container( - margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - decoration: BoxDecoration(color: AppTheme.surface), - child: Row( - children: [ - Expanded( - child: _TimeButton( - label: '开始', - time: formatMinutes(dndStart), - onTap: () async { - final picked = await showAppTimePicker( - context, - initialTime: TimeOfDay( - hour: dndStart ~/ 60, - minute: dndStart % 60, - ), - ); - if (picked != null && context.mounted) { - ref - .read(notificationPrefsProvider.notifier) - .setDndStart(picked); - } - }, + ), + ], + ), + const SizedBox(height: 24), + _SettingsListSection( + title: '免打扰时段', + children: [ + _SwitchTile( + title: '开启免打扰模式', + subtitle: prefs.dndEnabled + ? '${formatMinutes(prefs.dndStartMinutes)} - ${formatMinutes(prefs.dndEndMinutes)} 期间静音' + : '关闭后全天接收通知', + value: prefs.dndEnabled, + saving: savingKeys.contains('dndEnabled'), + showDivider: prefs.dndEnabled, + onChanged: savingKeys.contains('dndEnabled') + ? null + : (value) => save( + notifier.updateBool( + operationKey: 'dndEnabled', + backendField: 'dndEnabled', + value: value, + ), + ), + ), + if (prefs.dndEnabled) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + color: AppTheme.surface, + child: Row( + children: [ + Expanded( + child: _TimeButton( + label: '开始', + time: formatMinutes(prefs.dndStartMinutes), + saving: savingKeys.contains('dndStart'), + onTap: () async { + final picked = await showAppTimePicker( + context, + initialTime: TimeOfDay( + hour: prefs.dndStartMinutes ~/ 60, + minute: prefs.dndStartMinutes % 60, + ), + ); + if (picked != null && context.mounted) { + await save(notifier.setDndStart(picked)); + } + }, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + '~', + style: TextStyle( + fontSize: 19, + color: AppColors.textHint, ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - '~', - style: TextStyle( - fontSize: 19, - color: AppTheme.textHint, - ), - ), + ), + Expanded( + child: _TimeButton( + label: '结束', + time: formatMinutes(prefs.dndEndMinutes), + saving: savingKeys.contains('dndEnd'), + onTap: () async { + final picked = await showAppTimePicker( + context, + initialTime: TimeOfDay( + hour: prefs.dndEndMinutes ~/ 60, + minute: prefs.dndEndMinutes % 60, + ), + ); + if (picked != null && context.mounted) { + await save(notifier.setDndEnd(picked)); + } + }, ), - Expanded( - child: _TimeButton( - label: '结束', - time: formatMinutes(dndEnd), - onTap: () async { - final picked = await showAppTimePicker( - context, - initialTime: TimeOfDay( - hour: dndEnd ~/ 60, - minute: dndEnd % 60, - ), - ); - if (picked != null && context.mounted) { - ref - .read(notificationPrefsProvider.notifier) - .setDndEnd(picked); - } - }, - ), - ), - ], - ), + ), + ], ), - ], - ), - const SizedBox(height: 40), - ], - ), + ), + ], + ), + const SizedBox(height: 40), + ], ), ); } } -// ── 子组件 ── +Future _showHealthMetricSheet( + BuildContext context, + Set selected, + Future Function(Set) onSave, +) => showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _HealthMetricSheet(selected: selected, onSave: onSave), +); + +class _HealthMetricSheet extends StatefulWidget { + final Set selected; + final Future Function(Set) onSave; + + const _HealthMetricSheet({required this.selected, required this.onSave}); + + @override + State<_HealthMetricSheet> createState() => _HealthMetricSheetState(); +} + +class _HealthMetricSheetState extends State<_HealthMetricSheet> { + late final Set _draft = {...widget.selected}; + bool _saving = false; + String? _error; + + Future _save() async { + if (_draft.isEmpty) { + setState(() => _error = '至少选择一项提醒指标'); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final error = await widget.onSave(_draft); + if (!mounted) return; + if (error == null) { + Navigator.pop(context); + return; + } + setState(() { + _saving = false; + _error = error; + }); + } + + @override + Widget build(BuildContext context) => Container( + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + 18 + MediaQuery.viewInsetsOf(context).bottom, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 38, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: AppRadius.pillBorder, + ), + ), + ), + const SizedBox(height: 18), + const Text( + '选择提醒指标', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 5), + const Text( + '当天未录入已选指标时,小脉会发送提醒。', + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + for (final metric in HealthReminderMetric.values) + _MetricCheckbox( + metric: metric, + selected: _draft.contains(metric), + enabled: !_saving, + onChanged: (selected) { + setState(() { + selected ? _draft.add(metric) : _draft.remove(metric); + _error = null; + }); + }, + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.error, + ), + ), + ], + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: Text(_saving ? '保存中...' : '保存'), + ), + ), + ], + ), + ); +} + +class _MetricCheckbox extends StatelessWidget { + final HealthReminderMetric metric; + final bool selected; + final bool enabled; + final ValueChanged onChanged; + + const _MetricCheckbox({ + required this.metric, + required this.selected, + required this.enabled, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) => InkWell( + onTap: enabled ? () => onChanged(!selected) : null, + borderRadius: AppRadius.mdBorder, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Checkbox( + value: selected, + onChanged: enabled ? (value) => onChanged(value ?? false) : null, + ), + const SizedBox(width: 8), + Text( + metric.label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ); +} class _SectionTitle extends StatelessWidget { final String title; 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: AppColors.textSecondary, - ), + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 4, bottom: 10), + child: Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, ), - ); - } + ), + ); } class _SettingsListSection extends StatelessWidget { @@ -411,21 +576,19 @@ class _SettingsListSection extends StatelessWidget { const _SettingsListSection({required this.title, required this.children}); @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle(title: title), - ClipRRect( - borderRadius: AppRadius.mdBorder, - child: ColoredBox( - color: AppTheme.surface, - child: Column(children: children), - ), + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(title: title), + ClipRRect( + borderRadius: AppRadius.mdBorder, + child: ColoredBox( + color: AppTheme.surface, + child: Column(children: children), ), - ], - ); - } + ), + ], + ); } class _SwitchTile extends StatelessWidget { @@ -435,8 +598,9 @@ class _SwitchTile extends StatelessWidget { final String title; final String? subtitle; final bool value; + final bool saving; final bool showDivider; - final ValueChanged onChanged; + final ValueChanged? onChanged; const _SwitchTile({ this.icon, @@ -445,159 +609,247 @@ class _SwitchTile extends StatelessWidget { required this.title, this.subtitle, required this.value, + this.saving = false, this.showDivider = true, required this.onChanged, }); @override - Widget build(BuildContext context) { - final dense = icon == null && (subtitle == null || subtitle!.isEmpty); - return Container( - margin: EdgeInsets.zero, - decoration: BoxDecoration(color: AppTheme.surface), - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: 14, - vertical: dense ? 8 : 14, - ), - child: Row( - children: [ - if (icon != null) ...[ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: iconBg ?? AppColors.iconBg, - borderRadius: AppRadius.smBorder, - ), - child: Icon( - icon, - size: 20, - color: iconColor ?? AppColors.primary, - ), + Widget build(BuildContext context) => _SettingsRow( + icon: icon, + iconBg: iconBg, + iconColor: iconColor, + title: title, + subtitle: subtitle, + showDivider: showDivider, + trailing: saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _AppSwitch(value: value, onChanged: onChanged), + ); +} + +class _ActionTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final bool enabled; + final bool saving; + final VoidCallback onTap; + + const _ActionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.enabled, + required this.saving, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? onTap : null, + child: _SettingsRow( + icon: icon, + iconBg: AppColors.iconBg, + iconColor: AppColors.primary, + title: title, + subtitle: subtitle, + showDivider: false, + muted: !enabled, + trailing: saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + ), + ), + ), + ); +} + +class _SettingsRow extends StatelessWidget { + final IconData? icon; + final Color? iconBg; + final Color? iconColor; + final String title; + final String? subtitle; + final Widget trailing; + final bool showDivider; + final bool muted; + + const _SettingsRow({ + this.icon, + this.iconBg, + this.iconColor, + required this.title, + this.subtitle, + required this.trailing, + required this.showDivider, + this.muted = false, + }); + + @override + Widget build(BuildContext context) => Opacity( + opacity: muted ? 0.48 : 1, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + child: Row( + children: [ + if (icon != null) ...[ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: iconBg ?? AppColors.iconBg, + borderRadius: AppRadius.smBorder, ), - const SizedBox(width: 12), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: dense ? 15 : 16, - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - if (subtitle != null && subtitle!.isNotEmpty) - const SizedBox(height: 2), - if (subtitle != null && subtitle!.isNotEmpty) - Text( - subtitle!, - style: TextStyle( - fontSize: 13, - color: AppTheme.textSub, - ), - ), - ], + child: Icon( + icon, + size: 20, + color: iconColor ?? AppColors.primary, ), ), - _AppSwitch(value: value, onChanged: onChanged), + const SizedBox(width: 12), ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle?.isNotEmpty == true) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 10), + trailing, + ], + ), + ), + if (showDivider) + Padding( + padding: EdgeInsets.only(left: icon != null ? 60 : 14), + child: const Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), ), ), - if (showDivider) - Padding( - padding: EdgeInsets.only(left: icon != null ? 60 : 14), - child: const Divider( - height: 1, - thickness: 0.7, - color: Color(0xFFE8ECF2), - ), - ), - ], - ), - ); - } + ], + ), + ); } class _AppSwitch extends StatelessWidget { final bool value; - final ValueChanged onChanged; + final ValueChanged? onChanged; const _AppSwitch({required this.value, required this.onChanged}); @override - Widget build(BuildContext context) { - final trackColor = value ? AppColors.primary : Colors.white; - final borderColor = value - ? AppColors.primary.withValues(alpha: 0.38) - : AppColors.border; - return Semantics( - button: true, - toggled: value, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onChanged(!value), - child: AnimatedContainer( + Widget build(BuildContext context) => Semantics( + button: true, + toggled: value, + enabled: onChanged != null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onChanged == null ? null : () => onChanged!(!value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + width: 46, + height: 28, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: value ? AppColors.primary : Colors.white, + borderRadius: AppRadius.pillBorder, + border: Border.all( + color: value + ? AppColors.primary.withValues(alpha: 0.38) + : AppColors.border, + ), + ), + child: AnimatedAlign( duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, - width: 46, - height: 28, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: trackColor, - borderRadius: AppRadius.pillBorder, - border: Border.all(color: borderColor, width: 1), - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: value ? Colors.white : AppColors.textHint, - shape: BoxShape.circle, - ), + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: value ? Colors.white : AppColors.textHint, + shape: BoxShape.circle, ), ), ), ), - ); - } + ), + ); } class _TimeButton extends StatelessWidget { final String label; final String time; + final bool saving; final VoidCallback onTap; + const _TimeButton({ required this.label, required this.time, + required this.saving, required this.onTap, }); @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: AppColors.border), - borderRadius: AppRadius.mdBorder, - ), - child: Column( - children: [ - Text( - label, - style: TextStyle(fontSize: 14, color: AppTheme.textHint), - ), + Widget build(BuildContext context) => InkWell( + onTap: saving ? null : onTap, + borderRadius: AppRadius.mdBorder, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: AppRadius.mdBorder, + ), + child: Column( + children: [ + Text( + label, + style: const TextStyle(fontSize: 14, color: AppColors.textHint), + ), + if (saving) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else Text( time, style: const TextStyle( @@ -606,9 +858,8 @@ class _TimeButton extends StatelessWidget { color: AppTheme.primary, ), ), - ], - ), + ], ), - ); - } + ), + ); } diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index 0ddd538..f5af1bf 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -13,11 +13,11 @@ class SettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - backgroundColor: const Color(0xFFF6F8FC), + backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: Colors.white, elevation: 0, - scrolledUnderElevation: 0.5, + scrolledUnderElevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), @@ -33,105 +33,116 @@ class SettingsPage extends ConsumerWidget { ), centerTitle: true, ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SettingsGroup( - children: [ - _SettingsTile( - icon: LucideIcons.bluetooth, - title: '蓝牙设备', - onTap: () => pushRoute(ref, 'devices'), - ), - _SettingsTile( - icon: LucideIcons.bell, - title: '消息通知', - onTap: () => pushRoute(ref, 'notificationPrefs'), - ), - _SettingsTile( - icon: LucideIcons.info, - title: '关于小脉健康', - onTap: () => - pushRoute(ref, 'staticText', params: {'type': 'about'}), - ), - _SettingsTile( - icon: LucideIcons.shield, - title: '隐私协议', - onTap: () => pushRoute( - ref, - 'staticText', - params: {'type': 'privacy'}, + body: ColoredBox( + color: AppColors.background, + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 18, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SettingsGroup( + children: [ + _SettingsTile( + icon: LucideIcons.bluetooth, + title: '蓝牙设备', + onTap: () => pushRoute(ref, 'devices'), ), - ), - _SettingsTile( - icon: Icons.fact_check_outlined, - title: '个人信息收集清单', - onTap: () => pushRoute( - ref, - 'staticText', - params: {'type': 'personalInfoList'}, + _SettingsTile( + icon: LucideIcons.bell, + title: '消息通知', + onTap: () => pushRoute(ref, 'notificationPrefs'), ), - ), - _SettingsTile( - icon: Icons.hub_outlined, - title: '第三方 SDK 共享清单', - onTap: () => pushRoute( - ref, - 'staticText', - params: {'type': 'thirdPartySdkList'}, + _SettingsTile( + icon: LucideIcons.info, + title: '关于小脉健康', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'about'}, + ), + ), + _SettingsTile( + icon: LucideIcons.shield, + title: '隐私协议', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'privacy'}, + ), + ), + _SettingsTile( + icon: Icons.fact_check_outlined, + title: '个人信息收集清单', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'personalInfoList'}, + ), + ), + _SettingsTile( + icon: Icons.hub_outlined, + title: '第三方 SDK 共享清单', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'thirdPartySdkList'}, + ), + ), + _SettingsTile( + icon: Icons.description_outlined, + title: '服务协议', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'terms'}, + ), + ), + ], + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () => _deleteAccount(context, ref), + icon: const Icon(LucideIcons.trash, size: 19), + label: const Text('删除账号'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: BorderSide( + color: AppColors.error.withValues(alpha: 0.34), + ), + backgroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.lgBorder, ), - ), - _SettingsTile( - icon: Icons.description_outlined, - title: '服务协议', - onTap: () => - pushRoute(ref, 'staticText', params: {'type': 'terms'}), - ), - ], - ), - const SizedBox(height: 24), - OutlinedButton.icon( - onPressed: () => _deleteAccount(context, ref), - icon: const Icon(LucideIcons.trash, size: 19), - label: const Text('删除账号'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.error, - side: BorderSide( - color: AppColors.error.withValues(alpha: 0.34), - ), - backgroundColor: Colors.white, - minimumSize: const Size.fromHeight(52), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), ), ), - ), - const SizedBox(height: 12), - ElevatedButton.icon( - onPressed: () => _logout(context, ref), - icon: const Icon(LucideIcons.logOut, size: 19), - label: const Text('退出登录'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.error, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(52), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: () => _logout(context, ref), + icon: const Icon(LucideIcons.logOut, size: 19), + label: const Text('退出登录'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.textPrimary, + elevation: 0, + side: const BorderSide(color: AppColors.borderLight), + minimumSize: const Size.fromHeight(52), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.lgBorder, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index 4cd1f81..938dd62 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -11,6 +11,8 @@ class UserInfo { final String role; final String? name; final String? avatarUrl; + final String? gender; + final String? birthDate; UserInfo({ required this.id, @@ -18,6 +20,8 @@ class UserInfo { this.role = 'User', this.name, this.avatarUrl, + this.gender, + this.birthDate, }); } @@ -112,6 +116,8 @@ class AuthNotifier extends Notifier { role: user['role'] ?? state.user?.role ?? 'User', name: user['name'], avatarUrl: user['avatarUrl'], + gender: user['gender'], + birthDate: user['birthDate']?.toString(), ), ); } diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index c4bab0d..18c3032 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -40,31 +40,39 @@ enum ActiveAgent { exercise, } +const _keepConversationId = Object(); + class ChatState { final ActiveAgent activeAgent; final List messages; final String? conversationId; final bool isStreaming; final String? thinkingText; + final bool isViewingHistory; const ChatState({ this.activeAgent = ActiveAgent.default_, this.messages = const [], this.conversationId, this.isStreaming = false, this.thinkingText, + this.isViewingHistory = false, }); ChatState copyWith({ ActiveAgent? activeAgent, List? messages, - String? conversationId, + Object? conversationId = _keepConversationId, bool? isStreaming, String? thinkingText, + bool? isViewingHistory, }) => ChatState( activeAgent: activeAgent ?? this.activeAgent, messages: messages ?? this.messages, - conversationId: conversationId ?? this.conversationId, + conversationId: identical(conversationId, _keepConversationId) + ? this.conversationId + : conversationId as String?, isStreaming: isStreaming ?? this.isStreaming, thinkingText: thinkingText ?? this.thinkingText, + isViewingHistory: isViewingHistory ?? this.isViewingHistory, ); } @@ -77,6 +85,7 @@ class ChatNotifier extends Notifier { Completer? _streamDone; ActiveAgent? _lastTriggeredAgent; Timer? _agentTapLockTimer; + bool _loadingConversation = false; /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 /// 历史记录页一键清空 / 删除当前会话时调用。 @@ -162,42 +171,17 @@ class ChatNotifier extends Notifier { ); } - /// 根据 AI 调用的工具自动切换智能体胶囊 - void _switchAgentByTool(String tool) { - ActiveAgent? agent; - switch (tool) { - case 'record_health_data': - case 'query_health_records': - agent = ActiveAgent.health; - break; - case 'estimate_food_text': - agent = ActiveAgent.diet; - break; - case 'manage_medication': - agent = ActiveAgent.medication; - break; - case 'manage_exercise': - agent = ActiveAgent.exercise; - break; - case 'request_doctor': - agent = ActiveAgent.consultation; - break; - case 'analyze_report': - agent = ActiveAgent.report; - break; - } - if (agent != null) { - state = state.copyWith(activeAgent: agent); - } - } - Future loadConversation(String convId) async { + if (state.isStreaming) return '小脉正在回复,请稍后再切换对话'; + if (_loadingConversation) return '正在加载其他对话,请稍候'; + _loadingConversation = true; await _cancelActiveStream(); _cancelPendingAgentWelcome(); try { final api = ref.read(apiClientProvider); final res = await api.get('/api/ai/conversations/$convId'); final rawMessages = (res.data['data'] as List?) ?? []; + if (rawMessages.isEmpty) return '该对话已不存在,请刷新记录'; final messages = rawMessages.map((m) { final map = m as Map; final role = map['role']?.toString().toLowerCase() == 'user' @@ -218,14 +202,18 @@ class ChatNotifier extends Notifier { ); }).toList(); - state = state.copyWith( + state = ChatState( messages: messages, conversationId: convId, activeAgent: ActiveAgent.default_, + isViewingHistory: true, ); + _lastTriggeredAgent = null; return null; } catch (_) { return '会话加载失败,请稍后重试'; + } finally { + _loadingConversation = false; } } @@ -234,6 +222,7 @@ class ChatNotifier extends Notifier { /// 重复点击同一胶囊不重复弹卡片 void triggerAgent(ActiveAgent agent, String label) { if (_agentTapLockTimer != null || _lastTriggeredAgent == agent) return; + _resumeConversationFromHistory(); _lastTriggeredAgent = agent; final now = DateTime.now(); @@ -272,6 +261,7 @@ class ChatNotifier extends Notifier { if (!await file.exists()) return; _lastTriggeredAgent = null; _cancelPendingAgentWelcome(); + _resumeConversationFromHistory(); // 先显示用户消息(本地显示图片路径) final userMsg = ChatMessage( @@ -338,6 +328,7 @@ class ChatNotifier extends Notifier { if (!await file.exists()) return; _lastTriggeredAgent = null; _cancelPendingAgentWelcome(); + _resumeConversationFromHistory(); final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', @@ -392,6 +383,7 @@ class ChatNotifier extends Notifier { if (text.trim().isEmpty || state.isStreaming) return; _lastTriggeredAgent = null; _cancelPendingAgentWelcome(); + _resumeConversationFromHistory(); final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', @@ -470,6 +462,11 @@ class ChatNotifier extends Notifier { } } + void _resumeConversationFromHistory() { + if (!state.isViewingHistory) return; + state = state.copyWith(isViewingHistory: false); + } + Future _cancelActiveStream() async { await _subscription?.cancel(); _subscription = null; @@ -496,7 +493,10 @@ class ChatNotifier extends Notifier { final a = j['action'] as String?; switch (a) { case 'conversation_id': - state = state.copyWith(conversationId: j['data']?.toString()); + state = state.copyWith( + conversationId: j['data']?.toString(), + isViewingHistory: false, + ); case 'answer': final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); @@ -510,8 +510,6 @@ class ChatNotifier extends Notifier { state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': final tool = j['tool'] as String? ?? ''; - // 根据 AI 调用的工具自动切换智能体胶囊 - _switchAgentByTool(tool); if (tool == 'record_health_data') { ref.invalidate(latestHealthProvider); } diff --git a/health_app/lib/providers/consultation_provider.dart b/health_app/lib/providers/consultation_provider.dart index e8e320d..a324396 100644 --- a/health_app/lib/providers/consultation_provider.dart +++ b/health_app/lib/providers/consultation_provider.dart @@ -63,26 +63,26 @@ class ConsultationChatState { bool? isConnected, int? quotaRemaining, int? quotaTotal, - }) => - ConsultationChatState( - consultationId: consultationId ?? this.consultationId, - doctorId: doctorId ?? this.doctorId, - doctorName: doctorName ?? this.doctorName, - doctorTitle: doctorTitle ?? this.doctorTitle, - doctorDepartment: doctorDepartment ?? this.doctorDepartment, - status: status ?? this.status, - messages: messages ?? this.messages, - isLoading: isLoading ?? this.isLoading, - isSending: isSending ?? this.isSending, - isConnected: isConnected ?? this.isConnected, - quotaRemaining: quotaRemaining ?? this.quotaRemaining, - quotaTotal: quotaTotal ?? this.quotaTotal, - ); + }) => ConsultationChatState( + consultationId: consultationId ?? this.consultationId, + doctorId: doctorId ?? this.doctorId, + doctorName: doctorName ?? this.doctorName, + doctorTitle: doctorTitle ?? this.doctorTitle, + doctorDepartment: doctorDepartment ?? this.doctorDepartment, + status: status ?? this.status, + messages: messages ?? this.messages, + isLoading: isLoading ?? this.isLoading, + isSending: isSending ?? this.isSending, + isConnected: isConnected ?? this.isConnected, + quotaRemaining: quotaRemaining ?? this.quotaRemaining, + quotaTotal: quotaTotal ?? this.quotaTotal, + ); } final consultationChatProvider = NotifierProvider( - ConsultationChatNotifier.new); + ConsultationChatNotifier.new, + ); class ConsultationChatNotifier extends Notifier { HubConnection? _hub; @@ -108,7 +108,10 @@ class ConsultationChatNotifier extends Notifier { ); // 创建问诊会话 - final createRes = await api.post('/api/consultations', data: {'doctorId': doctorId}); + final createRes = await api.post( + '/api/consultations', + data: {'doctorId': doctorId}, + ); final consultationId = createRes.data['data']?['id']?.toString() ?? ''; // 加载历史消息 @@ -130,7 +133,10 @@ class ConsultationChatNotifier extends Notifier { isLoading: false, ); } else { - state = state.copyWith(consultationId: consultationId, isLoading: false); + state = state.copyWith( + consultationId: consultationId, + isLoading: false, + ); } // 建立 SignalR 连接 @@ -156,21 +162,26 @@ class ConsultationChatNotifier extends Notifier { if (msgConsultationId != consultationId) return; final msg = ConsultationMsg( - id: data['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), + id: + data['id']?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(), senderType: data['senderType']?.toString() ?? 'Ai', senderName: data['senderName']?.toString(), content: data['content']?.toString() ?? '', createdAt: data['createdAt'] != null - ? DateTime.tryParse(data['createdAt'].toString()) ?? DateTime.now() + ? DateTime.tryParse(data['createdAt'].toString()) ?? + DateTime.now() : DateTime.now(), ); // 去重:按 ID + 内容+时间窗口(防止本地消息和服务器消息重复) - final isDuplicate = state.messages.any((m) => - m.id == msg.id || - (m.senderType == msg.senderType && - m.content == msg.content && - msg.createdAt.difference(m.createdAt).inSeconds.abs() < 3)); + final isDuplicate = state.messages.any( + (m) => + m.id == msg.id || + (m.senderType == msg.senderType && + m.content == msg.content && + msg.createdAt.difference(m.createdAt).inSeconds.abs() < 3), + ); if (!isDuplicate) { state = state.copyWith(messages: [...state.messages, msg]); } @@ -191,14 +202,17 @@ class ConsultationChatNotifier extends Notifier { final res = await api.get('/api/doctors'); final list = (res.data['data'] as List?) ?? []; final doc = list.cast>().firstWhere( - (d) => d['id']?.toString() == doctorId, - orElse: () => {}); + (d) => d['id']?.toString() == doctorId, + orElse: () => {}, + ); state = state.copyWith( doctorName: doc['name']?.toString() ?? '', doctorTitle: doc['title']?.toString() ?? '', doctorDepartment: doc['department']?.toString() ?? '', ); - } catch (e) { log('[Consultation]请求失败: $e'); } + } catch (e) { + log('[Consultation]请求失败: $e'); + } } Future _loadMessages(String consultationId) async { @@ -213,17 +227,25 @@ class ConsultationChatNotifier extends Notifier { senderType: map['senderType']?.toString() ?? 'User', senderName: map['senderName']?.toString(), content: map['content']?.toString() ?? '', - createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(), + createdAt: + DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? + DateTime.now(), ); }).toList(); if (msgs.isNotEmpty) { state = state.copyWith(messages: msgs); } - } catch (e) { log('[Consultation]请求失败: $e'); } + } catch (e) { + log('[Consultation]请求失败: $e'); + } } Future sendMessage(String text) async { - if (text.trim().isEmpty || state.isSending || state.consultationId == null) return; + if (text.trim().isEmpty || + state.isSending || + state.consultationId == null) { + return; + } final localId = '${DateTime.now().millisecondsSinceEpoch}'; final userMsg = ConsultationMsg( @@ -240,8 +262,10 @@ class ConsultationChatNotifier extends Notifier { try { // 优先通过 SignalR 发送 if (_hub != null && _hub!.state == HubConnectionState.Connected) { - final result = await _hub!.invoke('SendMessage', - args: [state.consultationId!, 'User', '', text]); + final result = await _hub!.invoke( + 'SendMessage', + args: [state.consultationId!, 'User', '', text], + ); // 用服务器返回的真实 ID 更新本地消息,防止后续轮询重复 if (result != null) { final serverId = (result as Map)['id']?.toString(); @@ -264,8 +288,10 @@ class ConsultationChatNotifier extends Notifier { } else { // 回退到 HTTP final api = ref.read(apiClientProvider); - final res = await api.post('/api/consultations/${state.consultationId}/messages', - data: {'content': text}); + final res = await api.post( + '/api/consultations/${state.consultationId}/messages', + data: {'content': text}, + ); // 用服务器返回的真实 ID 更新本地消息 final dataMap = res.data['data'] as Map?; final serverId = dataMap?['id']?.toString(); @@ -296,7 +322,10 @@ class ConsultationChatNotifier extends Notifier { void _startPolling() { _pollTimer?.cancel(); - _pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollMessages()); + _pollTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => _pollMessages(), + ); } Future _pollMessages() async { @@ -325,7 +354,8 @@ class ConsultationChatNotifier extends Notifier { senderName: map['senderName']?.toString(), content: map['content']?.toString() ?? '', createdAt: - DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(), + DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? + DateTime.now(), ); }) .where((m) => !existingIds.contains(m.id)) @@ -334,7 +364,9 @@ class ConsultationChatNotifier extends Notifier { if (newMsgs.isNotEmpty) { state = state.copyWith(messages: [...state.messages, ...newMsgs]); } - } catch (e) { log('[Consultation]请求失败: $e'); } + } catch (e) { + log('[Consultation]请求失败: $e'); + } } void stop() { diff --git a/health_app/lib/providers/conversation_history_provider.dart b/health_app/lib/providers/conversation_history_provider.dart index ba20212..549f69b 100644 --- a/health_app/lib/providers/conversation_history_provider.dart +++ b/health_app/lib/providers/conversation_history_provider.dart @@ -35,7 +35,6 @@ class ConversationHistoryNotifier extends AsyncNotifier> { @override Future> build() { - ref.watch(chatProvider.select((state) => state.conversationId)); return _fetch(); } @@ -43,11 +42,9 @@ class ConversationHistoryNotifier final api = ref.read(apiClientProvider); final res = await api.get('/api/ai/conversations'); final raw = (res.data['data'] as List?) ?? const []; - final currentConversationId = ref.read(chatProvider).conversationId; return raw .whereType() .map((m) => ConversationListItem.fromJson(Map.from(m))) - .where((item) => item.id != currentConversationId) .toList(); } @@ -56,21 +53,16 @@ class ConversationHistoryNotifier state = await AsyncValue.guard(_fetch); } - /// 删除单条;先乐观更新 UI,失败回滚。 + /// 删除单条;后端确认成功后再更新 UI。 Future deleteOne(String id) async { final previous = state.asData?.value ?? const []; + final api = ref.read(apiClientProvider); + await api.delete('/api/ai/conversations/$id'); state = AsyncValue.data(previous.where((e) => e.id != id).toList()); - try { - final api = ref.read(apiClientProvider); - await api.delete('/api/ai/conversations/$id'); - } catch (_) { - state = AsyncValue.data(previous); - rethrow; - } // 同步清掉当前 chat state(如果删的就是当前会话) final chat = ref.read(chatProvider); if (chat.conversationId == id) { - ref.read(chatProvider.notifier).resetSession(); + await ref.read(chatProvider.notifier).resetSession(); } } @@ -79,7 +71,7 @@ class ConversationHistoryNotifier final api = ref.read(apiClientProvider); final res = await api.delete('/api/ai/conversations'); state = const AsyncValue.data([]); - ref.read(chatProvider.notifier).resetSession(); + await ref.read(chatProvider.notifier).resetSession(); final deleted = res.data['data']?['deleted']; return deleted is num ? deleted.toInt() : 0; } diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 64a5120..30cf6b0 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -43,9 +43,7 @@ final inAppNotificationServiceProvider = Provider(( return InAppNotificationService(ref.watch(apiClientProvider)); }); -final notificationUnreadCountProvider = FutureProvider.autoDispose(( - ref, -) async { +final notificationUnreadCountProvider = FutureProvider((ref) async { final history = await ref .watch(inAppNotificationServiceProvider) .getHistory(); @@ -53,14 +51,11 @@ final notificationUnreadCountProvider = FutureProvider.autoDispose(( }); /// 最新健康数据 Provider -final latestHealthProvider = FutureProvider.autoDispose>(( - ref, -) async { +final latestHealthProvider = FutureProvider>((ref) async { final service = ref.watch(healthServiceProvider); return service.getLatest(); }); - /// 用药列表 Provider final medicationListProvider = FutureProvider.autoDispose>>((ref) async { @@ -68,27 +63,50 @@ final medicationListProvider = return service.getList(); }); -final medicationReminderProvider = - FutureProvider.autoDispose>>((ref) async { - final service = ref.watch(medicationServiceProvider); - return service.getReminders(); - }); - -/// 医生列表 Provider -final doctorListProvider = FutureProvider>>(( +final medicationReminderProvider = FutureProvider>>(( ref, ) async { - final service = ref.watch(consultationServiceProvider); - return service.getDoctors().timeout(const Duration(seconds: 8)); + final service = ref.watch(medicationServiceProvider); + return service.getReminders(); }); -/// 当前运动计划 Provider -final currentExercisePlanProvider = - FutureProvider.autoDispose?>((ref) async { - final service = ref.watch(exerciseServiceProvider); - return service.getCurrentPlan().timeout(const Duration(seconds: 8)); +/// 医生列表 Provider +final doctorListProvider = + FutureProvider.autoDispose>>((ref) async { + final service = ref.watch(consultationServiceProvider); + return service.getDoctors().timeout(const Duration(seconds: 8)); }); +/// 当前运动计划 Provider +final currentExercisePlanProvider = FutureProvider?>(( + ref, +) async { + final service = ref.watch(exerciseServiceProvider); + return service.getCurrentPlan().timeout(const Duration(seconds: 8)); +}); + +typedef TodayHealthSnapshot = ({ + Map health, + List> reminders, + Map? exercisePlan, +}); + +/// 首页今日健康一次性快照。依赖项即使先后返回,也只在全部完成后整体更新。 +final todayHealthSnapshotProvider = FutureProvider(( + ref, +) async { + final values = await Future.wait([ + ref.watch(latestHealthProvider.future), + ref.watch(medicationReminderProvider.future), + ref.watch(currentExercisePlanProvider.future), + ]); + return ( + health: values[0] as Map, + reminders: values[1] as List>, + exercisePlan: values[2] as Map?, + ); +}); + /// 拍照/相册直接触发(无需跳转页面) final cameraActionProvider = NotifierProvider( CameraActionNotifier.new, diff --git a/health_app/lib/services/admin_service.dart b/health_app/lib/services/admin_service.dart index 037a74e..7971a8b 100644 --- a/health_app/lib/services/admin_service.dart +++ b/health_app/lib/services/admin_service.dart @@ -14,7 +14,10 @@ class AdminService { return res.data; } - Future> updateDoctor(String id, Map data) async { + Future> updateDoctor( + String id, + Map data, + ) async { final res = await _api.put('/api/admin/doctors/$id', data: data); return res.data; } @@ -29,7 +32,11 @@ class AdminService { return res.data; } - Future> getPatients({String? search, int page = 1, int pageSize = 20}) async { + Future> getPatients({ + String? search, + int page = 1, + int pageSize = 20, + }) async { final query = {'page': page, 'pageSize': pageSize}; if (search != null && search.isNotEmpty) query['search'] = search; final res = await _api.get('/api/admin/patients', queryParameters: query); diff --git a/health_app/lib/services/in_app_notification_service.dart b/health_app/lib/services/in_app_notification_service.dart index 0210435..55cacb8 100644 --- a/health_app/lib/services/in_app_notification_service.dart +++ b/health_app/lib/services/in_app_notification_service.dart @@ -78,6 +78,12 @@ class InAppNotificationService { .toList(); } + Future checkDue() async { + final response = await _api.post('/api/notifications/check-due'); + final data = response.data['data'] as Map? ?? const {}; + return (data['createdCount'] as num?)?.toInt() ?? 0; + } + Future acknowledge(String id) async { await _api.post('/api/notifications/$id/acknowledge'); } diff --git a/health_app/lib/utils/sse_handler.dart b/health_app/lib/utils/sse_handler.dart index 0f0d6b6..acbec57 100644 --- a/health_app/lib/utils/sse_handler.dart +++ b/health_app/lib/utils/sse_handler.dart @@ -14,10 +14,7 @@ class SseHandler { String? pdfUrl, required String token, }) { - final params = { - 'message': message, - 'token': token, - }; + final params = {'message': message, 'token': token}; if (conversationId != null) { params['conversationId'] = conversationId; } @@ -42,10 +39,12 @@ class SseHandler { String url, ) async { try { - final dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(minutes: 5), - )); + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(minutes: 5), + ), + ); final response = await dio.get( url, diff --git a/health_app/lib/widgets/ai_content.dart b/health_app/lib/widgets/ai_content.dart index ce5b4e3..cd1a890 100644 --- a/health_app/lib/widgets/ai_content.dart +++ b/health_app/lib/widgets/ai_content.dart @@ -62,7 +62,9 @@ class AiMarkdownView extends StatelessWidget { fontWeight: FontWeight.w900, ), listBulletPadding: const EdgeInsets.only(right: 8), - blockquote: AppTextStyles.chatBody.copyWith(color: AppColors.textSecondary), + blockquote: AppTextStyles.chatBody.copyWith( + color: AppColors.textSecondary, + ), ), ); } @@ -74,14 +76,6 @@ class AiGeneratedNote extends StatelessWidget { @override Widget build(BuildContext context) => const Padding( padding: EdgeInsets.only(top: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.auto_awesome_rounded, size: 15, color: AppColors.textHint), - SizedBox(width: 5), - Text('内容由 AI 生成', style: AppTextStyles.aiNote), - ], - ), + child: Text('内容由 AI 生成', style: AppTextStyles.aiNote), ); } - diff --git a/health_app/lib/widgets/app_empty_state.dart b/health_app/lib/widgets/app_empty_state.dart index a0aad20..5c2d8e5 100644 --- a/health_app/lib/widgets/app_empty_state.dart +++ b/health_app/lib/widgets/app_empty_state.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import '../core/app_colors.dart'; -import 'app_gradient_widgets.dart'; class AppEmptyState extends StatelessWidget { final IconData icon; final String title; final String? subtitle; final Widget? action; + final Color iconColor; + final Color? iconBackground; + final EdgeInsetsGeometry padding; const AppEmptyState({ super.key, @@ -14,32 +16,34 @@ class AppEmptyState extends StatelessWidget { required this.title, this.subtitle, this.action, + this.iconColor = AppColors.primaryDark, + this.iconBackground, + this.padding = const EdgeInsets.all(48), }); @override Widget build(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.all(48), + padding: padding, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 80, - height: 80, + key: const ValueKey('empty-state-icon-surface'), + width: 72, + height: 72, decoration: BoxDecoration( - gradient: AppColors.softGlassGradient, - borderRadius: BorderRadius.circular(40), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, + color: iconBackground ?? iconColor.withValues(alpha: 0.09), + shape: BoxShape.circle, ), - child: AppGradientIcon(icon: icon, size: 34), + child: Icon(icon, size: 32, color: iconColor), ), - const SizedBox(height: 18), + const SizedBox(height: 16), Text( title, style: const TextStyle( - fontSize: 18, + fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), diff --git a/health_app/lib/widgets/app_error_state.dart b/health_app/lib/widgets/app_error_state.dart index 5ce0103..27671ac 100644 --- a/health_app/lib/widgets/app_error_state.dart +++ b/health_app/lib/widgets/app_error_state.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../core/app_colors.dart'; -import 'app_gradient_widgets.dart'; +import 'common_widgets.dart'; /// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试", /// 避免把网络/服务失败误显示成"暂无数据"。 @@ -28,14 +28,13 @@ class AppErrorState extends StatelessWidget { width: 80, height: 80, decoration: BoxDecoration( - gradient: AppColors.softGlassGradient, - borderRadius: BorderRadius.circular(40), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, + color: AppColors.errorLight, + shape: BoxShape.circle, ), - child: const AppGradientIcon( - icon: Icons.cloud_off_outlined, - size: 34, + child: const Icon( + Icons.cloud_off_outlined, + size: 32, + color: AppColors.errorText, ), ), const SizedBox(height: 18), @@ -63,7 +62,10 @@ class AppErrorState extends StatelessWidget { const SizedBox(height: 20), SizedBox( width: 116, - child: AppGradientOutlineButton(label: '重试', onTap: onRetry!), + child: AppGradientOutlineButton( + label: '重试', + onPressed: onRetry!, + ), ), ], ], diff --git a/health_app/lib/widgets/app_future_view.dart b/health_app/lib/widgets/app_future_view.dart index 820da01..33c5328 100644 --- a/health_app/lib/widgets/app_future_view.dart +++ b/health_app/lib/widgets/app_future_view.dart @@ -29,10 +29,7 @@ class AppFutureView extends StatelessWidget { builder: (ctx, snap) { // 失败优先于其它判断,避免把错误显示成"暂无数据" if (snap.hasError) { - return AppErrorState( - title: errorTitle ?? '加载失败', - onRetry: onRetry, - ); + return AppErrorState(title: errorTitle ?? '加载失败', onRetry: onRetry); } // 首次加载、尚无数据 → 转圈 if (snap.connectionState == ConnectionState.waiting && !snap.hasData) { diff --git a/health_app/lib/widgets/app_toast.dart b/health_app/lib/widgets/app_toast.dart index 1a900d8..6de418d 100644 --- a/health_app/lib/widgets/app_toast.dart +++ b/health_app/lib/widgets/app_toast.dart @@ -34,11 +34,16 @@ class AppToast { minHeight: 42, maxWidth: MediaQuery.sizeOf(ctx).width * 0.82, ), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.lgBorder, - border: Border.all(color: visual.color.withValues(alpha: 0.20)), + border: Border.all( + color: visual.color.withValues(alpha: 0.20), + ), boxShadow: AppShadows.floating, ), child: Row( @@ -76,11 +81,19 @@ class AppToast { static ({IconData icon, Color color}) _visual(AppToastType type) { return switch (type) { - AppToastType.success => (icon: LucideIcons.circleCheck, color: AppColors.success), - AppToastType.error => (icon: LucideIcons.circleX, color: AppColors.error), - AppToastType.warning => (icon: LucideIcons.triangleAlert, color: AppColors.warning), + AppToastType.success => ( + icon: LucideIcons.circleCheck, + color: AppColors.successText, + ), + AppToastType.error => ( + icon: LucideIcons.circleX, + color: AppColors.errorText, + ), + AppToastType.warning => ( + icon: LucideIcons.triangleAlert, + color: AppColors.warningText, + ), AppToastType.info => (icon: LucideIcons.info, color: AppColors.health), }; } } - diff --git a/health_app/lib/widgets/common_widgets.dart b/health_app/lib/widgets/common_widgets.dart index 93ccef0..838c34d 100644 --- a/health_app/lib/widgets/common_widgets.dart +++ b/health_app/lib/widgets/common_widgets.dart @@ -1,12 +1,118 @@ import 'package:flutter/material.dart'; import '../core/app_colors.dart'; +import '../core/app_design_tokens.dart'; + +class AppGradientOutlineButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final bool loading; + + const AppGradientOutlineButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + }); + + @override + Widget build(BuildContext context) { + final enabled = onPressed != null && !loading; + return DecoratedBox( + decoration: BoxDecoration( + gradient: enabled ? AppColors.actionOutlineGradient : null, + color: enabled ? null : AppColors.borderLight, + borderRadius: AppRadius.mdBorder, + ), + child: Padding( + padding: const EdgeInsets.all(1.4), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.md - 1.4), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: enabled ? onPressed : null, + child: SizedBox( + height: 50, + child: Center( + child: loading + ? const SizedBox.square( + dimension: 19, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + label, + style: AppTextStyles.button.copyWith( + color: enabled + ? AppColors.primaryDark + : AppColors.textHint, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class AppCreateFab extends StatelessWidget { + final VoidCallback onPressed; + final String tooltip; + final IconData icon; + + const AppCreateFab({ + super.key, + required this.onPressed, + required this.tooltip, + this.icon = Icons.add_rounded, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: DecoratedBox( + key: const ValueKey('app-create-fab-border'), + decoration: BoxDecoration( + gradient: AppColors.actionOutlineGradient, + borderRadius: AppRadius.pillBorder, + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(1.5), + child: Material( + color: Colors.white, + borderRadius: AppRadius.pillBorder, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: SizedBox.square( + dimension: 53, + child: Icon(icon, color: AppColors.primaryDark, size: 27), + ), + ), + ), + ), + ), + ); + } +} class SwipeDeleteTile extends StatefulWidget { final Widget child; final VoidCallback onDelete; final VoidCallback? onTap; final EdgeInsetsGeometry margin; + final BorderRadiusGeometry borderRadius; + final bool enabled; const SwipeDeleteTile({ super.key, @@ -14,6 +120,8 @@ class SwipeDeleteTile extends StatefulWidget { required this.onDelete, this.onTap, this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + this.borderRadius = const BorderRadius.all(Radius.circular(16)), + this.enabled = true, }); @override @@ -27,59 +135,89 @@ class _SwipeDeleteTileState extends State static const _threshold = 24.0; void _onDragUpdate(DragUpdateDetails d) { + if (!widget.enabled) return; setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0)); } void _onDragEnd(DragEndDetails d) { + if (!widget.enabled) return; setState(() => _dx = _dx < -_threshold ? -_maxSlide : 0); } + void _close() { + if (_dx == 0) return; + setState(() => _dx = 0); + } + + void _delete() { + _close(); + widget.onDelete(); + } + @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, - ), + child: ClipRRect( + borderRadius: widget.borderRadius, + child: Stack( + children: [ + Positioned.fill(child: ColoredBox(color: AppColors.error)), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: isSwiped ? _close : widget.onTap, + onHorizontalDragUpdate: widget.enabled ? _onDragUpdate : null, + onHorizontalDragEnd: widget.enabled ? _onDragEnd : null, + child: Transform.translate( + offset: Offset(_dx, 0), + child: ColoredBox( + key: const ValueKey('swipe-delete-content-surface'), + color: Colors.white, + child: isSwiped + ? AbsorbPointer(child: widget.child) + : widget.child, ), ), ), - ), - 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, - ), - ), - ], + if (isSwiped) + Positioned.fill( + child: Align( + alignment: Alignment.centerRight, + child: Material( + color: Colors.transparent, + child: InkWell( + key: const ValueKey('swipe-delete-action'), + onTap: widget.enabled ? _delete : null, + child: const SizedBox( + width: _maxSlide, + height: double.infinity, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.delete_outline, + color: AppColors.textOnGradient, + size: 24, + ), + SizedBox(height: 2), + Text( + '删除', + style: TextStyle( + color: AppColors.textOnGradient, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), ), ); } diff --git a/health_app/lib/widgets/drawer_shell.dart b/health_app/lib/widgets/drawer_shell.dart index 7a97db5..2770018 100644 --- a/health_app/lib/widgets/drawer_shell.dart +++ b/health_app/lib/widgets/drawer_shell.dart @@ -8,7 +8,7 @@ class DrawerShell extends StatelessWidget { const DrawerShell({ super.key, required this.child, - this.widthFactor = 0.84, + this.widthFactor = 0.78, this.showBackground = true, }); diff --git a/health_app/lib/widgets/enterprise_widgets.dart b/health_app/lib/widgets/enterprise_widgets.dart index ee64f32..74af101 100644 --- a/health_app/lib/widgets/enterprise_widgets.dart +++ b/health_app/lib/widgets/enterprise_widgets.dart @@ -39,9 +39,7 @@ class EnterpriseHeader extends StatelessWidget { padding: AppSpacing.panel, decoration: BoxDecoration( color: Colors.white, - borderRadius: AppRadius.xlBorder, - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: AppShadows.soft, + borderRadius: AppRadius.lgBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -50,17 +48,13 @@ class EnterpriseHeader extends StatelessWidget { children: [ if (showIcon) ...[ Container( - width: 46, - height: 46, + width: 42, + height: 42, decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [color, accent], - ), - borderRadius: BorderRadius.circular(14), + color: color.withValues(alpha: 0.10), + borderRadius: AppRadius.smBorder, ), - child: Icon(icon, color: Colors.white, size: 25), + child: Icon(icon, color: color, size: 22), ), const SizedBox(width: 12), ], @@ -96,7 +90,6 @@ class EnterpriseHeader extends StatelessWidget { child: _HeaderStat( stat: stats[i], color: i.isEven ? color : accent, - accent: i.isEven ? accent : color, ), ), if (i != stats.length - 1) const SizedBox(width: 8), @@ -113,13 +106,8 @@ class EnterpriseHeader extends StatelessWidget { class _HeaderStat extends StatelessWidget { final EnterpriseStat stat; final Color color; - final Color accent; - const _HeaderStat({ - required this.stat, - required this.color, - required this.accent, - }); + const _HeaderStat({required this.stat, required this.color}); @override Widget build(BuildContext context) { @@ -127,16 +115,8 @@ class _HeaderStat extends StatelessWidget { constraints: const BoxConstraints(minHeight: 66), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - color.withValues(alpha: 0.10), - accent.withValues(alpha: 0.06), - ], - ), + color: const Color(0xFFF7F8FA), borderRadius: AppRadius.smBorder, - border: Border.all(color: AppColors.borderLight, width: 1.1), ), child: Row( children: [ diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 47de318..d9a9343 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/app_colors.dart'; import '../core/app_design_tokens.dart'; +import '../core/app_module_visuals.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; @@ -16,22 +17,13 @@ class HealthDrawer extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final auth = ref.watch(authProvider); - final latestHealth = ref.watch(latestHealthProvider); - return DrawerShell( widthFactor: 0.9, showBackground: false, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter( - child: _DrawerHero( - user: auth.user, - latestHealth: latestHealth, - ref: ref, - ), - ), + const SliverToBoxAdapter(child: _DrawerHeroConsumer()), SliverToBoxAdapter( child: Container( padding: const EdgeInsets.fromLTRB(14, 10, 14, 18), @@ -43,13 +35,7 @@ class HealthDrawer extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _NavigationSection(ref: ref), - const SizedBox(height: 14), - const Divider( - height: 1, - thickness: 0.7, - color: Color(0xFFE8ECF2), - ), - const SizedBox(height: 14), + const SizedBox(height: 22), _HistorySection(ref: ref), ], ), @@ -61,6 +47,17 @@ class HealthDrawer extends ConsumerWidget { } } +class _DrawerHeroConsumer extends ConsumerWidget { + const _DrawerHeroConsumer(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(authProvider.select((state) => state.user)); + final latestHealth = ref.watch(latestHealthProvider); + return _DrawerHero(user: user, latestHealth: latestHealth, ref: ref); + } +} + class _DrawerHero extends StatelessWidget { final dynamic user; final AsyncValue> latestHealth; @@ -413,7 +410,7 @@ class _NavigationSection extends StatelessWidget { colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)], ), _NavItem( - icon: LucideIcons.footprints, + icon: AppModuleVisuals.exercise.icon, title: '运动', route: 'exercisePlan', colors: AppColors.exerciseGradient.colors, @@ -429,7 +426,7 @@ class _NavigationSection extends StatelessWidget { return _LightSection( title: '常用功能', child: GridView.builder( - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(top: 3), itemCount: items.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -530,8 +527,13 @@ class _NavTile extends StatelessWidget { class _LightSection extends StatelessWidget { final String title; final Widget child; + final Widget? trailing; - const _LightSection({required this.title, required this.child}); + const _LightSection({ + required this.title, + required this.child, + this.trailing, + }); @override Widget build(BuildContext context) => Column( @@ -551,10 +553,11 @@ class _LightSection extends StatelessWidget { ), ), ), + ?trailing, ], ), ), - const SizedBox(height: 2), + const SizedBox(height: 8), child, ], ); @@ -711,7 +714,7 @@ class _HistorySection extends ConsumerStatefulWidget { final WidgetRef ref; const _HistorySection({required this.ref}); - static const int _previewCount = 5; + static const int _previewCount = 7; @override ConsumerState<_HistorySection> createState() => _HistorySectionState(); @@ -721,6 +724,9 @@ class _HistorySectionState extends ConsumerState<_HistorySection> { String? _selectedId; bool _selecting = false; bool _deleting = false; + bool _expanded = false; + bool _clearing = false; + String? _loadingConversationId; void _enterSelect(String id) { setState(() { @@ -750,9 +756,13 @@ class _HistorySectionState extends ConsumerState<_HistorySection> { @override Widget build(BuildContext context) { final async = ref.watch(conversationHistoryProvider); + final currentConversationId = ref.watch( + chatProvider.select((state) => state.conversationId), + ); return _LightSection( title: '对话记录', + trailing: _buildHeaderAction(context, async), child: async.when( loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 14), @@ -771,7 +781,10 @@ class _HistorySectionState extends ConsumerState<_HistorySection> { style: TextStyle(fontSize: 13, color: AppColors.textSecondary), ), ), - data: (list) { + data: (allItems) { + final list = allItems + .where((item) => item.id != currentConversationId) + .toList(); if (list.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), @@ -781,89 +794,140 @@ class _HistorySectionState extends ConsumerState<_HistorySection> { ), ); } - final preview = list.take(_HistorySection._previewCount).toList(); - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Stack( - clipBehavior: Clip.none, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < preview.length; i++) - _DrawerHistoryTile( - item: preview[i], - isLast: i == preview.length - 1, - selecting: _selecting, - selected: _selectedId == preview[i].id, - onTap: () async { - if (_selecting) { - _toggleSelect(preview[i].id); - return; - } - final error = await ref - .read(chatProvider.notifier) - .loadConversation(preview[i].id); - if (!context.mounted) return; - if (error != null) { - AppToast.show( - context, - error, - type: AppToastType.error, - ); - return; - } - Navigator.of(context).maybePop(); - }, - onLongPress: () => _enterSelect(preview[i].id), + final visible = _expanded + ? list + : list.take(_HistorySection._previewCount).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < visible.length; i++) + _DrawerHistoryTile( + item: visible[i], + selecting: _selecting, + selected: _selectedId == visible[i].id, + loading: _loadingConversationId == visible[i].id, + onTap: () => _openConversation(context, visible[i].id), + onLongPress: _loadingConversationId == null && !_deleting + ? () => _enterSelect(visible[i].id) + : null, + ), + if (list.length > _HistorySection._previewCount) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: _selecting + ? null + : () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, ), - if (list.length > _HistorySection._previewCount) ...[ - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: InkWell( - onTap: () { - Navigator.of(context).maybePop(); - pushRoute(ref, 'conversationHistory'); - }, - borderRadius: BorderRadius.circular(999), - child: const Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - child: Text( - '查看全部', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: AppColors.textSecondary, - ), - ), - ), + child: Text( + _expanded ? '收起' : '显示全部', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.textSecondary, ), ), - ], - ], - ), - if (_selecting && _selectedId != null) - Positioned( - top: -46, - right: 2, - child: _HistorySelectionBar( - deleting: _deleting, - onCancel: _exitSelect, - onDelete: () => _deleteSelected(context), ), ), + ), ], - ), + ], ); }, ), ); } + Widget? _buildHeaderAction( + BuildContext context, + AsyncValue> async, + ) { + if (_selecting && _selectedId != null) { + return _HistorySelectionBar( + deleting: _deleting, + onCancel: _exitSelect, + onDelete: () => _deleteSelected(context), + ); + } + final hasHistory = async.asData?.value.isNotEmpty == true; + if (!_expanded || !hasHistory) return null; + return TextButton( + onPressed: _clearing ? null : () => _confirmClearAll(context), + style: TextButton.styleFrom( + foregroundColor: AppColors.error, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + _clearing ? '清空中' : '清空全部', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ), + ); + } + + Future _openConversation(BuildContext context, String id) async { + if (_loadingConversationId != null || _deleting || _clearing) return; + if (_selecting) { + _toggleSelect(id); + return; + } + setState(() => _loadingConversationId = id); + final error = await ref.read(chatProvider.notifier).loadConversation(id); + if (!mounted) return; + setState(() => _loadingConversationId = null); + if (error != null) { + if (context.mounted) { + AppToast.show(context, error, type: AppToastType.error); + } + return; + } + if (context.mounted) Navigator.of(context).maybePop(); + } + + Future _confirmClearAll(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('清空全部对话'), + content: const Text('清空后无法恢复,确定继续吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('清空'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _clearing = true); + try { + final count = await ref + .read(conversationHistoryProvider.notifier) + .clearAll(); + if (!context.mounted) return; + setState(() => _expanded = false); + AppToast.show(context, '已清空 $count 条对话', type: AppToastType.success); + } catch (_) { + if (context.mounted) { + AppToast.show(context, '清空失败,请稍后重试', type: AppToastType.error); + } + } finally { + if (mounted) setState(() => _clearing = false); + } + } + Future _deleteSelected(BuildContext context) async { final id = _selectedId; if (id == null || _deleting) return; @@ -975,16 +1039,16 @@ class _HistoryActionButton extends StatelessWidget { class _DrawerHistoryTile extends StatelessWidget { final ConversationListItem item; - final bool isLast; final bool selecting; final bool selected; + final bool loading; final VoidCallback onTap; - final VoidCallback onLongPress; + final VoidCallback? onLongPress; const _DrawerHistoryTile({ required this.item, - required this.isLast, required this.selecting, required this.selected, + required this.loading, required this.onTap, required this.onLongPress, }); @@ -1025,23 +1089,24 @@ class _DrawerHistoryTile extends StatelessWidget { ), ), const SizedBox(width: 10), - Text( - _shortDate(item.updatedAt), - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.textHint, + if (loading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Text( + _shortDate(item.updatedAt), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), ), - ), ], ), ), - if (!isLast) - const Divider( - height: 1, - thickness: 0.7, - color: Color(0xFFE8ECF2), - ), ], ), ), diff --git a/health_app/test/ai_content_test.dart b/health_app/test/ai_content_test.dart new file mode 100644 index 0000000..f7405b0 --- /dev/null +++ b/health_app/test/ai_content_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/widgets/ai_content.dart'; + +void main() { + testWidgets('AI generated note shows text without an icon', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: AiGeneratedNote())), + ); + + expect(find.text('内容由 AI 生成'), findsOneWidget); + expect( + find.descendant( + of: find.byType(AiGeneratedNote), + matching: find.byType(Icon), + ), + findsNothing, + ); + }); +} diff --git a/health_app/test/app_router_test.dart b/health_app/test/app_router_test.dart index c234f03..43d40a4 100644 --- a/health_app/test/app_router_test.dart +++ b/health_app/test/app_router_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:health_app/core/app_router.dart'; import 'package:health_app/core/navigation_provider.dart'; +import 'package:health_app/pages/report/report_pages.dart'; void main() { testWidgets( @@ -24,4 +25,26 @@ void main() { expect(find.text('页面参数错误'), findsOneWidget); }, ); + + testWidgets('report upload route opens report manager upload options', ( + tester, + ) async { + late Widget page; + await tester.pumpWidget( + ProviderScope( + child: Consumer( + builder: (context, ref, _) { + page = buildPage( + const RouteInfo('reports', params: {'openUpload': 'true'}), + ref, + ); + return const MaterialApp(home: SizedBox()); + }, + ), + ), + ); + + expect(page, isA()); + expect((page as ReportListPage).openUploadOnEnter, isTrue); + }); } diff --git a/health_app/test/audit_fix_guardrails_test.dart b/health_app/test/audit_fix_guardrails_test.dart new file mode 100644 index 0000000..82ce615 --- /dev/null +++ b/health_app/test/audit_fix_guardrails_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/pages/history/conversation_history_page.dart'; +import 'package:health_app/pages/notifications/notification_center_page.dart'; + +void main() { + test('notification opening continues when acknowledge fails', () async { + var opened = false; + + final acknowledged = await acknowledgeBeforeNotificationOpen( + acknowledge: () async => throw Exception('offline'), + open: () => opened = true, + ); + + expect(acknowledged, isFalse); + expect(opened, isTrue); + }); + + test( + 'conversation dismiss is rejected when backend deletion fails', + () async { + expect( + await deleteConversationForDismiss( + () async => throw Exception('delete failed'), + ), + isFalse, + ); + expect(await deleteConversationForDismiss(() async {}), isTrue); + }, + ); + + test('doctor picker does not keep a stale list for the whole app session', () { + final source = File('lib/providers/data_providers.dart').readAsStringSync(); + final adminSource = File( + 'lib/pages/admin/admin_add_doctor_page.dart', + ).readAsStringSync(); + + expect( + source, + matches( + RegExp( + r'final doctorListProvider\s*=\s*FutureProvider\.autoDispose>>', + ), + ), + ); + expect(adminSource, contains('ref.invalidate(doctorListProvider)')); + }); + + test('home header buttons use a larger touch area', () { + final source = File('lib/pages/home/home_page.dart').readAsStringSync(); + final buttonSource = source.substring( + source.indexOf('class _HeaderIconButton'), + ); + + expect(buttonSource, contains('width: 44')); + expect(buttonSource, contains('height: 44')); + }); + + test('settings logout action is white with dark text', () { + final source = File( + 'lib/pages/settings/settings_pages.dart', + ).readAsStringSync(); + final logoutButtonStart = source.indexOf('ElevatedButton.icon('); + final logoutButtonEnd = source.indexOf( + 'Future _deleteAccount', + logoutButtonStart, + ); + final logoutButton = source.substring(logoutButtonStart, logoutButtonEnd); + + expect(logoutButton, contains('backgroundColor: Colors.white')); + expect(logoutButton, contains('foregroundColor: AppColors.textPrimary')); + }); +} diff --git a/health_app/test/care_plan_ui_logic_test.dart b/health_app/test/care_plan_ui_logic_test.dart new file mode 100644 index 0000000..3677f8a --- /dev/null +++ b/health_app/test/care_plan_ui_logic_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/pages/care_plan_ui_logic.dart'; + +void main() { + group('care plan lifecycle', () { + final today = DateTime(2026, 7, 13); + + test('separates active, upcoming, and ended records', () { + expect( + resolveCarePlanPhase( + startDate: '2026-07-01', + endDate: '2026-07-20', + today: today, + ), + CarePlanPhase.active, + ); + expect( + resolveCarePlanPhase( + startDate: '2026-07-14', + endDate: '2026-07-20', + today: today, + ), + CarePlanPhase.upcoming, + ); + expect( + resolveCarePlanPhase( + startDate: '2026-06-01', + endDate: '2026-07-12', + today: today, + ), + CarePlanPhase.ended, + ); + }); + + test('disabled medication is treated as ended', () { + expect( + resolveCarePlanPhase( + enabled: false, + startDate: '2026-07-01', + endDate: '2026-07-20', + today: today, + ), + CarePlanPhase.ended, + ); + }); + }); + + group('medication form validation', () { + test('requires dosage', () { + expect( + validateMedicationForm( + name: '阿司匹林', + dosage: '', + times: const ['08:00'], + startDate: DateTime(2026, 7, 13), + ), + '请输入服药剂量', + ); + }); + + test('rejects duplicate dose times', () { + expect( + validateMedicationForm( + name: '阿司匹林', + dosage: '100mg', + times: const ['08:00', '08:00'], + startDate: DateTime(2026, 7, 13), + ), + '服药时间不能重复', + ); + }); + + test('rejects an end date before the start date', () { + expect( + validateMedicationForm( + name: '阿司匹林', + dosage: '100mg', + times: const ['08:00'], + startDate: DateTime(2026, 7, 13), + endDate: DateTime(2026, 7, 12), + ), + '结束日期不能早于开始日期', + ); + }); + }); +} diff --git a/health_app/test/chat_provider_test.dart b/health_app/test/chat_provider_test.dart index 0a93c2b..7464419 100644 --- a/health_app/test/chat_provider_test.dart +++ b/health_app/test/chat_provider_test.dart @@ -1,8 +1,35 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:health_app/providers/chat_provider.dart'; void main() { + test('chat state can clear a loaded conversation id for a new chat', () { + const state = ChatState(conversationId: 'old-conversation'); + + final next = state.copyWith(conversationId: null); + + expect(next.conversationId, isNull); + }); + + test('history continues with the same conversation and visible context', () { + final chatSource = File( + 'lib/providers/chat_provider.dart', + ).readAsStringSync(); + + expect(chatSource, contains('conversationId: convId')); + expect( + chatSource, + isNot( + contains( + 'state = const ChatState();\n }\n\n' + ' Future _cancelActiveStream()', + ), + ), + ); + }); + test('delayed agent welcome is skipped after session reset', () async { final container = ProviderContainer(); addTearDown(container.dispose); diff --git a/health_app/test/diet_record_logic_test.dart b/health_app/test/diet_record_logic_test.dart index c4baae6..cdaa0b5 100644 --- a/health_app/test/diet_record_logic_test.dart +++ b/health_app/test/diet_record_logic_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:health_app/pages/diet/diet_record_logic.dart'; @@ -15,4 +16,15 @@ void main() { test('blank food draft is not valid for saving', () { expect(isSavableFood(name: '', portion: '', calories: 0), isFalse); }); + + test('diet row maps right swipe to edit and left swipe to delete', () { + expect( + dietRecordSwipeAction(DismissDirection.startToEnd), + DietRecordSwipeAction.edit, + ); + expect( + dietRecordSwipeAction(DismissDirection.endToStart), + DietRecordSwipeAction.delete, + ); + }); } diff --git a/health_app/test/home_message_order_test.dart b/health_app/test/home_message_order_test.dart new file mode 100644 index 0000000..35c9db2 --- /dev/null +++ b/health_app/test/home_message_order_test.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/pages/home/widgets/chat_messages_view.dart'; +import 'package:health_app/providers/chat_provider.dart'; + +void main() { + test('chat messages keep their natural display order', () { + final messages = [ + ChatMessage( + id: 'oldest', + role: 'user', + content: '第一条', + createdAt: DateTime(2026, 1, 1), + ), + ChatMessage( + id: 'newest', + role: 'assistant', + content: '最新一条', + createdAt: DateTime(2026, 1, 2), + ), + ]; + + expect(messageAtDisplayIndex(messages, 0).id, 'oldest'); + expect(messageAtDisplayIndex(messages, 1).id, 'newest'); + }); + + test('today health card stays in the normal message stream', () { + final taskCard = ChatMessage( + id: 'task_card', + role: 'assistant', + content: '', + createdAt: DateTime(2026, 1, 1), + type: MessageType.taskCard, + ); + final userMessage = ChatMessage( + id: 'user', + role: 'user', + content: '你好', + createdAt: DateTime(2026, 1, 2), + ); + + final messages = [taskCard, userMessage]; + + expect(messageAtDisplayIndex(messages, 0).id, 'task_card'); + expect(messageAtDisplayIndex(messages, 1).id, 'user'); + }); + + test('today health keeps one cached snapshot during background refresh', () { + final providers = File( + 'lib/providers/data_providers.dart', + ).readAsStringSync(); + final card = File( + 'lib/pages/home/widgets/chat_messages_view.dart', + ).readAsStringSync(); + final home = File('lib/pages/home/home_page.dart').readAsStringSync(); + + expect(providers, contains('todayHealthSnapshotProvider')); + expect( + providers, + isNot( + contains( + 'notificationUnreadCountProvider = FutureProvider.autoDispose', + ), + ), + ); + expect( + providers, + isNot( + contains( + 'medicationReminderProvider =\n FutureProvider.autoDispose', + ), + ), + ); + expect( + providers, + isNot( + contains( + 'currentExercisePlanProvider =\n FutureProvider.autoDispose', + ), + ), + ); + expect(card, contains('ref.watch(todayHealthSnapshotProvider)')); + expect(card, contains('final snapshot = snapshotAsync.value')); + expect(card, contains('正在获取今日健康')); + expect(home, contains('_refreshTodayHealthIfCached()')); + }); + + test('drawer keeps seven recent conversations and expands in place', () { + final source = File('lib/widgets/health_drawer.dart').readAsStringSync(); + + expect(source, contains('static const int _previewCount = 7;')); + expect(source, contains('bool _expanded = false;')); + expect(source, isNot(contains("pushRoute(ref, 'conversationHistory')"))); + }); + + test('history actions stay inside the section hit-test area', () { + final source = File('lib/widgets/health_drawer.dart').readAsStringSync(); + + expect(source, isNot(contains('top: -46'))); + expect(source, contains('trailing:')); + }); + + test('drawer sections use spacing without horizontal divider lines', () { + final source = File('lib/widgets/health_drawer.dart').readAsStringSync(); + + expect(source, isNot(contains('Divider('))); + expect(source, contains('const SizedBox(height: 22)')); + }); + + test('home drawer follows the native drag gesture', () { + final source = File('lib/pages/home/home_page.dart').readAsStringSync(); + + expect(source, contains('drawerEnableOpenDragGesture: true')); + expect(source, contains('drawerDragStartBehavior: DragStartBehavior.down')); + expect(source, contains('drawerEdgeDragWidth:')); + expect(source, isNot(contains('_drawerDragStartX'))); + expect(source, isNot(contains('onHorizontalDragUpdate:'))); + }); + + test('agent capsules use slightly larger icons and labels', () { + final source = File('lib/pages/home/home_page.dart').readAsStringSync(); + final cards = File( + 'lib/pages/home/widgets/chat_messages_view.dart', + ).readAsStringSync(); + final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync(); + + expect(source, contains('width: 18')); + expect(source, contains('height: 18')); + expect(source, contains('Icon(visual.icon, size: 13')); + expect(source, contains('fontSize: 15')); + expect( + source, + contains('selected ? AppColors.actionOutlineGradient : null'), + ); + expect(source, contains('selected ? null : Colors.transparent')); + expect(source, contains('color: Colors.white')); + expect(cards, isNot(contains('LucideIcons.footprints'))); + expect(cards, contains('AppModuleVisuals.exercise.icon')); + expect(drawer, contains('AppModuleVisuals.exercise.icon')); + }); + + test('upload and send actions stay inside one input capsule', () { + final source = File('lib/pages/home/home_page.dart').readAsStringSync(); + final input = source.substring( + source.indexOf('Widget _buildInputBar()'), + source.indexOf('void _showAttachmentPicker'), + ); + + expect(input, contains('borderRadius: AppRadius.pillBorder')); + expect(input, contains('LucideIcons.plus')); + expect(input, contains('LucideIcons.send')); + expect(input, contains('border: InputBorder.none')); + expect(input, contains('crossAxisAlignment: CrossAxisAlignment.center')); + expect( + RegExp('gradient: AppColors.primaryGradient').allMatches(input).length, + 2, + ); + expect(input, isNot(contains('Border.all(color: AppColors.borderLight)'))); + expect(source, isNot(contains('class _RoundToolButton'))); + }); +} diff --git a/health_app/test/login_flow_guardrails_test.dart b/health_app/test/login_flow_guardrails_test.dart new file mode 100644 index 0000000..d2ed718 --- /dev/null +++ b/health_app/test/login_flow_guardrails_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final loginSource = File('lib/pages/auth/login_page.dart').readAsStringSync(); + final appSource = File('lib/app.dart').readAsStringSync(); + + test('login validates fields before asking for agreement', () { + final submit = loginSource.substring( + loginSource.indexOf('Future _submit()'), + loginSource.indexOf('Future _showAgreementDialog()'), + ); + expect( + submit.indexOf("_error = '请输入手机号和验证码'"), + lessThan(submit.indexOf('_showAgreementDialog()')), + ); + }); + + test('registration keeps the authenticated session', () { + final submit = loginSource.substring( + loginSource.indexOf('Future _submit()'), + loginSource.indexOf('Future _showAgreementDialog()'), + ); + expect(submit, isNot(contains('authProvider.notifier).logout()'))); + }); + + test('boot gate stays visible until authenticated route changes', () { + expect(appSource, contains("currentRoute == 'login'")); + }); + + test('keyboard moves the foreground without resizing the background', () { + expect(loginSource, contains('resizeToAvoidBottomInset: false')); + expect(loginSource, contains('MediaQuery.viewInsetsOf(context).bottom')); + }); + + test('development sms code is only filled in debug builds', () { + expect(loginSource, contains('kDebugMode && result.devCode != null')); + }); +} diff --git a/health_app/test/medication_checkin_logic_test.dart b/health_app/test/medication_checkin_logic_test.dart new file mode 100644 index 0000000..4e1971f --- /dev/null +++ b/health_app/test/medication_checkin_logic_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/pages/medication/medication_checkin_page.dart'; + +void main() { + test('same-name medications remain separate check-in groups', () { + final groups = groupMedicationReminders([ + { + 'id': 'med-a', + 'name': '阿司匹林', + 'dosage': '100 mg', + 'scheduledTime': '08:00', + 'status': 'taken', + }, + { + 'id': 'med-b', + 'name': '阿司匹林', + 'dosage': '50 mg', + 'scheduledTime': '08:00', + 'status': 'upcoming', + }, + ]); + + expect(groups.keys, containsAll(['med-a', 'med-b'])); + expect(groups['med-a']!.single['status'], 'taken'); + expect(groups['med-b']!.single['status'], 'upcoming'); + }); +} diff --git a/health_app/test/medication_ui_logic_test.dart b/health_app/test/medication_ui_logic_test.dart new file mode 100644 index 0000000..439896e --- /dev/null +++ b/health_app/test/medication_ui_logic_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/pages/medication/medication_ui_logic.dart'; + +void main() { + test('medication list dates omit the current year', () { + final now = DateTime(2026, 7, 14); + + expect(formatMedicationPeriod('2026-07-14', null, now: now), '07.14 - 长期'); + expect( + formatMedicationPeriod('2026-07-13', '2026-07-14', now: now), + '07.13 - 07.14', + ); + }); + + test('medication list dates keep years when the range crosses years', () { + expect( + formatMedicationPeriod( + '2026-12-20', + '2027-01-15', + now: DateTime(2026, 7, 14), + ), + '2026.12.20 - 2027.01.15', + ); + }); + + test('form dates use hyphens and keep the year', () { + expect(formatMedicationFormDate(DateTime(2026, 7, 14)), '2026-07-14'); + }); +} diff --git a/health_app/test/notification_prefs_logic_test.dart b/health_app/test/notification_prefs_logic_test.dart new file mode 100644 index 0000000..eefa716 --- /dev/null +++ b/health_app/test/notification_prefs_logic_test.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/pages/settings/notification_prefs_logic.dart'; + +void main() { + test('notification preferences parse the complete backend payload', () { + final prefs = NotificationPrefs.fromJson({ + 'pushEnabled': false, + 'medicationReminder': false, + 'followUpReminder': true, + 'doctorReply': true, + 'abnormalAlert': false, + 'dndEnabled': true, + 'dndStartMinutes': 1200, + 'dndEndMinutes': 420, + 'healthRecordReminder': true, + 'healthRecordReminderBloodPressure': true, + 'healthRecordReminderHeartRate': false, + 'healthRecordReminderGlucose': true, + 'healthRecordReminderSpO2': false, + 'healthRecordReminderWeight': false, + }); + + expect(prefs.pushEnabled, false); + expect(prefs.doctorReply, true); + expect(prefs.dndStartMinutes, 1200); + expect(prefs.enabledHealthMetrics, { + HealthReminderMetric.bloodPressure, + HealthReminderMetric.glucose, + }); + }); + + test('metric summary remains compact and readable', () { + expect( + healthMetricSummary({ + HealthReminderMetric.bloodPressure, + HealthReminderMetric.heartRate, + }), + '血压、心率', + ); + expect(healthMetricSummary(HealthReminderMetric.values.toSet()), '全部指标'); + expect(healthMetricSummary({}), '至少选择一项'); + }); + + test('metric payload sends all five fields in one request', () { + final payload = healthMetricUpdatePayload({ + HealthReminderMetric.bloodPressure, + HealthReminderMetric.spo2, + }); + + expect(payload.length, 5); + expect(payload['healthRecordReminderBloodPressure'], true); + expect(payload['healthRecordReminderHeartRate'], false); + expect(payload['healthRecordReminderSpO2'], true); + }); + + test('notification settings uses a batch metric bottom sheet', () { + final source = File( + 'lib/pages/settings/notification_prefs_page.dart', + ).readAsStringSync(); + + expect(source, contains('showModalBottomSheet')); + expect(source, contains('saveHealthMetrics')); + expect(source, contains('提醒指标')); + }); +} diff --git a/health_app/test/prelaunch_guardrails_test.dart b/health_app/test/prelaunch_guardrails_test.dart index 85a7a40..464a04c 100644 --- a/health_app/test/prelaunch_guardrails_test.dart +++ b/health_app/test/prelaunch_guardrails_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:health_app/models/ble_device.dart'; import 'package:health_app/pages/chart/trend_page.dart'; @@ -11,6 +13,61 @@ void main() { expect(normalizeTrendMetricType('spo2'), 'spo2'); }); + test('trend period includes today and the requested preceding days', () { + final now = DateTime(2026, 7, 13, 18); + final records = [ + {'date': DateTime(2026, 7, 6, 23), 'value': 70}, + {'date': DateTime(2026, 7, 7, 0), 'value': 71}, + {'date': DateTime(2026, 7, 13, 8), 'value': 72}, + ]; + + final result = filterTrendRecordsForPeriod(records, 7, now); + + expect(result.map((record) => record['value']), [71, 72]); + }); + + test('trend values do not show an unnecessary decimal', () { + expect(formatTrendNumber(88.0), '88'); + expect(formatTrendNumber(88.5), '88.5'); + expect(formatTrendNumber(null), '--'); + }); + + test('blood pressure abnormal label identifies the affected value', () { + expect( + trendStatusLabel({ + 'systolic': 113, + 'diastolic': 97, + 'isAbnormal': true, + }, 'blood_pressure'), + '舒张压偏高', + ); + expect( + trendStatusLabel({ + 'systolic': 122, + 'diastolic': 89, + 'isAbnormal': false, + }, 'blood_pressure'), + '', + ); + }); + + test('calendar month switch keeps the selected day when possible', () { + expect( + calendarDateForMonth(DateTime(2026, 6), DateTime(2026, 7, 13)), + DateTime(2026, 6, 13), + ); + expect( + calendarDateForMonth(DateTime(2026, 2), DateTime(2026, 1, 31)), + DateTime(2026, 2, 28), + ); + }); + + test('health archive list input is normalized without conflicting none', () { + expect(normalizeArchiveItems('青霉素,海鲜\n花粉'), ['青霉素', '海鲜', '花粉']); + expect(normalizeArchiveItems('无'), ['无']); + expect(normalizeArchiveItems('无、青霉素'), ['青霉素']); + }); + test('static compliance pages include collection and sdk lists', () { expect(staticTextTitle('personalInfoList'), '个人信息收集清单'); expect(staticTextContent('personalInfoList'), contains('健康数据')); @@ -19,6 +76,19 @@ void main() { expect(staticTextContent('thirdPartySdkList'), contains('AI')); }); + test('all in-app compliance documents contain readable Chinese', () { + final source = File('lib/pages/remaining_pages.dart').readAsStringSync(); + final start = source.indexOf( + 'const Map _extraStaticTextTitles', + ); + expect(start, isNonNegative); + final complianceSection = source.substring(start); + + expect(complianceSection, isNot(contains('锛'))); + expect(complianceSection, isNot(contains('銆'))); + expect(complianceSection, isNot(contains('€'))); + }); + test('ble sync is only implemented for blood pressure devices', () { expect( HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure), diff --git a/health_app/test/profile_device_drawer_guardrails_test.dart b/health_app/test/profile_device_drawer_guardrails_test.dart new file mode 100644 index 0000000..5fe0d00 --- /dev/null +++ b/health_app/test/profile_device_drawer_guardrails_test.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/models/ble_device.dart'; +import 'package:health_app/pages/device/device_sync_ui_logic.dart'; + +void main() { + test('unsupported bound devices are labelled without pretending to sync', () { + expect(deviceSyncAvailabilityLabel(BleDeviceType.glucose), '暂不支持自动同步'); + expect(deviceSyncAvailabilityLabel(BleDeviceType.bloodPressure), isNull); + }); + + test('device sync errors keep connection and upload failures distinct', () { + expect( + deviceSyncErrorMessage( + const DeviceSyncFailure(DeviceSyncFailureStage.permission), + ), + contains('蓝牙权限'), + ); + expect( + deviceSyncErrorMessage( + const DeviceSyncFailure(DeviceSyncFailureStage.upload), + ), + contains('上传'), + ); + }); + + test('bound device page keeps the automatic scan indicator', () { + final page = File( + 'lib/pages/device/device_management_page.dart', + ).readAsStringSync(); + + expect(page, contains('AnimationController(')); + expect(page, contains('child: _ScanIndicator(')); + expect(page, contains('scanning: _scanning')); + }); + + test('profile exposes a real edit route and keeps phone read only', () { + final profile = File( + 'lib/pages/profile/profile_page.dart', + ).readAsStringSync(); + final editor = File( + 'lib/pages/profile/profile_edit_page.dart', + ).readAsStringSync(); + final router = File('lib/core/app_router.dart').readAsStringSync(); + + expect(profile, contains("pushRoute(ref, 'profileEdit')")); + expect(editor, contains('手机号不可修改')); + expect(router, contains("case 'profileEdit':")); + }); + + test( + 'drawer does not refetch history when only current conversation changes', + () { + final provider = File( + 'lib/providers/conversation_history_provider.dart', + ).readAsStringSync(); + final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync(); + + expect( + provider, + isNot( + contains( + 'ref.watch(chatProvider.select((state) => state.conversationId))', + ), + ), + ); + expect(drawer, contains('currentConversationId')); + }, + ); +} diff --git a/health_app/test/report_polling_test.dart b/health_app/test/report_polling_test.dart index 14d7feb..2b6dfe1 100644 --- a/health_app/test/report_polling_test.dart +++ b/health_app/test/report_polling_test.dart @@ -8,4 +8,15 @@ void main() { expect(reportAnalysisPollDelay(6), const Duration(seconds: 12)); expect(reportAnalysisPollDelay(16), isNull); }); + + test('report state starts in an explicit loading phase', () { + expect(ReportState().isLoadingReports, isTrue); + expect(ReportState().reportsError, isNull); + }); + + test('original report type prefers metadata and falls back to the url', () { + expect(isPdfReport('Pdf', '/uploads/report.bin'), isTrue); + expect(isPdfReport('Image', '/uploads/report.pdf'), isTrue); + expect(isPdfReport('Image', '/uploads/report.jpg'), isFalse); + }); } diff --git a/health_app/test/secondary_page_visuals_test.dart b/health_app/test/secondary_page_visuals_test.dart new file mode 100644 index 0000000..bfb6231 --- /dev/null +++ b/health_app/test/secondary_page_visuals_test.dart @@ -0,0 +1,212 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/core/app_colors.dart'; +import 'package:health_app/core/app_design_tokens.dart'; +import 'package:health_app/core/app_theme.dart'; +import 'package:health_app/widgets/common_widgets.dart'; +import 'package:health_app/widgets/app_empty_state.dart'; + +void main() { + testWidgets('secondary page separates neutral canvas from white app bar', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + home: GradientScaffold( + appBar: AppBar(title: const Text('用药管理')), + body: const SizedBox.expand(), + ), + ), + ); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, AppColors.background); + expect(AppTheme.lightTheme.appBarTheme.backgroundColor, Colors.white); + }); + + testWidgets('create fab uses one white gradient-outline visual', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: AppCreateFab(tooltip: '添加用药', onPressed: () {}), + ), + ), + ); + + final border = tester.widget( + find.byKey(const ValueKey('app-create-fab-border')), + ); + final decoration = border.decoration as BoxDecoration; + expect(decoration.gradient, AppColors.actionOutlineGradient); + expect(decoration.borderRadius, AppRadius.pillBorder); + expect(find.byIcon(Icons.add_rounded), findsOneWidget); + }); + + testWidgets('empty state uses a flat restrained icon surface', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppEmptyState( + icon: Icons.event_available_outlined, + title: '今日没有安排', + iconColor: AppColors.success, + ), + ), + ), + ); + + final surface = tester.widget( + find.byKey(const ValueKey('empty-state-icon-surface')), + ); + final decoration = surface.decoration as BoxDecoration; + expect(decoration.gradient, isNull); + expect(decoration.boxShadow, isNull); + }); + + test('health metrics and notification preferences use lucide icons', () { + final trend = File('lib/pages/chart/trend_page.dart').readAsStringSync(); + final notifications = File( + 'lib/pages/settings/notification_prefs_page.dart', + ).readAsStringSync(); + + expect(trend, contains('LucideIcons.gauge')); + expect(trend, contains('LucideIcons.heartPulse')); + expect(notifications, contains('AppModuleVisuals.medication.icon')); + expect(notifications, contains('LucideIcons.slidersHorizontal')); + }); + + test( + 'login and registration keep the image background with neutral forms', + () { + final login = File('lib/pages/auth/login_page.dart').readAsStringSync(); + + expect(login, contains("Image.asset(\n _loginBg")); + expect(login, contains('Colors.white.withValues(alpha: 0.88)')); + expect(login, contains('fillColor: AppColors.cardInner')); + expect(login, contains('showDragHandle: true')); + }, + ); + + test('today health special states do not use a filled warning row', () { + final homeCard = File( + 'lib/pages/home/widgets/chat_messages_view.dart', + ).readAsStringSync(); + + expect(homeCard, contains("'overdue': LucideIcons.circleAlert")); + expect(homeCard, contains('color: Colors.transparent')); + expect(homeCard, isNot(contains('isOverdue ? const Color(0xFFFFEBEE)'))); + expect(homeCard, contains('EdgeInsets.fromLTRB(14, 5, 14, 7)')); + expect(homeCard, contains('EdgeInsets.symmetric(vertical: 1)')); + }); + + test('active create and report states follow the shared visual system', () { + final remaining = File('lib/pages/remaining_pages.dart').readAsStringSync(); + final reports = File( + 'lib/pages/report/report_pages.dart', + ).readAsStringSync(); + + expect(remaining, contains("AppGradientOutlineButton(label: '保存计划'")); + expect(reports, contains('icon: LucideIcons.camera')); + expect(reports, contains('icon: LucideIcons.images')); + final pdfState = reports.substring( + reports.indexOf('Widget _buildPdf'), + reports.indexOf('String _absoluteUrl'), + ); + expect(pdfState, contains('return AppEmptyState(')); + expect(pdfState, contains('icon: AppModuleVisuals.report.icon')); + }); + + test('secondary module colors do not keep the old mixed palettes', () { + final diet = File( + 'lib/pages/diet/diet_nutrition_widgets.dart', + ).readAsStringSync(); + final remaining = File('lib/pages/remaining_pages.dart').readAsStringSync(); + final reports = File( + 'lib/pages/report/report_pages.dart', + ).readAsStringSync(); + final medication = File( + 'lib/pages/medication/medication_list_page.dart', + ).readAsStringSync(); + final checkin = File( + 'lib/pages/medication/medication_checkin_page.dart', + ).readAsStringSync(); + final exercise = File( + 'lib/pages/exercise/exercise_plan_page.dart', + ).readAsStringSync(); + + expect(diet, contains('static const primary = Color(0xFF6476E8)')); + expect(remaining, isNot(contains('AppModuleVisuals.diet.color'))); + expect(reports, contains('const _reportPageSoft = Color(0xFFF0F0FF)')); + expect(reports, isNot(contains('Color(0xFF60A5FA)'))); + expect(medication, contains('AppColors.blueMeasure')); + expect(checkin, contains('AppColors.blueMeasure')); + expect(exercise, contains('AppColors.blueMeasure')); + }); + + test('trend colors and status text colors stay visually distinct', () { + final colors = File('lib/core/app_colors.dart').readAsStringSync(); + final trend = File('lib/pages/chart/trend_page.dart').readAsStringSync(); + + expect(trend, contains('class _TrendColors')); + expect(trend, contains("'color': _TrendColors.bloodPressure")); + expect(trend, contains("'color': _TrendColors.heartRate")); + expect(trend, isNot(contains("'color': Color(0xFFEF4444)"))); + expect(trend, isNot(contains("'color': Color(0xFFF59E0B)"))); + expect(colors, contains('successText = Color(0xFF15803D)')); + expect(colors, contains('errorText = Color(0xFFDC2626)')); + expect(colors, contains('warningText = Color(0xFFB45309)')); + }); + + test('exercise rows use the shared leading icon list pattern', () { + final exercise = File( + 'lib/pages/exercise/exercise_plan_page.dart', + ).readAsStringSync(); + final row = exercise.substring( + exercise.indexOf('class _ExercisePlanRow'), + exercise.indexOf('class _CompactActionButton'), + ); + + expect(row, contains('AppModuleVisuals.exercise.icon')); + expect(row, contains('color: AppColors.exerciseLight')); + expect(row, contains('left: 68')); + expect(row, isNot(contains('width: 16'))); + final modules = File('lib/core/app_module_visuals.dart').readAsStringSync(); + expect(modules, contains('icon: Icons.directions_run_outlined')); + }); + + test( + 'report utility action and notification rows stay neutral and compact', + () { + final analysis = File( + 'lib/pages/report/ai_analysis_page.dart', + ).readAsStringSync(); + final notifications = File( + 'lib/pages/notifications/notification_center_page.dart', + ).readAsStringSync(); + + final originalReportAction = analysis.substring( + analysis.indexOf("label: const Text('查看原始报告')"), + analysis.indexOf("label: const Text('查看原始报告')") + 500, + ); + expect( + originalReportAction, + contains('foregroundColor: AppColors.textPrimary'), + ); + expect(originalReportAction, contains('color: AppColors.border')); + expect(notifications, contains('BoxConstraints(minHeight: 82)')); + expect(notifications, contains('width: 68')); + expect(notifications, contains("const TextSpan(text: ' 条未读消息')")); + expect( + notifications, + isNot(contains('color: AppColors.notificationLight')), + ); + }, + ); +} diff --git a/health_app/test/swipe_delete_tile_test.dart b/health_app/test/swipe_delete_tile_test.dart new file mode 100644 index 0000000..503e63a --- /dev/null +++ b/health_app/test/swipe_delete_tile_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/widgets/common_widgets.dart'; + +void main() { + testWidgets('swipe row paints an opaque white content surface', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeDeleteTile( + onDelete: () {}, + child: const SizedBox(height: 72, child: Text('用药一')), + ), + ), + ), + ); + + final surface = tester.widget( + find.byKey(const ValueKey('swipe-delete-content-surface')), + ); + expect(surface.color, Colors.white); + }); + + testWidgets('swipe reveals a dedicated delete action', (tester) async { + var deleteCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeDeleteTile( + onDelete: () => deleteCount++, + child: const SizedBox( + height: 72, + width: double.infinity, + child: Text('报告一'), + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('swipe-delete-action')), findsNothing); + await tester.drag(find.text('报告一'), const Offset(-100, 0)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('swipe-delete-action')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('swipe-delete-action'))); + await tester.pumpAndSettle(); + + expect(deleteCount, 1); + }); + + testWidgets('tapping an opened row closes it without deleting', ( + tester, + ) async { + var deleteCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SwipeDeleteTile( + onDelete: () => deleteCount++, + child: const SizedBox( + height: 72, + width: double.infinity, + child: Text('用药一'), + ), + ), + ), + ), + ); + + await tester.drag(find.text('用药一'), const Offset(-100, 0)); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(100, 40)); + await tester.pumpAndSettle(); + + expect(deleteCount, 0); + }); +}