feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端 - 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法 - 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展 - 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整 - 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景 - 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展 - AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理 - Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整 - BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑 - 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试 ## 前端 - UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新 - 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构 - 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取 - 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化 - 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化 - 趋势图: trend_page 大幅重构 - 登录: login_page 重构 - 个人资料: 新增 profile_edit_page; profile_page 优化 - 运动: 新增 exercise/ 目录 + care_plan_ui_logic - 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整 - 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化 - Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整 - AndroidManifest: 移除多余权限 - 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试 ## 文档 - 新增 ui-design-system.md 设计系统文档 - 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
1
audit/2026-07-13/window.xml
Normal file
1
audit/2026-07-13/window.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/01-current.xml
Normal file
1
audit/2026-07-14/01-current.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/02-drawer.xml
Normal file
1
audit/2026-07-14/02-drawer.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/03-health-archive.xml
Normal file
1
audit/2026-07-14/03-health-archive.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/04-reports.xml
Normal file
1
audit/2026-07-14/04-reports.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/05-medications.xml
Normal file
1
audit/2026-07-14/05-medications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/06-diet-records.xml
Normal file
1
audit/2026-07-14/06-diet-records.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/07-calendar.xml
Normal file
1
audit/2026-07-14/07-calendar.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/08-followups.xml
Normal file
1
audit/2026-07-14/08-followups.xml
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,3200]"><node index="0" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1440,302]"><node NAF="true" index="0" text="" resource-id="" class="android.widget.Button" package="com.datalumina.YYA" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[14,127][182,295]" /><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="复查随访" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,173][840,250]" /></node><node index="1" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="暂无随访安排" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[600,1816][840,1872]" /><node index="2" text="" resource-id="" class="android.view.View" package="com.datalumina.YYA" content-desc="医生创建随访后将显示在这里" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[401,1886][1039,1952]" /></node></node></node></node></node></node></node></node></hierarchy>
|
||||
1
audit/2026-07-14/09-exercise.xml
Normal file
1
audit/2026-07-14/09-exercise.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/10-devices.xml
Normal file
1
audit/2026-07-14/10-devices.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/11-notifications.xml
Normal file
1
audit/2026-07-14/11-notifications.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/12-settings.xml
Normal file
1
audit/2026-07-14/12-settings.xml
Normal file
File diff suppressed because one or more lines are too long
1
audit/2026-07-14/13-profile.xml
Normal file
1
audit/2026-07-14/13-profile.xml
Normal file
File diff suppressed because one or more lines are too long
261
audit/2026-07-14/project-audit-report.md
Normal file
261
audit/2026-07-14/project-audit-report.md
Normal file
@@ -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 签名和商店账号,因此只检查了当前准备缺口。
|
||||
1
audit/ui-pages/window.xml
Normal file
1
audit/ui-pages/window.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -35,7 +35,7 @@ public interface IAiConversationService
|
||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OpenConversationResult> 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<bool> 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<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||
|
||||
@@ -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})");
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
|
||||
IReadOnlyList<string> DietRestrictions,
|
||||
IReadOnlyList<string> ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
string? SurgeryHistoryStatus,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record HealthArchiveUpdateRequest(
|
||||
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||
string? SurgeryHistoryStatus = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
|
||||
@@ -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<HealthArchiveDto> 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<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||
|
||||
@@ -27,6 +27,7 @@ public interface IHealthRecordService
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
|
||||
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
|
||||
Guid userId,
|
||||
IReadOnlyList<HealthRecordUpsertRequest> 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<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
bool ClearEndDate = false);
|
||||
|
||||
public sealed record MedicationDto(
|
||||
Guid Id,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<bool> ProcessNextAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ReminderCatchUpResult(int CreatedCount);
|
||||
|
||||
public interface IReminderCatchUpService
|
||||
{
|
||||
Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
IReadOnlyList<string> ConversationMetadataJson);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||
@@ -26,6 +30,12 @@ public interface IUserService
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<AccountFileReferences> 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);
|
||||
}
|
||||
|
||||
@@ -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<UserProfileDto?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class HealthArchive
|
||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||
public List<string> 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!;
|
||||
|
||||
@@ -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}" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
}
|
||||
|
||||
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHealthArchiveSurgeryStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SurgeryHistoryStatus",
|
||||
table: "HealthArchives",
|
||||
type: "character varying(8)",
|
||||
maxLength: 8,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SurgeryHistoryStatus",
|
||||
table: "HealthArchives");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<DateOnly?>("SurgeryDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("SurgeryHistoryStatus")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("SurgeryType")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<HealthArchive>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.UserId).IsUnique();
|
||||
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||
});
|
||||
|
||||
builder.Entity<HealthArchiveSurgery>(e =>
|
||||
|
||||
@@ -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<ReminderCatchUpResult> 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<NotificationPreference> 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<int> 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<int> 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<int> 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<int> 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<int> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationMessage>(candidate.Payload)
|
||||
?? throw new InvalidOperationException("通知消息内容为空");
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var fileUrls = new List<string>();
|
||||
|
||||
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<string> values, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>(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<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,66 @@
|
||||
using Health.Application.Notifications;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 健康录入提醒服务——每天 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.
|
||||
/// </summary>
|
||||
public sealed class HealthRecordReminderService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<HealthRecordReminderService> _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<AppDbContext>();
|
||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// 一键清空当前用户的全部对话
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<object>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<CreateHealthRecordRequest> 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,
|
||||
|
||||
@@ -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<MedicationFrequency>(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<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>? Allergies, List<string>? DietRestrictions,
|
||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries,
|
||||
string? SurgeryHistoryStatus);
|
||||
|
||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||
|
||||
@@ -142,6 +142,7 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
|
||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
|
||||
builder.Services.AddScoped<IReportService, ReportService>();
|
||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||
@@ -149,6 +150,8 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||
|
||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
|
||||
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
@@ -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<string>();
|
||||
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<string>();
|
||||
var repository = new FakeUserRepository(new AccountFileReferences([], []), calls);
|
||||
var service = new UserService(repository, new FailingAccountFileCleanup(calls));
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() =>
|
||||
service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(["references", "files"], calls);
|
||||
}
|
||||
|
||||
private sealed class FakeUserRepository(
|
||||
AccountFileReferences references,
|
||||
List<string> calls) : IUserRepository
|
||||
{
|
||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => Task.FromResult<User?>(null);
|
||||
|
||||
public Task<AccountFileReferences> 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<string> 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<string> calls) : IAccountFileCleanup
|
||||
{
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
calls.Add("files");
|
||||
throw new IOException("file is locked");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<bool>), 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<HealthRecord> Added { get; } = [];
|
||||
public int SaveCount { get; private set; }
|
||||
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
||||
@@ -208,9 +302,9 @@ public sealed class ApplicationServiceTests
|
||||
RecordedAfter = recordedAfter;
|
||||
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||
}
|
||||
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<Conversation> conversations) : IAiConversationRepository
|
||||
{
|
||||
private readonly List<Conversation> _conversations = conversations;
|
||||
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
|
||||
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId));
|
||||
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
|
||||
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId));
|
||||
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<Conversation>>(_conversations
|
||||
.Where(item => item.UserId == userId)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ToList());
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task AddConversationAsync(Conversation value, CancellationToken ct)
|
||||
{
|
||||
_conversations.Add(value);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<int> 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<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
||||
|
||||
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
@@ -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<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([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; }
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 通知设置改造设计
|
||||
|
||||
## 目标
|
||||
|
||||
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
|
||||
|
||||
## 页面结构
|
||||
|
||||
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
|
||||
|
||||
健康录入提醒区域只展示:
|
||||
|
||||
- “每日健康录入提醒”总开关。
|
||||
- “提醒指标”入口,副标题展示当前已选指标摘要。
|
||||
|
||||
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
|
||||
|
||||
## 状态与保存
|
||||
|
||||
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
|
||||
|
||||
- 首次加载成功后才展示设置内容。
|
||||
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
|
||||
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
|
||||
- 保存失败恢复原值并显示错误提示。
|
||||
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
|
||||
- 免打扰时间采用相同的真实保存与失败恢复规则。
|
||||
|
||||
## 后端行为
|
||||
|
||||
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
|
||||
|
||||
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
|
||||
- 保存过程中禁止同一设置重复提交。
|
||||
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
|
||||
- 后端返回失败时不显示成功状态。
|
||||
|
||||
## 测试
|
||||
|
||||
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
|
||||
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
|
||||
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
|
||||
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。
|
||||
79
docs/ui-design-system.md
Normal file
79
docs/ui-design-system.md
Normal file
@@ -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. 逐页精修原则
|
||||
|
||||
- 每次只精修一个真实页面或一组完全同构页面。
|
||||
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
|
||||
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。
|
||||
@@ -8,7 +8,6 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
<application
|
||||
|
||||
@@ -48,6 +48,8 @@ final appReadyProvider = Provider<bool>((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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -168,4 +168,3 @@ class AppTextStyles {
|
||||
height: 1.25,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -45,8 +45,7 @@ Future<DateTime?> 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<DateTime?> 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,7 +96,9 @@ Future<DateTime?> showAppDatePicker(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
Expanded(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: yearCtrl,
|
||||
itemExtent: 40,
|
||||
@@ -97,9 +108,16 @@ Future<DateTime?> showAppDatePicker(
|
||||
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(
|
||||
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,
|
||||
@@ -109,15 +127,37 @@ Future<DateTime?> showAppDatePicker(
|
||||
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(
|
||||
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))],
|
||||
))),
|
||||
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<TimeOfDay?> 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<TimeOfDay?> showAppTimePicker(
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(flex: 1),
|
||||
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
|
||||
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(
|
||||
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))],
|
||||
))),
|
||||
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<DateTime?> 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<DateTime?> 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<DateTime?> showAppDateTimePicker(
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _wrapPicker(CupertinoPicker(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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))],
|
||||
))),
|
||||
children: [
|
||||
for (var i = 0; i <= 59; i++)
|
||||
Center(
|
||||
child: Text(
|
||||
i.toString().padLeft(2, '0'),
|
||||
style: _pickerItemStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -322,28 +469,47 @@ Future<int?> 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(
|
||||
child: _wrapPicker(
|
||||
CupertinoPicker(
|
||||
selectionOverlay: null,
|
||||
scrollController: FixedExtentScrollController(initialItem: selected - min),
|
||||
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)),
|
||||
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,
|
||||
|
||||
@@ -34,17 +34,19 @@ class LocalDatabase {
|
||||
|
||||
Future<void> 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<String?> 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?;
|
||||
}
|
||||
|
||||
@@ -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<AdminAddDoctorPage> {
|
||||
'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<AdminAddDoctorPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
|
||||
@@ -5,25 +5,32 @@ import '../../widgets/admin_drawer.dart';
|
||||
import 'admin_doctors_page.dart';
|
||||
import 'admin_patients_page.dart';
|
||||
|
||||
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(AdminPageNotifier.new);
|
||||
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(
|
||||
AdminPageNotifier.new,
|
||||
);
|
||||
|
||||
class AdminPageNotifier extends Notifier<String> {
|
||||
@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(),
|
||||
|
||||
@@ -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<LoginPage> {
|
||||
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<LoginPage> {
|
||||
}
|
||||
|
||||
Future<void> _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<LoginPage> {
|
||||
_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<LoginPage> {
|
||||
.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<LoginPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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<bool?> _showAgreementDialog() {
|
||||
@@ -152,10 +137,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
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<LoginPage> {
|
||||
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<LoginPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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<LoginPage> {
|
||||
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<LoginPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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<LoginPage> {
|
||||
),
|
||||
),
|
||||
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<LoginPage> {
|
||||
|
||||
@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,20 +388,26 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
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(
|
||||
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, 28, 20, 24),
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: (constraints.maxHeight - 52).clamp(
|
||||
@@ -432,7 +423,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _LoginHero(),
|
||||
const SizedBox(height: 28),
|
||||
const SizedBox(height: 22),
|
||||
_LoginFormCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -453,7 +444,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
'姓名',
|
||||
TextInputType.name,
|
||||
20,
|
||||
icon: Icons.person_outline_rounded,
|
||||
icon: LucideIcons.userRound,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@@ -462,8 +453,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
'手机号',
|
||||
TextInputType.phone,
|
||||
11,
|
||||
icon: Icons.local_phone_rounded,
|
||||
gradientIcon: true,
|
||||
icon: LucideIcons.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -474,8 +464,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
'验证码',
|
||||
TextInputType.number,
|
||||
6,
|
||||
icon: Icons.sms_rounded,
|
||||
gradientIcon: true,
|
||||
icon: LucideIcons.messageSquareText,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@@ -493,7 +482,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
agreed: _agreed,
|
||||
onTap: () =>
|
||||
setState(() => _agreed = !_agreed),
|
||||
onTermsTap: () => _openStaticText('terms'),
|
||||
onTermsTap: () =>
|
||||
_openStaticText('terms'),
|
||||
onPrivacyTap: () =>
|
||||
_openStaticText('privacy'),
|
||||
),
|
||||
@@ -539,6 +529,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
41
health_app/lib/pages/care_plan_ui_logic.dart
Normal file
41
health_app/lib/pages/care_plan_ui_logic.dart
Normal file
@@ -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<String> 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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: Colors.transparent,
|
||||
title: Column(
|
||||
children: [
|
||||
Text(
|
||||
|
||||
@@ -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<DeviceManagementPage>
|
||||
bool _scanning = false;
|
||||
bool _permissionBlocked = false;
|
||||
bool _disposed = false;
|
||||
String? _pageMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -58,15 +63,18 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
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<bool> _ensureBlePermissions() async {
|
||||
final statuses = await [
|
||||
Map<Permission, PermissionStatus> 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<void>(
|
||||
@@ -224,6 +266,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
setState(() {
|
||||
_busyDeviceId = bound.id;
|
||||
_connectedDeviceId = null;
|
||||
_pageMessage = '正在连接 ${bound.name}';
|
||||
});
|
||||
|
||||
await FlutterBluePlus.stopScan();
|
||||
@@ -237,7 +280,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
);
|
||||
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<DeviceManagementPage>
|
||||
if (await notifier.isDuplicateReading(device, reading)) return false;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/health-records', data: reading.toHealthRecord());
|
||||
final records = <Map<String, dynamic>>[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<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
}
|
||||
|
||||
ref.listen<DeviceBindState>(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<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
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<DeviceManagementPage>
|
||||
child: _ScanIndicator(
|
||||
animation: _scanAnim,
|
||||
scanning: _scanning,
|
||||
syncing: connected,
|
||||
syncing: _connectedDeviceId != null || _busyDeviceId != null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -394,7 +491,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
final ok = await showDialog<bool>(
|
||||
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<DeviceManagementPage>
|
||||
),
|
||||
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<DeviceManagementPage>
|
||||
}
|
||||
}
|
||||
|
||||
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<BoundBleDevice> devices;
|
||||
final String? connectedDeviceId;
|
||||
final String? busyDeviceId;
|
||||
final ValueChanged<BoundBleDevice> 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<double> 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<BoundBleDevice> devices;
|
||||
final String? connectedDeviceId;
|
||||
final String? busyDeviceId;
|
||||
final ValueChanged<BoundBleDevice> 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<void> 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('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<DeviceScanPage>
|
||||
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<DeviceScanPage>
|
||||
),
|
||||
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,
|
||||
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 == result.device.remoteId.toString(),
|
||||
onConnect: () => _connectBindAndSync(result),
|
||||
);
|
||||
},
|
||||
_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,7 +454,23 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
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,
|
||||
@@ -457,7 +478,7 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
Text(
|
||||
type?.label ?? '健康设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
@@ -468,7 +489,7 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
@@ -477,17 +498,21 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: FilledButton(
|
||||
height: 36,
|
||||
child: OutlinedButton(
|
||||
onPressed: connecting ? null : onConnect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.device,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: AppColors.cardInner,
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.device,
|
||||
disabledForegroundColor: AppColors.textHint,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
side: BorderSide(
|
||||
color: connecting
|
||||
? AppColors.borderLight
|
||||
: AppColors.device,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
@@ -499,6 +524,10 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
33
health_app/lib/pages/device/device_sync_ui_logic.dart
Normal file
33
health_app/lib/pages/device/device_sync_ui_logic.dart
Normal file
@@ -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' : '数据已读取,但上传失败,请检查网络后重试',
|
||||
};
|
||||
}
|
||||
@@ -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<DietState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
||||
// 饮食主题色
|
||||
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<DietCapturePage> {
|
||||
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<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -480,7 +480,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── 分析中 ───────────
|
||||
// 分析中
|
||||
Widget _buildAnalyzing(DietState state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
@@ -503,7 +503,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
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<DietCapturePage> {
|
||||
}
|
||||
|
||||
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<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -618,8 +589,9 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
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<DietCapturePage> {
|
||||
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<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -770,8 +741,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
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<DietCapturePage> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DateTime> recentDietDates(DateTime now, {int count = 7}) {
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
return List.generate(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
(archive['surgeries'] as List)
|
||||
.map((item) {
|
||||
final surgery = item as Map;
|
||||
final date = surgery['date']?.toString() ?? '';
|
||||
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
|
||||
}).join(';'),
|
||||
})
|
||||
.join(';'),
|
||||
)
|
||||
else if (archive['surgeryType'] != null)
|
||||
_row(
|
||||
|
||||
@@ -40,7 +40,7 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
|
||||
@@ -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,7 +298,11 @@ 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('查看原始报告'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
604
health_app/lib/pages/exercise/exercise_plan_page.dart
Normal file
604
health_app/lib/pages/exercise/exercise_plan_page.dart
Normal file
@@ -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<EnterpriseExercisePlanPage> createState() =>
|
||||
_EnterpriseExercisePlanPageState();
|
||||
}
|
||||
|
||||
class _EnterpriseExercisePlanPageState
|
||||
extends ConsumerState<EnterpriseExercisePlanPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
final Set<String> _busyItems = {};
|
||||
final Set<String> _deletingPlans = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() => setState(() {
|
||||
_future = ref.read(exerciseServiceProvider).getPlans();
|
||||
});
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_load();
|
||||
await _future;
|
||||
}
|
||||
|
||||
Future<void> _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<void> _confirmDelete(String id, String title) async {
|
||||
if (id.isEmpty || _deletingPlans.contains(id)) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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 = <CarePlanPhase, List<Map<String, dynamic>>>{
|
||||
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<Map<String, dynamic>> items;
|
||||
final int completed;
|
||||
final Set<String> busyItems;
|
||||
final Future<void> 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<Map<String, dynamic>?>().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<Map<String, dynamic>> plans;
|
||||
final String todayKey;
|
||||
final Set<String> busyItems;
|
||||
final Set<String> deletingPlans;
|
||||
final Future<void> Function(String) onCheckIn;
|
||||
final Future<void> 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<String, dynamic> plan;
|
||||
final String title;
|
||||
final CarePlanPhase phase;
|
||||
final String todayKey;
|
||||
final Set<String> busyItems;
|
||||
final bool deleting;
|
||||
final bool showDivider;
|
||||
final Future<void> 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<Map<String, dynamic>?>().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<Map<String, dynamic>> _itemsOf(Map<String, dynamic> plan) =>
|
||||
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
|
||||
String _exerciseTitle(List<Map<String, dynamic>> 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,
|
||||
};
|
||||
@@ -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<bool> deleteConversationForDismiss(
|
||||
Future<void> 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<void> _deleteOne(
|
||||
Future<bool> _deleteOne(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String id,
|
||||
) async {
|
||||
try {
|
||||
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
||||
final deleted = await deleteConversationForDismiss(
|
||||
() => 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);
|
||||
}
|
||||
AppToast.show(
|
||||
context,
|
||||
deleted ? '已删除' : '删除失败,请稍后重试',
|
||||
type: deleted ? AppToastType.success : AppToastType.error,
|
||||
);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
Future<void> _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<bool> 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 {
|
||||
|
||||
@@ -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<HomePage>
|
||||
final _scrollCtrl = ScrollController();
|
||||
final _focusNode = FocusNode();
|
||||
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
double? _drawerDragStartX;
|
||||
String? _pickedImagePath;
|
||||
Timer? _notificationTimer;
|
||||
|
||||
@@ -36,19 +36,21 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
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<HomePage>
|
||||
}
|
||||
}
|
||||
|
||||
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,57 +127,38 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
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,
|
||||
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: Stack(
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,20 +168,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
: '用户';
|
||||
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<HomePage>
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -261,34 +251,38 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
.read(chatProvider.notifier)
|
||||
.triggerAgent(agent, visual.label),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? null : Colors.transparent,
|
||||
gradient: selected ? AppColors.actionOutlineGradient : null,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
padding: const EdgeInsets.all(1.2),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11.8,
|
||||
vertical: 7.8,
|
||||
),
|
||||
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,
|
||||
),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
gradient: visual.gradient,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(visual.icon, size: 11, color: Colors.white),
|
||||
child: Icon(visual.icon, size: 13, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
visual.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
@@ -296,6 +290,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -314,7 +309,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
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,17 +395,39 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
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),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(6, 5, 6, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 118),
|
||||
constraints: const BoxConstraints(maxHeight: 112),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
focusNode: _focusNode,
|
||||
@@ -420,55 +437,46 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你想说的...',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 11,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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: 8),
|
||||
GestureDetector(
|
||||
const SizedBox(width: 6),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
|
||||
decoration: BoxDecoration(
|
||||
child: Ink(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.send,
|
||||
size: 19,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -517,23 +525,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
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<HomePage>
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _checkDueNotifications() async {
|
||||
try {
|
||||
await ref.read(inAppNotificationServiceProvider).checkDue();
|
||||
} catch (_) {
|
||||
// Network failures should not block the home page.
|
||||
} finally {
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
late final _fade = Tween<double>(
|
||||
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<ChatMessage> 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(
|
||||
final msg = messageAtDisplayIndex(messages, index);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('chat-message-${msg.id}'),
|
||||
child: _buildMessageContent(
|
||||
context,
|
||||
ref,
|
||||
msg,
|
||||
streaming.$1,
|
||||
streaming.$2 ?? '',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -167,8 +127,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final agentColors = _agentColors(agent);
|
||||
final artworkPath = _agentArtworkPath(agent);
|
||||
|
||||
return _AnimatedCardEntry(
|
||||
child: Align(
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
@@ -193,7 +152,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── 标题区域 ──
|
||||
Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
@@ -277,10 +235,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
info.$3,
|
||||
style: AppTextStyles.summarySubtitle,
|
||||
),
|
||||
Text(info.$3, style: AppTextStyles.summarySubtitle),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -289,16 +244,12 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ── 插画展示 ──
|
||||
Image.asset(
|
||||
artworkPath,
|
||||
width: double.infinity,
|
||||
height: 206,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
// ── AI问诊引导 ──
|
||||
if (agent == ActiveAgent.consultation) ...[
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
@@ -311,7 +262,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Text(
|
||||
'在下方输入框描述您的症状,AI医生将为您提供专业建议',
|
||||
'小脉会陪您一步步梳理症状。请先告诉我哪里不舒服,或说说您现在最担心的问题。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
@@ -321,7 +272,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
@@ -344,8 +294,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// ── 底部提示 ──
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
@@ -386,7 +334,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 +536,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return _AnimatedCardEntry(
|
||||
child: Align(
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
@@ -717,7 +665,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
: Icon(
|
||||
isMedication
|
||||
? Icons.medication_liquid_outlined
|
||||
: LucideIcons.footprints,
|
||||
: AppModuleVisuals.exercise.icon,
|
||||
size: 36,
|
||||
color: visual.color,
|
||||
),
|
||||
@@ -841,9 +789,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
Container(
|
||||
height: 1,
|
||||
color: AppColors.borderLight,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
_buildFieldRow(fields[i]),
|
||||
],
|
||||
@@ -955,7 +901,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic> healthData,
|
||||
AsyncValue<List<Map<String, dynamic>>> reminders,
|
||||
AsyncValue<Map<String, dynamic>?> exercisePlan,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final tasks = <Widget>[];
|
||||
@@ -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,34 +1843,36 @@ 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,
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
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),
|
||||
),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: ibg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(icon, size: 18, color: ic),
|
||||
),
|
||||
@@ -1898,21 +1882,25 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
trailing ?? label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: status == 'overdue' || status == 'warning'
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
icons[status] ?? Icons.circle_outlined,
|
||||
size: 21,
|
||||
size: 20,
|
||||
color: colors[status] ?? Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2064,7 +2052,7 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
ActiveAgent.health: [
|
||||
_AgentAction(
|
||||
label: '健康概览',
|
||||
icon: Icons.trending_up,
|
||||
icon: AppModuleVisuals.healthOverviewIcon,
|
||||
isWide: true,
|
||||
route: 'trend',
|
||||
),
|
||||
@@ -2104,10 +2092,10 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
label: '上传报告',
|
||||
icon: Icons.upload_file_outlined,
|
||||
isWide: true,
|
||||
route: 'reports',
|
||||
action: 'openReportUpload',
|
||||
),
|
||||
_AgentAction(
|
||||
label: '查看历史',
|
||||
label: '查看报告',
|
||||
icon: Icons.history_outlined,
|
||||
isWide: true,
|
||||
route: 'reports',
|
||||
|
||||
@@ -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<MedicationCheckInPage> {
|
||||
static const _visual = AppModuleVisuals.medication;
|
||||
static const _softGreen = Color(0xFFEAF8EF);
|
||||
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
final Set<String> _busyDoses = {};
|
||||
|
||||
@@ -34,11 +34,9 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
void _load() => setState(() {
|
||||
_future = ref.read(medicationReminderProvider.future);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
@@ -48,26 +46,32 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
|
||||
Future<void> _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<MedicationCheckInPage> {
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text('服药打卡'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
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,23 +179,25 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
List<Map<String, dynamic>> _filterReminders(
|
||||
List<Map<String, dynamic>> reminders,
|
||||
) {
|
||||
if (widget.medId == null) return reminders;
|
||||
return reminders.where((r) => r['id']?.toString() == widget.medId).toList();
|
||||
if (widget.medId == null || widget.medId!.isEmpty) return reminders;
|
||||
return reminders
|
||||
.where((item) => item['id']?.toString() == widget.medId)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<Map<String, dynamic>>> _groupByMedication(
|
||||
Map<String, List<Map<String, dynamic>>> groupMedicationReminders(
|
||||
List<Map<String, dynamic>> reminders,
|
||||
) {
|
||||
final grouped = <String, List<Map<String, dynamic>>>{};
|
||||
for (final reminder in reminders) {
|
||||
final name = reminder['name']?.toString().trim();
|
||||
grouped
|
||||
.putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () {
|
||||
return <Map<String, dynamic>>[];
|
||||
})
|
||||
.add(reminder);
|
||||
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, () => <Map<String, dynamic>>[]).add(reminder);
|
||||
}
|
||||
|
||||
for (final doses in grouped.values) {
|
||||
doses.sort(
|
||||
(a, b) => _formatTime(
|
||||
@@ -174,184 +207,60 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
}
|
||||
|
||||
class _CheckInSummary extends StatelessWidget {
|
||||
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<Color>(
|
||||
_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<Map<String, dynamic>> doses;
|
||||
final Set<String> busyDoses;
|
||||
final Future<void> 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<String, dynamic> dose;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final bool isBusy;
|
||||
final Future<void> Function(String medId, String time, bool taken) onToggle;
|
||||
final Future<void> 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,94 +286,91 @@ 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(
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 92),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
time,
|
||||
style: AppTextStyles.listTitle.copyWith(fontSize: 18),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
height: 92,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: isTaken ? AppTheme.success : Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isTaken ? AppTheme.success : AppColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isTaken
|
||||
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||
: null,
|
||||
if (!isFirst)
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 46,
|
||||
child: Container(width: 2, color: AppColors.divider),
|
||||
),
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: isTaken
|
||||
? AppTheme.success.withValues(alpha: 0.34)
|
||||
: AppColors.borderLight,
|
||||
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: 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,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
displayTime,
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isTaken ? AppTheme.textSub : AppTheme.text,
|
||||
style: AppTextStyles.listTitle.copyWith(fontSize: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
isTaken ? '已完成打卡' : '等待确认服用',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isTaken
|
||||
? AppTheme.success
|
||||
: AppColors.textSecondary,
|
||||
[
|
||||
dosage,
|
||||
_statusLabel(status),
|
||||
].where((text) => text.isNotEmpty).join(' · '),
|
||||
style: AppTextStyles.listSubtitle.copyWith(
|
||||
fontSize: 13,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -595,57 +379,54 @@ class _DoseRow extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: FilledButton.icon(
|
||||
onPressed: isBusy || medId.isEmpty || time.isEmpty
|
||||
child: OutlinedButton(
|
||||
onPressed: isBusy || scheduled
|
||||
? null
|
||||
: () => onToggle(medId, time, isTaken),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: isTaken
|
||||
? Colors.white
|
||||
: _MedicationCheckInPageState._visual.color,
|
||||
foregroundColor: isTaken
|
||||
? AppTheme.success
|
||||
: () => onToggle(medId, rawTime, taken),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13),
|
||||
backgroundColor: taken
|
||||
? AppColors.successLight
|
||||
: Colors.white,
|
||||
disabledBackgroundColor: AppColors.cardInner,
|
||||
foregroundColor: taken
|
||||
? AppColors.successText
|
||||
: AppColors.medication,
|
||||
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,
|
||||
color: scheduled
|
||||
? AppColors.borderLight
|
||||
: (taken
|
||||
? AppColors.successText
|
||||
: AppColors.medication),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: isBusy
|
||||
child: isBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
isTaken
|
||||
? Icons.undo_rounded
|
||||
: Icons.check_rounded,
|
||||
size: 17,
|
||||
),
|
||||
label: Text(
|
||||
isTaken ? '撤销' : '打卡',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
: Text(
|
||||
scheduled ? '未到时间' : (taken ? '撤销' : '打卡'),
|
||||
style: AppTextStyles.miniButton,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
const Positioned(
|
||||
left: 96,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: AppColors.divider,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -654,16 +435,26 @@ class _DoseRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTaken(Map<String, dynamic> dose) {
|
||||
return dose['status']?.toString().toLowerCase() == 'taken';
|
||||
}
|
||||
bool _isTaken(Map<String, dynamic> 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,
|
||||
};
|
||||
|
||||
@@ -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<MedicationEditPage> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _dosageCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
int _timesPerDay = 2;
|
||||
final _nameController = TextEditingController();
|
||||
final _dosageController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
List<TimeOfDay> _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<void> _load() async {
|
||||
final srv = ref.read(medicationServiceProvider);
|
||||
final list = await srv.getList();
|
||||
final m = list.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(x) => x?['id']?.toString() == widget.id,
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_loadError = null;
|
||||
});
|
||||
try {
|
||||
final medications = await ref.read(medicationServiceProvider).getList();
|
||||
final medication = medications.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(item) => item?['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();
|
||||
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<TimeOfDay>()
|
||||
.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<void> _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 = <String, dynamic>{
|
||||
'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<void> _deleteMedication() async {
|
||||
if (!_editing || _saving) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<MedicationEditPage> {
|
||||
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: '如:阿司匹林'),
|
||||
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(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _field('剂量', _dosageCtrl, hint: '如:100mg'),
|
||||
),
|
||||
),
|
||||
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(height: 16),
|
||||
_label('每日服药次数'),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final n = await showAppCountPicker(
|
||||
_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<void> _pickCount() async {
|
||||
final count = await showAppCountPicker(
|
||||
context,
|
||||
initialValue: _timesPerDay,
|
||||
initialValue: _times.length,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _dateField(
|
||||
'开始日期',
|
||||
_start,
|
||||
(d) => setState(() => _start = d),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _label(String text) => Text(text, style: AppTextStyles.formLabel);
|
||||
Future<void> _pickTime(int index) async {
|
||||
final value = await showAppTimePicker(context, initialTime: _times[index]);
|
||||
if (value != null && mounted) setState(() => _times[index] = value);
|
||||
}
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
|
||||
Column(
|
||||
Future<void> _pickStartDate() async {
|
||||
final value = await showAppDatePicker(
|
||||
context,
|
||||
initialDate: _startDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2035),
|
||||
);
|
||||
if (value != null && mounted) setState(() => _startDate = value);
|
||||
}
|
||||
|
||||
Future<void> _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<Widget> children;
|
||||
|
||||
const _FormSection({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTextStyles.sectionTitle.copyWith(fontSize: 16),
|
||||
),
|
||||
),
|
||||
style: AppTextStyles.formValue,
|
||||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Column(children: children),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
class _TextFieldRow extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
|
||||
Widget _dateFieldOpt(
|
||||
String label,
|
||||
DateTime? val,
|
||||
ValueChanged<DateTime?> 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(
|
||||
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: Text(
|
||||
val != null ? _displayDate(val) : '不设置',
|
||||
style: AppTextStyles.formValue.copyWith(
|
||||
color: val != null ? null : AppTheme.textHint,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (val != null)
|
||||
GestureDetector(
|
||||
onTap: () => cb(null),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 19,
|
||||
color: AppColors.textHint,
|
||||
style: AppTextStyles.formValue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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;
|
||||
class _PickerRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onClear;
|
||||
|
||||
const _GradientOutlineButton({required this.onPressed, required this.label});
|
||||
const _PickerRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
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),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: AppTextStyles.button.copyWith(
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -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<MedicationListPage> createState() => _MedicationListPageState();
|
||||
}
|
||||
|
||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
static const _medVisual = AppModuleVisuals.medication;
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
final Set<String> _deleting = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -29,22 +33,53 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
void _load() => setState(() {
|
||||
_future = ref.read(medicationServiceProvider).getMedications('');
|
||||
});
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_load();
|
||||
await _future;
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
Future<void> _confirmDelete(String id, String name) async {
|
||||
if (id.isEmpty || _deleting.contains(id)) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<MedicationListPage> {
|
||||
),
|
||||
title: const Text('用药管理'),
|
||||
),
|
||||
floatingActionButton: _GradientFab(
|
||||
gradient: _medVisual.gradient,
|
||||
onTap: () => pushRoute(ref, 'medicationEdit'),
|
||||
floatingActionButton: AppCreateFab(
|
||||
tooltip: '添加用药',
|
||||
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
||||
),
|
||||
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||
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 = <CarePlanPhase, List<Map<String, dynamic>>>{
|
||||
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<int>(
|
||||
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<MedicationListPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Widget> 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(
|
||||
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<Map<String, dynamic>> medications;
|
||||
final Set<String> deleting;
|
||||
final ValueChanged<String> onOpen;
|
||||
final ValueChanged<String> onEdit;
|
||||
final Future<void> 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.symmetric(
|
||||
horizontal: AppTheme.sLg,
|
||||
vertical: 13,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
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(
|
||||
gradient: isActive
|
||||
? visual.gradient
|
||||
: AppColors.surfaceGradient,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
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<String, dynamic> 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.fromLTRB(16, 12, 6, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
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,
|
||||
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.textHint,
|
||||
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<Map<String, dynamic>> 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<String, dynamic> 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,
|
||||
};
|
||||
|
||||
33
health_app/lib/pages/medication/medication_ui_logic.dart
Normal file
33
health_app/lib/pages/medication/medication_ui_logic.dart
Normal file
@@ -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';
|
||||
}
|
||||
@@ -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<bool> acknowledgeBeforeNotificationOpen({
|
||||
required Future<void> 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<void> _open(InAppNotification item) async {
|
||||
if (!item.isRead) {
|
||||
await _service.acknowledge(item.id);
|
||||
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),
|
||||
unreadCount: (current.unreadCount - 1).clamp(
|
||||
0,
|
||||
current.unreadCount,
|
||||
),
|
||||
items: current.items
|
||||
.map(
|
||||
(value) =>
|
||||
value.id == item.id ? value.copyWith(isRead: true) : value,
|
||||
(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,11 +227,16 @@ class _NotificationCenterPageState
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
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 条未读消息',
|
||||
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(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.notification,
|
||||
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: 21, color: visual.color),
|
||||
),
|
||||
child: Icon(visual.icon, size: 24, color: Colors.white),
|
||||
),
|
||||
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,13 +583,17 @@ 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: 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(
|
||||
@@ -565,6 +601,18 @@ class _NotificationRow extends StatelessWidget {
|
||||
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(
|
||||
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,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: Text(buttonText!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
278
health_app/lib/pages/profile/profile_edit_page.dart
Normal file
278
health_app/lib/pages/profile/profile_edit_page.dart
Normal file
@@ -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<ProfileEditPage> createState() => _ProfileEditPageState();
|
||||
}
|
||||
|
||||
class _ProfileEditPageState extends ConsumerState<ProfileEditPage> {
|
||||
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<void> _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<void> _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<void> _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<String>(
|
||||
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<Widget> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
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: '维护个人资料、病史、手术和过敏信息',
|
||||
visual: AppModuleVisuals.health,
|
||||
subtitle: '疾病、手术、过敏和生活习惯',
|
||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_LogoutButton(onPressed: () => _logout(context, ref)),
|
||||
],
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -53,37 +79,37 @@ class ProfilePage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showDialog<bool>(
|
||||
final confirmed = await showDialog<bool>(
|
||||
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) {
|
||||
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;
|
||||
class _AvatarFallback extends StatelessWidget {
|
||||
const _AvatarFallback();
|
||||
|
||||
const _Avatar({required this.avatarUrl});
|
||||
@override
|
||||
Widget build(BuildContext context) => Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppModuleVisuals.health.color,
|
||||
size: 34,
|
||||
);
|
||||
}
|
||||
|
||||
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<Widget> children;
|
||||
const _SettingsGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppModuleVisuals.health.gradient,
|
||||
color: Colors.white,
|
||||
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),
|
||||
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 _ActionTile extends StatelessWidget {
|
||||
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('退出登录'),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user