feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色 - 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼 - app_colors / app_design_tokens / app_theme: 配色体系微调 - 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等) ## 登录页品牌升级 - 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent - login_page 重构, 接入新品牌视觉 - app.dart 启动流程调整 ## iOS / Android 配置 - Info.plist: 权限描述调整 - AppIcon / LaunchImage: 资源更新 - AndroidManifest: 权限微调 ## 隐私文案修订 - privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述 - terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄 ## 通知中心 - notification_center_page: 重构通知项布局 ## 其他 - 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system) - 新增 native_navigation_test - 新增 HANDOFF 交接文档 - home_message_order_test 更新 - .gitignore 调整
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
|
||||
|
||||
<h2>一、我们收集的信息</h2>
|
||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
|
||||
|
||||
<h3>1. 账号与身份信息</h3>
|
||||
<ul>
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<h3>4. 蓝牙设备信息</h3>
|
||||
<ul>
|
||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
|
||||
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明。</li>
|
||||
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。</li>
|
||||
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
|
||||
</ul>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<p>如您不同意本协议,请停止注册或使用本应用。</p>
|
||||
|
||||
<h2>一、服务内容</h2>
|
||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
|
||||
<p>本应用当前可能包含以下服务:</p>
|
||||
<ul>
|
||||
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
|
||||
@@ -37,7 +37,6 @@
|
||||
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
|
||||
<li>AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
|
||||
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
|
||||
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
|
||||
</ul>
|
||||
|
||||
<h2>二、账号注册与使用</h2>
|
||||
@@ -56,7 +55,6 @@
|
||||
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
|
||||
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
|
||||
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
|
||||
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
|
||||
</ul>
|
||||
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
|
||||
|
||||
|
||||
233
docs/HANDOFF-2026-07-16.md
Normal file
233
docs/HANDOFF-2026-07-16.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 小脉健康项目交接文档
|
||||
|
||||
更新时间:2026-07-16
|
||||
工作目录:`D:\health_project`
|
||||
当前分支:`main`
|
||||
|
||||
## 1. 项目当前阶段
|
||||
|
||||
项目主体已经完成,包括 Flutter App 和 .NET 后端。目前处于:
|
||||
|
||||
> Android 本地真机测试、功能稳定优化、前端 UI 统一、合规准备,以及等待正式环境和资质流程。
|
||||
|
||||
当前不是“马上提交应用商店审核”的状态。
|
||||
|
||||
- Android 一直以本地真机为主要测试平台。
|
||||
- iOS 代码已经合入并做过基础适配,但尚未完成本地真实 iPhone 的完整回归。
|
||||
- 后端及 H5 合规页面仍以本地环境为主,正式服务器、域名和 HTTPS 尚未最终切换。
|
||||
- 正式 API、数据库、JWT、短信、文件存储和 CORS 要在服务器及域名确定后统一配置。
|
||||
- 正式 Android keystore、发布签名、APK/AAB 仍取决于外部资质和正式配置交付。
|
||||
- 苹果开发者账号、证书、Bundle ID、签名和 TestFlight 权限仍取决于外部流程。
|
||||
- ICP、公安备案、软著、安全评估和应用市场账号等由外部继续推进。
|
||||
- 项目明确不使用 Docker,不要围绕 Docker 增加工作。
|
||||
|
||||
## 2. Git 与代码状态
|
||||
|
||||
- 当前分支:`main`,不要创建额外分支。
|
||||
- 当前提交:`5288596 merge: integrate sccsbc release preparation safely`。
|
||||
- `origin/main` 当前为 `fade61a`,本地 `main` 比远端多 6 个提交。
|
||||
- `sccsbc` 的 Apple 登录、上线准备和 Android 签名相关提交已经通过合并提交进入本地 `main`。
|
||||
- 合并采用“保留当前新版业务和 UI、吸收对方上线准备”的方式,没有用对方旧快照覆盖现有代码。
|
||||
- 当前工作区有大量未提交修改。这些包含用户现阶段 UI、iOS 适配和交互调整,不能执行 `git reset --hard`、`git checkout --` 或用旧分支覆盖。
|
||||
- 开始新修改前必须先查看 `git status` 和相关文件差异,只改当前任务涉及的代码。
|
||||
|
||||
当前主要未跟踪正式资源:
|
||||
|
||||
- `health_app/assets/branding/login_background_v2.png`
|
||||
- `health_app/assets/branding/drawer_background_v2.png`
|
||||
- `health_app/assets/branding/health_login_character_transparent.png`
|
||||
- `health_app/test/native_navigation_test.dart`
|
||||
|
||||
## 3. 用户的协作习惯
|
||||
|
||||
- 先用用户视角解释,不要堆技术术语。
|
||||
- 一次讨论一个明确问题,说明表现、原因和修改结果。
|
||||
- 用户说“先讨论”时不能直接修改。
|
||||
- 用户说“开始改”“动手”“去做”后直接实现,不要反复确认。
|
||||
- 不要擅自扩大范围,不要增加与当前目标无关的工作量。
|
||||
- 用户可能同时修改其他文件,必须保留并兼容用户改动。
|
||||
- 用户通常自己构建和安装;除非明确要求,不要运行耗时构建或安装。
|
||||
- 简单改动只做快速格式或静态检查,不要反复跑长测试。
|
||||
- 数据删除、注销、历史对话等操作必须对应真实后端结果,不能只改前端显示。
|
||||
- 修改聊天相关代码时,必须保证恢复历史对话后仍可继续聊天。
|
||||
|
||||
## 4. 已确认的 UI 方向
|
||||
|
||||
### 整体基调
|
||||
|
||||
- 首页现有品牌化紫色背景和整体结构基本保留。
|
||||
- 普通二级页面以白色内容区为主,使用极浅中性灰建立页面与内容组边界。
|
||||
- 不允许把模块颜色铺满整个页面、标题栏或大卡片。
|
||||
- 风格要求干净、克制、专业、柔和,避免大面积紫色、重橙色和过亮颜色。
|
||||
|
||||
### 列表
|
||||
|
||||
- 同一内容组内连续排列,不要每行单独套白卡片。
|
||||
- 分隔线只从文字区域开始,不贯穿左侧图标区域。
|
||||
- 删除背景默认完全隐藏,左滑后才显示。
|
||||
- 删除失败时不能提前从前端移除真实数据。
|
||||
|
||||
### 图标
|
||||
|
||||
- 全 App 图标样式必须统一,颜色可以按模块变化。
|
||||
- 优先使用同一套线性、圆润图标。
|
||||
- 同类列表的图标底座尺寸、圆角、图标尺寸和对齐保持一致。
|
||||
- 运动模块已统一使用更合适的跑步图标,修改时要同步首页今日健康、胶囊和运动页面等所有入口。
|
||||
|
||||
### 色彩
|
||||
|
||||
- 通用输入焦点、选中和品牌操作继续使用 App 紫色体系。
|
||||
- 模块色只用于模块识别、图标、状态和少量关键数字。
|
||||
- 成功、警告、错误和信息颜色必须按语义使用,不能与模块色混用。
|
||||
- 当前页面背景为 `#F3F5F8`,白色内容区用于建立层次。
|
||||
- 当前健康模块为克制青绿色体系;运动模块为蓝靛体系;用药模块保持原有蓝青色体系。
|
||||
- 今日健康异常状态已从偏黑的棕红色改为鲜明红橙色 `#E8562A`。
|
||||
|
||||
### 圆角、阴影和字体
|
||||
|
||||
- 所有圆角优先使用项目已有的 `AppRadius`,不要随手写不同圆角。
|
||||
- 卡片不能层层嵌套,也不能同时堆重描边和重阴影。
|
||||
- 不要随意缩小字体;列表紧凑但必须清楚可读。
|
||||
- 选中状态不能改变文字大小、字重或位置。
|
||||
- 为避免 Android 跟随系统楷书时粗体失控,项目已减少 `w800/w900` 的使用,主要层级使用 `w600/w700`。
|
||||
|
||||
完整基础规范见:`docs/ui-design-system.md`。实际逐页精修仍以真机截图和用户确认优先。
|
||||
|
||||
## 5. 最近完成的界面与交互修改
|
||||
|
||||
### 登录页
|
||||
|
||||
- 登录页使用新背景:`login_background_v2.png`。
|
||||
- 新背景去掉旧版气泡、玻璃方块和心电线,改为低饱和蓝紫柔光,中央保持安静。
|
||||
- App 图标继续使用原来的白底人物版本,没有继续使用错误生成的紫色底图标。
|
||||
- 登录页单独使用透明人物资源:`health_login_character_transparent.png`。
|
||||
- 登录键盘掉帧已针对性优化:
|
||||
- 背景使用独立重绘边界,键盘弹出时背景不重复重绘。
|
||||
- 删除原来每次键盘高度变化都会重启的 `AnimatedPadding`。
|
||||
- 表单改为轻量 `Transform.translate` 位移,最大抬升 150 px。
|
||||
- 下滑表单可以关闭键盘。
|
||||
- 这项键盘优化只做了格式和差异检查,尚未经过用户重新安装后的真机确认。
|
||||
- 透明人物由原人物参考生成并去除背景。用户非常重视人物不变形;需要真机确认其造型是否与原人物完全一致。如果仍有偏差,应使用确定性的抠图方式处理原图,不能再次重新设计人物。
|
||||
|
||||
### App 图标与启动页
|
||||
|
||||
- Android 和 iOS 已恢复原来的白底人物图标及周围小元素。
|
||||
- 已撤销错误的紫色底图标和重新生成的人物衍生图。
|
||||
- Flutter 启动页恢复使用 `health_splash_master.png`。
|
||||
- Android 原生启动图恢复使用原 `launch_brand.png`。
|
||||
- iOS AppIcon 和 LaunchImage 配置恢复引用原文件。
|
||||
|
||||
### 侧边栏
|
||||
|
||||
- 使用新背景:`drawer_background_v2.png`。
|
||||
- 背景改为顶部淡蓝紫、向下过渡白色,遮罩同步调轻。
|
||||
- 对话记录区域继续保留当前结构,不要因为“区域转换突然”擅自重排;用户认为类似蚂蚁阿福的结构可以接受。
|
||||
- 当前侧边栏仍是:系统整页拖拽关闭,只在中间对话流区域使用自定义右滑触发;智能体胶囊和输入区不应触发侧边栏。
|
||||
- 顶部菜单按钮仍可打开侧边栏。
|
||||
- 不要再次擅自改成推页式侧栏或另一套 Drawer 手势,除非用户重新明确要求。
|
||||
|
||||
### 首页和今日健康
|
||||
|
||||
- 首页整体结构用户基本满意,不要进行大面积重构。
|
||||
- 今日健康加载策略已调整,避免返回首页时先显示两行再闪成三行。
|
||||
- 健康概览图标已统一为“记数据”中使用的图标风格。
|
||||
- 跑步、用药等入口图标需与智能体胶囊保持同样样式。
|
||||
- 今日健康异常文字和右侧警告图标当前统一为 `#E8562A`。
|
||||
- 首页键盘布局保持原来的结构;上一轮误加的首页键盘悬浮布局已经撤回。
|
||||
|
||||
### 通知中心
|
||||
|
||||
- 通知列表重新压缩并调整信息布局。
|
||||
- 用户指定单行最低高度约 82 px,不要再次压到 68 px。
|
||||
- 标签和时间已重新安排,时间位于右侧区域,减少不必要的独立行。
|
||||
- 列表内容要保持视觉垂直居中。
|
||||
- 未读提醒区域不能使用大面积强紫色。
|
||||
|
||||
### 用药、运动、报告和设备
|
||||
|
||||
- 用药与运动列表的红色删除背景“提前露出”问题已处理过;后续修改需继续检查右侧红底不能浮出。
|
||||
- 运动计划列表已向用药、报告的连续列表形式统一,并补充前置运动图标。
|
||||
- 报告页面以靛紫色为模块识别,但“查看原始报告”不能使用突兀的大紫色实心按钮。
|
||||
- 蓝牙设备管理仅允许修改 UI,不得改变扫描、连接、自动读取、上传和解绑逻辑。
|
||||
- 蓝牙页右下角自动扫描动画必须保留,用于表达页面正在持续检测设备。
|
||||
|
||||
## 6. 功能与平台适配状态
|
||||
|
||||
### 已合并/已处理
|
||||
|
||||
- `sccsbc` 分支的 Apple 登录和部分上线准备代码已经合并。
|
||||
- Apple 登录前端按钮、服务端身份映射和相关迁移已进入当前代码历史。
|
||||
- Android 签名相关配置结构已合入,但真实 keystore、密码和正式证书不应写入 Git。
|
||||
- iOS 页面导航已加入原生风格返回适配相关测试文件,但尚未在真实 iPhone 全量验证。
|
||||
- iOS 与 Android 共用同一套 Flutter 业务页面;平台差异主要在权限、签名、Apple 登录和系统交互。
|
||||
- Android 本地电脑与手机同 Wi-Fi 的开发 API 地址方式继续保留;iPhone 真机调试时需要配置为电脑局域网 IP,而不是 `localhost`。
|
||||
|
||||
### 暂不投入的功能
|
||||
|
||||
- 医生端尚未正式完成。
|
||||
- 医患实时交流、问诊和随访目前不是近期上线重点。
|
||||
- 未启用的医生端、问诊和随访页面不需要继续做 UI 精修,也不要为它们扩大工作量。
|
||||
- 但已有页面和入口不能伪装成已经可用的正式医疗服务。
|
||||
|
||||
## 7. Apple 审核与医疗合规风险
|
||||
|
||||
苹果反馈涉及 Guideline 1.4.1 和 2.1,要求:
|
||||
|
||||
- 外接医疗硬件的监管批准材料。
|
||||
- 能证明 App 与硬件按描述工作的硬件测试报告或同行评议研究。
|
||||
- 在真实 Apple 设备上完成硬件首次配对和完整流程的演示视频。
|
||||
- 涉及医疗数据、诊断或治疗建议时提供相应监管批准文件。
|
||||
|
||||
当前本地测试主要使用欧姆龙 J735 血压计,但代码定位为通用 BLE 设备。后续必须在产品表述和审核材料之间作出一致选择:
|
||||
|
||||
- 如果继续声明支持医疗硬件,需要设备授权、监管文件、测试材料和真实 iPhone 演示。
|
||||
- 如果定位为个人健康记录工具,需要削弱“医疗服务、诊断、治疗建议、恢复健康”等可能触发医疗器械审查的表述,并明确 AI 仅提供一般健康信息,不替代医生。
|
||||
- 不要在没有材料时直接回复苹果称已经满足要求。
|
||||
|
||||
## 8. 正式环境前仍需完成
|
||||
|
||||
- 正式服务器、域名、HTTPS。
|
||||
- 正式数据库与备份方案。
|
||||
- 正式 JWT 密钥和密钥轮换。
|
||||
- 正式短信服务。
|
||||
- 正式文件/对象存储及删除策略。
|
||||
- 正式 CORS 白名单。
|
||||
- Android 正式 keystore、release 签名、APK/AAB。
|
||||
- Apple 开发者证书、Bundle ID、签名、TestFlight 真机流程。
|
||||
- 隐私政策、服务协议、个人信息清单、第三方 SDK 清单和关于我们的最终定稿。
|
||||
- ICP、公安备案、软著、安全评估及各应用市场账号。
|
||||
- Apple 医疗硬件与医疗建议相关审核材料或产品定位调整。
|
||||
|
||||
## 9. 当前验证情况
|
||||
|
||||
- 最近图片和 UI 修改只做了 `dart format`、`git diff --check`、资源引用和配置文件检查。
|
||||
- 用户明确要求不要反复运行耗时测试,因此最近没有执行完整 Flutter build、安装或全量回归。
|
||||
- Android 和 iOS 图标恢复后尚需用户重新构建查看系统桌面实际裁切。
|
||||
- 登录页透明人物、新背景和键盘流畅度尚需 Android 真机确认。
|
||||
- iOS 尚需真实 iPhone 验证 Apple 登录、蓝牙权限、页面侧滑返回、键盘和 TestFlight 包。
|
||||
|
||||
## 10. 下一窗口建议的第一步
|
||||
|
||||
1. 先运行 `git status --short`,确认用户是否又修改了文件。
|
||||
2. 不要回退当前工作区,不要重新合并 `sccsbc`。
|
||||
3. 让用户先安装当前 Android 包,优先确认:
|
||||
- 登录页透明人物是否仍保持原人物造型。
|
||||
- 登录键盘弹出是否明显更流畅,输入框是否始终可见。
|
||||
- App 图标是否恢复白底且人物显示完整。
|
||||
- 新登录背景和侧边栏背景在真机上的亮度与边界。
|
||||
- 今日健康异常红橙色是否清楚但不过重。
|
||||
4. 根据用户真机截图逐项微调,每次只改明确页面。
|
||||
5. 用户确认当前视觉效果后,再整理未使用的项目图片并提交代码;删除前必须确认没有 Android、iOS 或 Flutter 配置引用。
|
||||
|
||||
## 11. 禁止事项
|
||||
|
||||
- 不使用 Docker。
|
||||
- 不创建其他分支。
|
||||
- 不覆盖或回退用户正在修改的文件。
|
||||
- 不用旧快照替换当前 main。
|
||||
- 不在用户说“先讨论”时直接修改。
|
||||
- 不随意扩大到未启用的医生端、问诊和随访页面。
|
||||
- 不重新设计“小脉健康”人物形象,只允许适配背景、留白、尺寸和系统裁切。
|
||||
- 不为了 UI 修改蓝牙、聊天、删除、注销和历史恢复等业务逻辑。
|
||||
- 不声称构建、安装或真机验证成功,除非实际执行并看到结果。
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# UI System First Pass Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
|
||||
|
||||
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Design Tokens And Common Widgets
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/core/app_colors.dart`
|
||||
- Modify: `health_app/lib/core/app_theme.dart`
|
||||
- Create: `health_app/lib/core/app_design_tokens.dart`
|
||||
- Create: `health_app/lib/core/app_module_visuals.dart`
|
||||
- Create: `health_app/lib/widgets/app_buttons.dart`
|
||||
- Create: `health_app/lib/widgets/app_status_badge.dart`
|
||||
- Create: `health_app/lib/widgets/app_toast.dart`
|
||||
- Create: `health_app/lib/widgets/ai_content.dart`
|
||||
|
||||
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
|
||||
- [ ] Add reusable gradient-outline button and module-aware buttons.
|
||||
- [ ] Add top-center toast helper for light feedback.
|
||||
- [ ] Add shared AI Markdown renderer and plain text cleaner.
|
||||
- [ ] Run `flutter analyze`.
|
||||
|
||||
### Task 2: AI Conversation And Cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
|
||||
- Modify: `health_app/lib/pages/home/home_page.dart`
|
||||
|
||||
- [ ] Apply shared AI content renderer to AI bubbles.
|
||||
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
|
||||
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
|
||||
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
|
||||
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
|
||||
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
|
||||
|
||||
### Task 3: Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/widgets/health_drawer.dart`
|
||||
|
||||
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
|
||||
- [ ] Convert user header to no-frame layout with settings on the right.
|
||||
- [ ] Keep health dashboard as a light panel.
|
||||
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
|
||||
- [ ] Keep conversation records as lightweight rows with summary left and date right.
|
||||
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
|
||||
|
||||
### Task 4: Core Management Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
|
||||
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
|
||||
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
|
||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
||||
- Modify: `health_app/lib/pages/report/report_pages.dart`
|
||||
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
|
||||
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
|
||||
|
||||
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
|
||||
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
|
||||
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
|
||||
- [ ] Normalize list title/subtitle/tag sizes and row heights.
|
||||
- [ ] Run targeted `flutter analyze` on modified files.
|
||||
|
||||
### Task 5: Profile, Health Archive, Diet, Device
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/profile/profile_page.dart`
|
||||
- Modify: `health_app/lib/pages/remaining_pages.dart`
|
||||
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
|
||||
- Modify: `health_app/lib/pages/device/device_management_page.dart`
|
||||
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
|
||||
|
||||
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
|
||||
- [ ] Apply module tokens to diet and device surfaces.
|
||||
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
|
||||
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
|
||||
- [ ] Run targeted `flutter analyze` on modified files.
|
||||
|
||||
### Task 6: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Test: `health_app`
|
||||
|
||||
- [ ] Run `flutter analyze`.
|
||||
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
|
||||
- [ ] Summarize changed files and any deferred surfaces.
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
# Apple Sign-In 集成实施方案
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为健康管家 App 集成 Sign in with Apple,让 iOS 用户可以通过 Apple ID 一键登录/注册。
|
||||
|
||||
**Architecture:** 后端新增 `POST /api/auth/apple-login` 端点,验证 Apple 返回的 identityToken(JWT),查找或创建 User 记录(无需手机号),返回 JWT token。前端添加 `sign_in_with_apple` Flutter 包,在登录页放置 Apple 登录按钮。
|
||||
|
||||
**Tech Stack:** Flutter 3.44 + Dart 3.12 | C# .NET 10 + EF Core + PostgreSQL | sign_in_with_apple 包 | Apple RSA 公钥验证
|
||||
|
||||
**设计决策:**
|
||||
- User.Phone 改为可空(Apple 用户无手机号),PostgreSQL 对多个 NULL 值不违反唯一索引
|
||||
- 新增 `AppleUserId` 字段(可空字符串 + 唯一索引)标识 Apple 用户
|
||||
- Apple 登录首次无需绑定医生(doctorId 可为空),用户可在个人中心后续设置
|
||||
- Apple identityToken 验证:缓存 Apple 公钥,本地验签,验证 audience/issuer/有效期
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — User 实体 + DB 迁移
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.Domain/Entities/user.cs`
|
||||
- Modify: `backend/src/Health.Infrastructure/Data/app_db_context.cs`
|
||||
|
||||
- [ ] **Step 1: User 实体添加 AppleUserId 字段,Phone 改为可空**
|
||||
|
||||
`backend/src/Health.Domain/Entities/user.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Health.Domain.Entities;
|
||||
|
||||
public sealed class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Phone { get; set; } // 改为可空,Apple 用户无手机号
|
||||
public string? AppleUserId { get; set; } // Apple 唯一标识
|
||||
public string Role { get; set; } = "User";
|
||||
public Guid? DoctorId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AppDbContext 添加 AppleUserId 配置**
|
||||
|
||||
`backend/src/Health.Infrastructure/Data/app_db_context.cs` 的 User 配置段:
|
||||
|
||||
```csharp
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // 新增
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — Apple JWT 验证服务
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/src/Health.Infrastructure/Services/apple_token_validator.cs`
|
||||
|
||||
- [ ] **Step 1: 创建 AppleTokenValidator**
|
||||
|
||||
`backend/src/Health.Infrastructure/Services/apple_token_validator.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码 header 获取 kid
|
||||
var rawToken = handler.ReadJwtToken(identityToken);
|
||||
var kid = rawToken.Header.Kid;
|
||||
|
||||
var keys = await GetKeysAsync(ct);
|
||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = AppleIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
var principal = handler.ValidateToken(identityToken, validationParams, out _);
|
||||
var sub = principal.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(sub))
|
||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册服务到 DI**
|
||||
|
||||
在 `backend/src/Health.WebApi/Program.cs` 的 services 注册区添加:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<AppleTokenValidator>();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — AuthService 添加 Apple 登录
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.Application/Auth/AuthContracts.cs`
|
||||
- Modify: `backend/src/Health.Infrastructure/Auth/AuthService.cs`
|
||||
|
||||
- [ ] **Step 1: 添加 AppleLoginCommand DTO 和接口方法**
|
||||
|
||||
`backend/src/Health.Application/Auth/AuthContracts.cs`:
|
||||
|
||||
```csharp
|
||||
namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name); // 新增
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct); // 新增
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AuthService 实现 AppleLoginAsync**
|
||||
|
||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs`:
|
||||
|
||||
在类的顶部依赖注入区添加 `AppleTokenValidator`:
|
||||
|
||||
```csharp
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // 新增
|
||||
) : IAuthService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly JwtProvider _jwt = jwt;
|
||||
private readonly SmsService _sms = sms;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
// ... 现有代码不变
|
||||
```
|
||||
|
||||
在 `LogoutAsync` 方法之后添加新方法,并修改 `AddTokens` 的 phone 参数类型为 `string?`:
|
||||
|
||||
```csharp
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2. 查找已有用户
|
||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||
if (existingUser != null)
|
||||
{
|
||||
// 已有用户 → 直接登录
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken, tokens.refreshToken,
|
||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = null, // Apple 用户未绑定手机
|
||||
AppleUserId = appleUserId,
|
||||
Role = "User",
|
||||
Name = command.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Users.Add(newUser);
|
||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
newTokens.accessToken, newTokens.refreshToken,
|
||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
同时修改 `AddTokens` 方法签名,phone 改为可空:
|
||||
|
||||
```csharp
|
||||
private (string accessToken, string refreshToken) AddTokens(Guid userId, string? phone, string role)
|
||||
{
|
||||
var access = _jwt.GenerateAccessToken(userId, phone ?? "", role);
|
||||
var refresh = _jwt.GenerateRefreshToken();
|
||||
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
return (access, refresh);
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: `AddTokens` 的 phone 参数改为 `string?`,`GenerateAccessToken` 接收到空字符串(Apple 用户无需手机号)。`RefreshAsync` 和 `RegisterAsync` 中现有的 `AddTokens` 调用无需修改(传入 `string?` 的 `user.Phone` 自动匹配)。Admin 的 `AddTokens(AdminId, AdminPhone, "Admin")`(`AdminPhone` 是 `string` 常量)也无需修改。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端 — 添加 Apple 登录端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`
|
||||
|
||||
- [ ] **Step 1: 添加 AppleLoginRequest 和端点**
|
||||
|
||||
`backend/src/Health.WebApi/Endpoints/auth_endpoints.cs`:
|
||||
|
||||
在现有端点中添加:
|
||||
|
||||
```csharp
|
||||
// 在 MapAuthEndpoints 方法内、logout 端点之后添加:
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
```
|
||||
|
||||
在文件末尾添加 DTO:
|
||||
|
||||
```csharp
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
```
|
||||
|
||||
文件最终结果为:
|
||||
|
||||
```csharp
|
||||
using Health.Application.Auth;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||
app.MapPost("/api/auth/apple-login", async (AppleLoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.AppleLoginAsync(new AppleLoginCommand(request.IdentityToken, request.AuthorizationCode, request.Name), ct)));
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
{
|
||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult ToResult(AuthResult result) =>
|
||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}
|
||||
|
||||
public sealed record SendSmsRequest(string Phone);
|
||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||
public sealed record AppleLoginRequest(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
public sealed record RefreshRequest(string RefreshToken);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 后端 — 重启并测试端点连通性
|
||||
|
||||
- [ ] **Step 1: 重启后端服务**
|
||||
|
||||
```bash
|
||||
cd backend/src/Health.WebApi && dotnet run &
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 测试无 token 调用(预期返回 40005,token 无效)**
|
||||
|
||||
```bash
|
||||
curl -s -w "\nHTTP %{http_code}" -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identityToken":"invalid","name":"test"}'
|
||||
```
|
||||
|
||||
Expected: HTTP 200, code=40005, message 包含"验证失败"
|
||||
|
||||
- [ ] **Step 3: 测试真实场景**
|
||||
|
||||
此时无法用 curl 直接测(需要真实的 Apple identityToken)。稍后在 Task 8 中从 App 端联合验证。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 — 添加 sign_in_with_apple 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/pubspec.yaml`
|
||||
|
||||
- [ ] **Step 1: 添加依赖**
|
||||
|
||||
`health_app/pubspec.yaml`,在 dependencies 区添加:
|
||||
|
||||
```yaml
|
||||
# Apple 登录
|
||||
sign_in_with_apple: ^6.1.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 安装依赖**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter pub get
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 前端 — AuthProvider 添加 Apple 登录方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/providers/auth_provider.dart`
|
||||
|
||||
- [ ] **Step 1: 添加 appleLogin 方法**
|
||||
|
||||
`health_app/lib/providers/auth_provider.dart`,在 `login()` 方法之后、`logout()` 方法之前添加:
|
||||
|
||||
```dart
|
||||
/// Apple 登录
|
||||
Future<String?> appleLogin(String identityToken, String? authorizationCode, String? name) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post(
|
||||
'/api/auth/apple-login',
|
||||
data: {
|
||||
'identityToken': identityToken,
|
||||
if (authorizationCode != null) 'authorizationCode': authorizationCode,
|
||||
if (name != null && name.isNotEmpty) 'name': name,
|
||||
},
|
||||
);
|
||||
final data = response.data['data'];
|
||||
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
|
||||
|
||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final user = data['user'];
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(
|
||||
id: user['id'] ?? '',
|
||||
phone: user['phone'] ?? '',
|
||||
role: user['role'] ?? 'User',
|
||||
name: user['name'],
|
||||
avatarUrl: user['avatarUrl'],
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Apple 登录失败: $e';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: `UserInfo` 的 `phone` 字段当前是 `required String`。Apple 用户可能无手机号。`user['phone']` 可能是 null。在 login() 方法中 Phone 断言 `phone: user['phone'] ?? ''` 已有兜底,appleLogin 保持一致。
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter analyze lib/providers/auth_provider.dart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 前端 — 登录页添加 Apple 登录按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `health_app/lib/pages/auth/login_page.dart`
|
||||
|
||||
- [ ] **Step 1: 添加 Apple 登录按钮组件和点击逻辑**
|
||||
|
||||
`health_app/lib/pages/auth/login_page.dart`:
|
||||
|
||||
顶部添加 import:
|
||||
|
||||
```dart
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
```
|
||||
|
||||
在 `_LoginPageState` 类中添加 Apple 登录方法(在 `_startCountdown` 方法之后):
|
||||
|
||||
```dart
|
||||
Future<void> _appleSignIn() async {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [
|
||||
AppleIDAuthorizationScopes.fullName,
|
||||
],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'com.datalumina.YYA.signin',
|
||||
redirectUri: Uri.parse('https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login'),
|
||||
),
|
||||
);
|
||||
|
||||
final identityToken = credential.identityToken;
|
||||
if (identityToken == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Apple 登录未返回身份信息,请重试';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final name = credential.givenName != null && credential.familyName != null
|
||||
? '${credential.familyName}${credential.givenName}'
|
||||
: null;
|
||||
|
||||
final err = await ref.read(authProvider.notifier).appleLogin(
|
||||
identityToken,
|
||||
credential.authorizationCode,
|
||||
name,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
if (err != null) {
|
||||
setState(() => _error = err);
|
||||
} else {
|
||||
_goHome();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (e is SignInWithAppleAuthorizationException) {
|
||||
_error = 'Apple 登录已取消';
|
||||
} else {
|
||||
_error = 'Apple 登录失败: $e';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `_LoginFormCard` 的 children 中,在 `_PrimaryButton` 和"去注册"链接之间(`_PrimaryButton` 之后,`const SizedBox(height: 16)` 之前)插入 Apple 按钮:
|
||||
|
||||
```dart
|
||||
const SizedBox(height: 20),
|
||||
// --- Apple 登录按钮 ---
|
||||
SignInWithAppleButton(
|
||||
onPressed: _appleSignIn,
|
||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||
height: 48,
|
||||
cornerRadius: 14,
|
||||
),
|
||||
// --- 结束 ---
|
||||
const SizedBox(height: 16),
|
||||
```
|
||||
|
||||
即在 build 方法中 `_LoginFormCard` child 的末尾(`_PrimaryButton` 之后、`GestureDetector` "去注册" 之前):
|
||||
|
||||
```dart
|
||||
// ... 上面是 _PrimaryButton 的 const SizedBox(height: 22) 和 _PrimaryButton 本身
|
||||
const SizedBox(height: 20),
|
||||
SignInWithAppleButton(
|
||||
onPressed: _appleSignIn,
|
||||
style: SignInWithAppleButtonStyle.whiteOutline,
|
||||
height: 48,
|
||||
cornerRadius: 14,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_isLogin = !_isLogin;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
}),
|
||||
// ... 去注册/去登录 链接
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 1 补充**: `_submit()` 方法中也需要加 `_loading` 检查,确保 Apple 按钮点击期间短信登录不响应。现有 `_submit` 已有 `setState(() => _loading = true)`,问题不大。
|
||||
|
||||
- [ ] **Step 2: 编译检查**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter analyze lib/pages/auth/login_page.dart
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译 iOS 验证无语法错误**
|
||||
|
||||
```bash
|
||||
cd health_app && flutter build ios --debug --no-codesign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: iOS — Xcode 配置 Sign in with Apple
|
||||
|
||||
- [ ] **Step 1: 在 Xcode 中启用 Sign in with Apple Capability**
|
||||
|
||||
```bash
|
||||
open health_app/ios/Runner.xcworkspace
|
||||
```
|
||||
|
||||
在 Xcode 中操作:
|
||||
1. 选择 Runner target → Signing & Capabilities
|
||||
2. 点击 "+ Capability" → 选择 "Sign in with Apple"
|
||||
3. 确认 .entitlements 文件自动生成了 `com.apple.developer.applesignin` entitlement
|
||||
|
||||
这也会自动在 `project.pbxproj` 中写入相关配置,后续 git diff 可以看到变化。
|
||||
|
||||
- [ ] **Step 2: 验证 .entitlements 文件**
|
||||
|
||||
确认 `health_app/ios/Runner/Runner.entitlements` 包含:
|
||||
|
||||
```xml
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: App Store Connect 配置
|
||||
|
||||
- [ ] **Step 1: 在 App Store Connect 中启用 Sign in with Apple**
|
||||
|
||||
1. 打开 [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. 进入 App 记录 → App 隐私 → Sign in with Apple
|
||||
3. 确认已为 `com.datalumina.YYA` 这个 App ID 启用了 Sign in with Apple capability
|
||||
|
||||
如果还未创建 App Store Connect 上的 App 记录,需要在 Certificates, Identifiers & Profiles 中为 App ID 手动勾选 "Sign in with Apple" capability。
|
||||
|
||||
---
|
||||
|
||||
### 验证计划
|
||||
|
||||
**后端单元验证:**
|
||||
|
||||
```bash
|
||||
# 1. 后端编译通过
|
||||
cd backend/src/Health.WebApi && dotnet build
|
||||
|
||||
# 2. 启动后端
|
||||
cd backend/src/Health.WebApi && dotnet run
|
||||
|
||||
# 3. 测试 Apple 登录端点(无 token 场景)
|
||||
curl -s -X POST "http://localhost:5000/api/auth/apple-login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identityToken":"test","name":"测试"}'
|
||||
# 预期: HTTP 200, body 包含 code=40005
|
||||
|
||||
# 4. 测试现有发送短信端点未被影响
|
||||
curl -s -X POST "http://localhost:5000/api/auth/send-sms" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"13800138000"}'
|
||||
# 预期: HTTP 200, code=0
|
||||
```
|
||||
|
||||
**前端验证:**
|
||||
|
||||
```bash
|
||||
# 1. 编译检查
|
||||
cd health_app && flutter analyze
|
||||
|
||||
# 2. iOS 构建通过
|
||||
flutter build ios --debug --no-codesign
|
||||
|
||||
# 3. 在模拟器上运行
|
||||
flutter run -d "iPhone 17 Pro"
|
||||
# 验证:登录页显示 Apple 登录按钮;点击后弹出 Apple ID 授权界面
|
||||
```
|
||||
|
||||
**端到端验证(模拟器):**
|
||||
1. 模拟器上启动 App → 进入登录页
|
||||
2. 点击 Apple 登录按钮 → Apple 授权弹窗出现 → 选择"继续"
|
||||
3. 后端验证 identityToken → 创建新用户或登录已有 → 返回 JWT → App 进入首页
|
||||
4. 二次打开 App → 如果之前 Apple 登录成功 + refresh_token 有效 → 自动恢复登录态
|
||||
|
||||
**DB 验证:**
|
||||
```sql
|
||||
-- 确认 Apple 用户创建成功
|
||||
SELECT id, phone, apple_user_id, name, role FROM "Users" WHERE apple_user_id IS NOT NULL;
|
||||
```
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,47 +0,0 @@
|
||||
# 通知设置改造设计
|
||||
|
||||
## 目标
|
||||
|
||||
让通知设置展示真实的服务端状态,保存失败时明确提示并恢复原值;将五个健康指标开关收纳到底部选择弹窗中,同时修复通知总开关未真正参与后端投递判断的问题。
|
||||
|
||||
## 页面结构
|
||||
|
||||
主页面保持四个区域:通知总开关、通知类型、健康录入提醒、免打扰时段。
|
||||
|
||||
健康录入提醒区域只展示:
|
||||
|
||||
- “每日健康录入提醒”总开关。
|
||||
- “提醒指标”入口,副标题展示当前已选指标摘要。
|
||||
|
||||
点击“提醒指标”后打开底部弹窗。弹窗使用复选框选择血压、心率、血糖、血氧和体重,至少保留一项,点击“保存”后一次性提交全部指标设置。
|
||||
|
||||
## 状态与保存
|
||||
|
||||
前端使用明确的通知偏好状态对象,不再直接使用无类型的 Map。状态区分首次加载、单项保存、指标批量保存和错误信息。
|
||||
|
||||
- 首次加载成功后才展示设置内容。
|
||||
- 首次加载失败展示错误页和重试按钮,不使用默认值冒充服务端数据。
|
||||
- 普通开关修改时只锁定当前操作,后端成功后采用响应中的完整偏好更新页面。
|
||||
- 保存失败恢复原值并显示错误提示。
|
||||
- 指标弹窗先维护本地草稿,点击保存时一次提交五个字段,避免连续请求和部分成功。
|
||||
- 免打扰时间采用相同的真实保存与失败恢复规则。
|
||||
|
||||
## 后端行为
|
||||
|
||||
保留现有 `/api/notification-prefs` GET 和 PUT 接口。PUT 继续支持部分字段更新并返回保存后的完整偏好。
|
||||
|
||||
通知发送管线必须优先检查 `PushEnabled`。总开关关闭时,不生成或投递任何用户通知;健康录入、用药、随访、医生回复和异常提醒继续读取各自开关,免打扰规则继续生效。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 网络或服务错误显示后端可读信息,无法解析时使用统一提示。
|
||||
- 保存过程中禁止同一设置重复提交。
|
||||
- 指标未选择任何一项时,弹窗内提示至少选择一项,不发送请求。
|
||||
- 后端返回失败时不显示成功状态。
|
||||
|
||||
## 测试
|
||||
|
||||
- Flutter 单元测试覆盖偏好解析、指标摘要、至少选择一项和失败回滚。
|
||||
- Flutter 组件测试覆盖加载失败、指标弹窗批量选择和保存中禁用。
|
||||
- 后端测试覆盖 `PushEnabled=false` 时所有通知类型均不投递,以及各分类开关和免打扰继续生效。
|
||||
- 运行 `flutter analyze`、Flutter 全量测试和相关 .NET 测试。
|
||||
@@ -1,79 +0,0 @@
|
||||
# 健康 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. 逐页精修原则
|
||||
|
||||
- 每次只精修一个真实页面或一组完全同构页面。
|
||||
- 先看真机截图,再确定该页的信息层级、内容组、操作和模块色用量。
|
||||
- 不因建立设计系统而批量重排所有页面;公共组件只提供一致基础,页面布局仍按实际任务设计。
|
||||
@@ -5,7 +5,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
|
||||
<!-- BLE 蓝牙血压计 -->
|
||||
<!-- BLE 蓝牙健康设备(当前实现血压计同步) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
BIN
health_app/assets/branding/drawer_background_v2.png
Normal file
BIN
health_app/assets/branding/drawer_background_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 834 KiB |
BIN
health_app/assets/branding/login_background_v2.png
Normal file
BIN
health_app/assets/branding/login_background_v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -27,15 +27,22 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接血压计</string>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存图片到相册</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以选取饮食照片、体检报告或个人头像</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于在同一局域网内连接小脉健康服务,以完成登录、健康数据同步和文件上传</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -139,7 +139,26 @@ class _RootNavigator extends ConsumerWidget {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) popRoute(ref);
|
||||
},
|
||||
child: buildPage(current, ref),
|
||||
child: Navigator(
|
||||
pages: [
|
||||
for (var index = 0; index < stack.length; index++)
|
||||
MaterialPage<void>(
|
||||
key: ValueKey(_pageKey(stack[index], index)),
|
||||
name: stack[index].name,
|
||||
child: buildPage(stack[index], ref),
|
||||
),
|
||||
],
|
||||
onDidRemovePage: (_) {
|
||||
if (ref.read(routeStackProvider).length > 1) popRoute(ref);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _pageKey(RouteInfo route, int index) {
|
||||
final params = route.params.entries
|
||||
.map((entry) => '${entry.key}=${entry.value}')
|
||||
.join('&');
|
||||
return '$index:${route.name}:$params';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,15 @@ class AppColors {
|
||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||
|
||||
static const Color health = Color(0xFF34D399);
|
||||
static const Color healthLight = Color(0xFFF0FDF7);
|
||||
static const Color healthBorder = Color(0xFFC8F7E1);
|
||||
static const Color health = Color(0xFF2D9D8F);
|
||||
static const Color healthLight = Color(0xFFECF8F5);
|
||||
static const Color healthBorder = Color(0xFFCBE9E2);
|
||||
static const Color medication = Color(0xFF00B8D9);
|
||||
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||
static const Color exercise = Color(0xFF4F7DD9);
|
||||
static const Color exerciseLight = Color(0xFFEFF4FF);
|
||||
static const Color exerciseBorder = Color(0xFFD5E2FF);
|
||||
static const Color exercise = Color(0xFF566FD4);
|
||||
static const Color exerciseLight = Color(0xFFF1F3FD);
|
||||
static const Color exerciseBorder = Color(0xFFD7DCF6);
|
||||
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(0xFFF7F8FA);
|
||||
static const Color background = Color(0xFFF3F5F8);
|
||||
static const Color backgroundSoft = Color(0xFFFFFFFF);
|
||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||
static const Color cardInner = Color(0xFFF1F5F9);
|
||||
@@ -168,7 +168,7 @@ class AppColors {
|
||||
static const LinearGradient healthGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFF6EE7B7), Color(0xFF67E8F9)],
|
||||
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)],
|
||||
);
|
||||
|
||||
static const LinearGradient medicationGradient = LinearGradient(
|
||||
@@ -180,7 +180,7 @@ class AppColors {
|
||||
static const LinearGradient exerciseGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
||||
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)],
|
||||
);
|
||||
|
||||
static const LinearGradient reportGradient = LinearGradient(
|
||||
|
||||
@@ -75,13 +75,13 @@ class AppTextStyles {
|
||||
|
||||
static const TextStyle appBarTitle = TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
static const TextStyle summaryTitle = TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
);
|
||||
@@ -95,13 +95,13 @@ class AppTextStyles {
|
||||
|
||||
static const TextStyle sectionTitle = TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
static const TextStyle listTitle = TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.22,
|
||||
);
|
||||
@@ -122,7 +122,7 @@ class AppTextStyles {
|
||||
|
||||
static const TextStyle formLabel = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
);
|
||||
|
||||
@@ -134,19 +134,19 @@ class AppTextStyles {
|
||||
|
||||
static const TextStyle tag = TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.15,
|
||||
);
|
||||
|
||||
static const TextStyle button = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
static const TextStyle miniButton = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
static const TextStyle chatBody = TextStyle(
|
||||
@@ -158,7 +158,7 @@ class AppTextStyles {
|
||||
static const TextStyle chatTitle = TextStyle(
|
||||
fontSize: 20,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
);
|
||||
|
||||
|
||||
@@ -678,7 +678,7 @@ class AppTheme {
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
toolbarHeight: 52,
|
||||
@@ -724,7 +724,7 @@ class AppTheme {
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
@@ -753,12 +753,12 @@ class AppTheme {
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: text,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
|
||||
@@ -19,7 +19,7 @@ class LoginPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
static const _loginBg = 'assets/branding/login_background_v1.png';
|
||||
static const _loginBg = 'assets/branding/login_background_v2.png';
|
||||
static const _loginActionGradient = AppColors.primaryGradient;
|
||||
|
||||
void _openStaticText(String type) {
|
||||
@@ -220,7 +220,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
@@ -303,7 +303,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -350,7 +350,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
'选择健康服务医生',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -438,10 +438,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
_loginBg,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
child: RepaintBoundary(
|
||||
child: Image.asset(
|
||||
_loginBg,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
@@ -460,16 +462,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
_LoginKeyboardLift(
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
@@ -589,7 +588,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -613,6 +612,23 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginKeyboardLift extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _LoginKeyboardLift({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final lift = (keyboardHeight * 0.42).clamp(0.0, 150.0);
|
||||
return Transform.translate(
|
||||
offset: Offset(0, -lift),
|
||||
transformHitTests: true,
|
||||
child: RepaintBoundary(child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginHero extends StatelessWidget {
|
||||
const _LoginHero();
|
||||
|
||||
@@ -626,7 +642,7 @@ class _LoginHero extends StatelessWidget {
|
||||
'小脉健康',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
@@ -676,26 +692,11 @@ class _BrandMark extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
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,
|
||||
),
|
||||
width: 116,
|
||||
height: 116,
|
||||
child: Image.asset(
|
||||
'assets/branding/health_login_character_transparent.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -981,7 +982,7 @@ class _PrimaryButton extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -555,7 +555,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
_displayValue(record),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.05,
|
||||
),
|
||||
|
||||
@@ -618,7 +618,7 @@ class _DevicesHeader extends StatelessWidget {
|
||||
'已绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -627,7 +627,7 @@ class _DevicesHeader extends StatelessWidget {
|
||||
'$deviceCount 台设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
@@ -685,7 +685,7 @@ class _DeviceSection extends StatelessWidget {
|
||||
type.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -783,7 +783,7 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -822,7 +822,7 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.successText,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -399,7 +399,7 @@ class _ScanningEmpty extends StatelessWidget {
|
||||
scanning ? '正在搜索设备' : '暂未发现设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -479,7 +479,7 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
type?.label ?? '健康设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -516,7 +516,7 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(connecting ? '连接中' : '连接'),
|
||||
|
||||
@@ -539,7 +539,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
'识别结果',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -750,7 +750,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
'AI饮食建议',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ class DietCalorieRing extends StatelessWidget {
|
||||
'$calories',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -341,7 +341,7 @@ class _ExercisePlanGroup extends StatelessWidget {
|
||||
'${_phaseLabel(phase)}(${plans.length})',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
||||
'清空',
|
||||
style: TextStyle(
|
||||
color: AppColors.errorText,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -201,7 +201,7 @@ class _HistoryTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -31,6 +30,26 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
String? _pickedImagePath;
|
||||
Timer? _notificationTimer;
|
||||
double _conversationDragDistance = 0;
|
||||
|
||||
void _startConversationDrawerDrag(DragStartDetails details) {
|
||||
_conversationDragDistance = 0;
|
||||
}
|
||||
|
||||
void _updateConversationDrawerDrag(DragUpdateDetails details) {
|
||||
_conversationDragDistance += details.delta.dx;
|
||||
if (_conversationDragDistance < 0) {
|
||||
_conversationDragDistance = 0;
|
||||
}
|
||||
if (_conversationDragDistance >= 42) {
|
||||
_conversationDragDistance = 0;
|
||||
_scaffoldKey.currentState?.openDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
void _endConversationDrawerDrag(DragEndDetails details) {
|
||||
_conversationDragDistance = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -134,9 +153,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
key: _scaffoldKey,
|
||||
backgroundColor: const Color(0xFFFFFCFF),
|
||||
drawer: const HealthDrawer(),
|
||||
drawerEnableOpenDragGesture: true,
|
||||
drawerDragStartBehavior: DragStartBehavior.down,
|
||||
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||
drawerEnableOpenDragGesture: false,
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -150,8 +167,15 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
children: [
|
||||
_buildHeader(user),
|
||||
Expanded(
|
||||
child: RepaintBoundary(
|
||||
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: _startConversationDrawerDrag,
|
||||
onHorizontalDragUpdate: _updateConversationDrawerDrag,
|
||||
onHorizontalDragEnd: _endConversationDrawerDrag,
|
||||
onHorizontalDragCancel: () => _conversationDragDistance = 0,
|
||||
child: RepaintBoundary(
|
||||
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
@@ -197,7 +221,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
'${_getGreeting()},$name',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -657,7 +681,7 @@ class _HeaderIconButton extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -231,7 +231,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
info.$2,
|
||||
style: AppTextStyles.chatTitle.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -692,7 +692,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
text: mainValue,
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: abnormal
|
||||
? AppColors.error
|
||||
: AppColors.textPrimary,
|
||||
@@ -886,7 +886,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
'确认录入',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -1416,7 +1416,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
'今日健康',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -1570,8 +1570,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
'健康指标',
|
||||
trailing: trailing,
|
||||
status: 'warning',
|
||||
iconColor: AppColors.warning,
|
||||
iconBg: AppColors.warningLight,
|
||||
iconGradient: AppModuleVisuals.health.gradient,
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
),
|
||||
);
|
||||
@@ -1586,6 +1585,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
status: 'done',
|
||||
iconColor: healthIconColor,
|
||||
iconBg: healthIconBg,
|
||||
iconGradient: AppModuleVisuals.health.gradient,
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
),
|
||||
);
|
||||
@@ -1625,6 +1625,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
: (now.hour >= 18 ? 'overdue' : 'pending'),
|
||||
iconColor: exIconColor,
|
||||
iconBg: exIconBg,
|
||||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||||
),
|
||||
);
|
||||
@@ -1642,6 +1643,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
status: 'pending',
|
||||
iconColor: exIconColor,
|
||||
iconBg: exIconBg,
|
||||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||||
),
|
||||
);
|
||||
@@ -1657,6 +1659,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
status: 'overdue',
|
||||
iconColor: exIconColor,
|
||||
iconBg: exIconBg,
|
||||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||||
),
|
||||
);
|
||||
@@ -1672,6 +1675,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
status: 'pending',
|
||||
iconColor: exIconColor,
|
||||
iconBg: exIconBg,
|
||||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||||
),
|
||||
);
|
||||
@@ -1690,12 +1694,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
tasks.add(
|
||||
_taskRow(
|
||||
context,
|
||||
LucideIcons.pill,
|
||||
AppModuleVisuals.medication.icon,
|
||||
'用药',
|
||||
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||||
status: 'done',
|
||||
iconColor: medIconColor,
|
||||
iconBg: medIconBg,
|
||||
iconGradient: AppModuleVisuals.medication.gradient,
|
||||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||
),
|
||||
);
|
||||
@@ -1748,12 +1753,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
tasks.add(
|
||||
_taskRow(
|
||||
context,
|
||||
LucideIcons.pill,
|
||||
AppModuleVisuals.medication.icon,
|
||||
title,
|
||||
trailing: trailing,
|
||||
status: status,
|
||||
iconColor: medIconColor,
|
||||
iconBg: medIconBg,
|
||||
iconGradient: AppModuleVisuals.medication.gradient,
|
||||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||
),
|
||||
);
|
||||
@@ -1788,7 +1794,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
'今日健康',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.15,
|
||||
),
|
||||
@@ -1835,10 +1841,11 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
VoidCallback? onTap,
|
||||
Color? iconColor,
|
||||
Color? iconBg,
|
||||
LinearGradient? iconGradient,
|
||||
}) {
|
||||
final colors = {
|
||||
'done': AppTheme.success,
|
||||
'warning': AppColors.warning,
|
||||
'warning': const Color(0xFFE8562A),
|
||||
'pending': AppColors.textHint,
|
||||
'overdue': AppTheme.error,
|
||||
};
|
||||
@@ -1852,7 +1859,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final ibg = iconBg ?? AppTheme.primaryLight;
|
||||
final textColor = switch (status) {
|
||||
'overdue' => AppColors.error,
|
||||
'warning' => AppColors.warning,
|
||||
'warning' => const Color(0xFFE8562A),
|
||||
_ => AppColors.textPrimary,
|
||||
};
|
||||
return Padding(
|
||||
@@ -1871,10 +1878,15 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: ibg,
|
||||
color: iconGradient == null ? ibg : null,
|
||||
gradient: iconGradient,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(icon, size: 18, color: ic),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: iconGradient == null ? ic : Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
|
||||
@@ -285,7 +285,7 @@ class _MedicationPhaseGroup extends StatelessWidget {
|
||||
'${_phaseLabel(phase)}(${medications.length})',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -196,7 +196,7 @@ class _NotificationCenterPageState
|
||||
'通知中心',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -396,7 +396,7 @@ class _UnreadHint extends StatelessWidget {
|
||||
TextSpan(
|
||||
text: '$unreadCount',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.errorText,
|
||||
),
|
||||
),
|
||||
@@ -507,115 +507,118 @@ class _NotificationRow extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 82),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: showDivider ? 81 : 82),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(visual.icon, size: 21, color: visual.color),
|
||||
),
|
||||
child: Icon(visual.icon, size: 21, color: visual.color),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.w600
|
||||
: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
visual.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: visual.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.message,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 68,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (item.isRead)
|
||||
const SizedBox(height: 8)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
visual.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: visual.color,
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.message,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 68,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (item.isRead)
|
||||
const SizedBox(height: 8)
|
||||
else
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
|
||||
@@ -234,7 +234,7 @@ class _FieldLabel extends StatelessWidget {
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ class _SectionTitle extends StatelessWidget {
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
@@ -266,7 +266,7 @@ class _ActionRow extends StatelessWidget {
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -147,7 +147,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
: '${_selectedDate.month}月${_selectedDate.day}日饮食',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -242,7 +242,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
'${d.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSel ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -441,7 +441,7 @@ class _DietDaySummary extends StatelessWidget {
|
||||
'${date.month}月${date.day}日',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -556,7 +556,7 @@ class _DietTrendPanel extends StatelessWidget {
|
||||
'热量趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -803,7 +803,7 @@ class _DietRecordDetailPageState extends ConsumerState<DietRecordDetailPage> {
|
||||
mealNames[d['mealType']?.toString()] ?? '饮食记录',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -1222,7 +1222,7 @@ class _ExercisePlanOverviewCard extends StatelessWidget {
|
||||
'$done/$total 天',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _ExercisePlanPageState._exerciseVisual.color,
|
||||
),
|
||||
),
|
||||
@@ -1375,7 +1375,7 @@ class _ExerciseTodayInlineTask extends StatelessWidget {
|
||||
!hasItem || isRestDay ? '今日无' : (isCompleted ? '撤销' : '打卡'),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1439,7 +1439,7 @@ class _ExerciseCheckInSummary extends StatelessWidget {
|
||||
'${(progress * 100).round()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _ExercisePlanPageState._exerciseVisual.color,
|
||||
),
|
||||
),
|
||||
@@ -1531,7 +1531,7 @@ class _ExerciseSummaryStat extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -1767,7 +1767,7 @@ class _ExerciseHeroCard extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -1896,7 +1896,7 @@ class _TodayExerciseCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -1943,7 +1943,7 @@ class _TodayExerciseCard extends StatelessWidget {
|
||||
isRestDay ? '休息' : (isDone ? '撤销' : '打卡'),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2062,7 +2062,7 @@ class _ExerciseDayTile extends StatelessWidget {
|
||||
!isToday
|
||||
? '只读'
|
||||
: (isRestDay ? '休息' : (isCompleted ? '撤销' : '打卡')),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -3091,7 +3091,7 @@ class _ArchiveSummary extends StatelessWidget {
|
||||
'$completion%',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -3166,7 +3166,7 @@ class _Section extends StatelessWidget {
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -4375,7 +4375,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
|
||||
### 一、我们收集的信息
|
||||
|
||||
为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:
|
||||
为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:
|
||||
|
||||
1. 账号与身份信息
|
||||
- 手机号码:用于注册、登录、身份识别和账号安全验证。
|
||||
@@ -4397,7 +4397,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
- 未经您主动选择或确认,我们不会读取您的相册其他内容,也不会持续访问您的相机。
|
||||
|
||||
4. 蓝牙设备信息
|
||||
- 当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。
|
||||
- 当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前实际支持以采用标准蓝牙血压服务的血压计为主;其他设备类型将在功能正式开放后另行说明。
|
||||
- 我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。
|
||||
- 通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。
|
||||
|
||||
@@ -4522,7 +4522,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
|
||||
### 一、服务内容
|
||||
|
||||
小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。
|
||||
小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。
|
||||
|
||||
本应用当前可能包含以下服务:
|
||||
|
||||
@@ -4532,7 +4532,6 @@ class StaticTextPage extends ConsumerWidget {
|
||||
- 饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。
|
||||
- AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。
|
||||
- 用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。
|
||||
- 在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。
|
||||
|
||||
### 二、账号注册与使用
|
||||
|
||||
@@ -4551,7 +4550,6 @@ class StaticTextPage extends ConsumerWidget {
|
||||
- 检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。
|
||||
- 饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。
|
||||
- 用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。
|
||||
- 医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。
|
||||
|
||||
如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。
|
||||
|
||||
@@ -4684,7 +4682,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -4709,7 +4707,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
h1Padding: const EdgeInsets.only(bottom: 18),
|
||||
h1: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.35,
|
||||
letterSpacing: 0,
|
||||
@@ -4717,7 +4715,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
h2Padding: const EdgeInsets.only(top: 18, bottom: 8),
|
||||
h2: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.45,
|
||||
letterSpacing: 0,
|
||||
@@ -4725,7 +4723,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
h3Padding: const EdgeInsets.only(top: 16, bottom: 6),
|
||||
h3: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.45,
|
||||
letterSpacing: 0,
|
||||
|
||||
@@ -866,7 +866,7 @@ class _ReportListPageState extends ConsumerState<ReportListPage> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.listTitle.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
|
||||
@@ -465,7 +465,7 @@ class _HealthMetricSheetState extends State<_HealthMetricSheet> {
|
||||
'选择提醒指标',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -114,7 +114,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
@@ -134,7 +134,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
|
||||
@@ -54,7 +54,7 @@ class AdminDrawer extends ConsumerWidget {
|
||||
'系统管理员',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -49,17 +49,17 @@ class AiMarkdownView extends StatelessWidget {
|
||||
p: AppTextStyles.chatBody,
|
||||
a: AppTextStyles.chatBody.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppColors.primary,
|
||||
),
|
||||
strong: AppTextStyles.chatBody.copyWith(fontWeight: FontWeight.w800),
|
||||
strong: AppTextStyles.chatBody.copyWith(fontWeight: FontWeight.w600),
|
||||
h1: AppTextStyles.chatTitle.copyWith(fontSize: 20),
|
||||
h2: AppTextStyles.chatTitle.copyWith(fontSize: 20),
|
||||
h3: AppTextStyles.chatTitle.copyWith(fontSize: 19),
|
||||
listBullet: AppTextStyles.chatBody.copyWith(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
listBulletPadding: const EdgeInsets.only(right: 8),
|
||||
blockquote: AppTextStyles.chatBody.copyWith(
|
||||
|
||||
@@ -84,7 +84,7 @@ class AppGradientOutlineButton extends StatelessWidget {
|
||||
child: AppGradientText(
|
||||
label,
|
||||
gradient: gradient,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -133,7 +133,7 @@ class _MetricCard extends StatelessWidget {
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1,
|
||||
),
|
||||
|
||||
@@ -155,7 +155,7 @@ class _DrawerHeader extends StatelessWidget {
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -135,7 +135,7 @@ class _HeaderStat extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -146,7 +146,7 @@ class _HeaderStat extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -76,7 +76,7 @@ class _DrawerHero extends StatelessWidget {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/branding/drawer_background_v1.png',
|
||||
'assets/branding/drawer_background_v2.png',
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topLeft,
|
||||
),
|
||||
@@ -88,9 +88,9 @@ class _DrawerHero extends StatelessWidget {
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.12),
|
||||
Colors.white.withValues(alpha: 0.35),
|
||||
Colors.white.withValues(alpha: 0.82),
|
||||
Colors.white.withValues(alpha: 0.06),
|
||||
Colors.white.withValues(alpha: 0.18),
|
||||
Colors.white.withValues(alpha: 0.78),
|
||||
],
|
||||
stops: const [0.0, 0.58, 1.0],
|
||||
),
|
||||
@@ -169,7 +169,7 @@ class _AccountHeader extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -214,9 +214,9 @@ class _HealthDashboard extends StatelessWidget {
|
||||
),
|
||||
titleColor: Colors.white,
|
||||
backgroundGradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6), Color(0xFFF472B6)],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||
),
|
||||
child: latestHealth.when(
|
||||
data: (data) => _DashboardMetrics(data: data, ref: ref),
|
||||
@@ -335,7 +335,7 @@ class _MetricTile extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
@@ -355,7 +355,7 @@ class _MetricTile extends StatelessWidget {
|
||||
info.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
@@ -485,8 +485,8 @@ class _NavTile extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
@@ -496,13 +496,13 @@ class _NavTile extends StatelessWidget {
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _colors.last.withValues(alpha: 0.18),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
color: _colors.last.withValues(alpha: 0.10),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(item.icon, color: Colors.white, size: 26),
|
||||
child: Icon(item.icon, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@@ -512,7 +512,7 @@ class _NavTile extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.1,
|
||||
),
|
||||
@@ -548,7 +548,7 @@ class _LightSection extends StatelessWidget {
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -605,7 +605,7 @@ class _Panel extends StatelessWidget {
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
@@ -651,7 +651,7 @@ class _TextAction extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: light ? Colors.white : Color(0xFF6D28D9),
|
||||
),
|
||||
),
|
||||
@@ -829,7 +829,7 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
|
||||
_expanded ? '收起' : '显示全部',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
@@ -867,7 +867,7 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
|
||||
),
|
||||
child: Text(
|
||||
_clearing ? '清空中' : '清空全部',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1026,7 +1026,7 @@ class _HistoryActionButton extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: onTap == null ? AppColors.textHint : color,
|
||||
),
|
||||
),
|
||||
@@ -1080,7 +1080,7 @@ class _DrawerHistoryTile extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected
|
||||
? AppColors.textSecondary
|
||||
: AppColors.textPrimary,
|
||||
|
||||
@@ -108,14 +108,15 @@ void main() {
|
||||
expect(source, contains('const SizedBox(height: 22)'));
|
||||
});
|
||||
|
||||
test('home drawer follows the native drag gesture', () {
|
||||
test('home drawer gesture is limited to the conversation stream', () {
|
||||
final source = File('lib/pages/home/home_page.dart').readAsStringSync();
|
||||
|
||||
expect(source, contains('drawerEnableOpenDragGesture: true'));
|
||||
expect(source, contains('drawerDragStartBehavior: DragStartBehavior.down'));
|
||||
expect(source, contains('drawerEdgeDragWidth:'));
|
||||
expect(source, contains('drawerEnableOpenDragGesture: false'));
|
||||
expect(
|
||||
source,
|
||||
contains('onHorizontalDragUpdate: _updateConversationDrawerDrag'),
|
||||
);
|
||||
expect(source, isNot(contains('_drawerDragStartX')));
|
||||
expect(source, isNot(contains('onHorizontalDragUpdate:')));
|
||||
});
|
||||
|
||||
test('agent capsules use slightly larger icons and labels', () {
|
||||
@@ -127,12 +128,11 @@ void main() {
|
||||
|
||||
expect(source, contains('width: 18'));
|
||||
expect(source, contains('height: 18'));
|
||||
expect(source, contains('Icon(visual.icon, size: 13'));
|
||||
expect(source, contains('visual.icon,'));
|
||||
expect(source, contains('size: 13'));
|
||||
expect(source, contains('fontSize: 15'));
|
||||
expect(
|
||||
source,
|
||||
contains('selected ? AppColors.actionOutlineGradient : null'),
|
||||
);
|
||||
expect(source, contains('gradient: selected'));
|
||||
expect(source, contains('? AppColors.actionOutlineGradient'));
|
||||
expect(source, contains('selected ? null : Colors.transparent'));
|
||||
expect(source, contains('color: Colors.white'));
|
||||
expect(cards, isNot(contains('LucideIcons.footprints')));
|
||||
|
||||
52
health_app/test/native_navigation_test.dart
Normal file
52
health_app/test/native_navigation_test.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/app.dart';
|
||||
import 'package:health_app/core/navigation_provider.dart';
|
||||
import 'package:health_app/providers/auth_provider.dart';
|
||||
|
||||
class _TestAuthNotifier extends AuthNotifier {
|
||||
@override
|
||||
AuthState build() => const AuthState(isLoggedIn: false, isLoading: false);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('native navigator pop keeps the app route stack in sync', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [authProvider.overrideWith(_TestAuthNotifier.new)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(container: container, child: const HealthApp()),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
container
|
||||
.read(routeStackProvider.notifier)
|
||||
.push('staticText', params: const {'type': 'privacy'});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final navigators = tester
|
||||
.stateList<NavigatorState>(find.byType(Navigator))
|
||||
.toList();
|
||||
expect(navigators.length, greaterThanOrEqualTo(2));
|
||||
expect(navigators.last.canPop(), isTrue);
|
||||
final route = ModalRoute.of(tester.element(find.text('隐私协议').first));
|
||||
expect(route, isA<PageRoute<void>>());
|
||||
expect((route! as PageRoute<void>).popGestureEnabled, isTrue);
|
||||
|
||||
await tester.dragFrom(const Offset(5, 100), const Offset(500, 0));
|
||||
await tester.pump(const Duration(milliseconds: 351));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
expect(container.read(routeStackProvider).length, 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user