From 4d96ab0912a455d531bf28dc3f15d847ce7f4f0f Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Thu, 10 Sep 2026 07:10:55 +0800 Subject: [PATCH] Update watch face: modular refactor + OWM/Solar/background + codegen tools - Split render into FieldTable/Fields/Icons/Layout/Settings modules - Add Fenix8V3Background, Owm (weather), Solar modules - Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg) - Tune README/manifest/strings/settings --- README.md | 38 ++- manifest.xml | 2 + resources-chn/strings/fields.xml | 52 +++- resources-chn/strings/strings.xml | 1 + resources/settings/properties.xml | 1 + resources/settings/settings.xml | 204 ++++++++++++++ resources/strings/fields.xml | 52 +++- resources/strings/strings.xml | 1 + source/Fenix8V3App.mc | 63 ++++- source/Fenix8V3Background.mc | 167 +++++++++++ source/Fenix8V3View.mc | 182 +++++++++--- source/FieldTable.mc | 23 +- source/Fields.mc | 447 ++++++++++++++++++++++-------- source/Icons.mc | 57 ++-- source/Layout.mc | 138 ++++----- source/Owm.mc | 68 +++++ source/Settings.mc | 72 +++-- source/Solar.mc | 120 ++++++++ source/Themes.mc | 17 +- tools/gen_fields.py | 98 +++++-- tools/gen_icons.py | 27 +- tools/gen_preview_svg.py | 11 +- tools/gen_themes.py | 52 ++-- 23 files changed, 1549 insertions(+), 344 deletions(-) create mode 100644 source/Fenix8V3Background.mc create mode 100644 source/Owm.mc create mode 100644 source/Solar.mc diff --git a/README.md b/README.md index 97abbea..ef5082c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,10 @@ fenix8v3-watchface/ │ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式 │ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制 │ ├── Icons.mc # 图标:位图三件套 + 其余图元 -│ ├── Fields.mc # 75 项数据字段的取值与格式化 +│ ├── Fields.mc # 125 项数据字段的取值与格式化 +│ ├── Solar.mc # 日出/晨昏蒙影/黄金蓝调时刻的太阳高度角计算 +│ ├── Owm.mc # OpenWeatherMap 缓存读取 +│ ├── Fenix8V3Background.mc # 后台服务:拉取 OWM │ ├── Settings.mc # 所有设置项的读取与默认值 │ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表 │ └── Themes.mc # ⚠️ 自动生成:七套主题配色 @@ -178,34 +181,41 @@ python3 tools/gen_themes.py && python3 tools/gen_preview_svg.py ### 数据字段 -四个数据位共用一张 **75 项** 的字段表,编号沿用 [watchface.io/docs/datafields](https://watchface.io/docs/datafields),两边可以直接对照。底部两格的小标签跟随所选字段自动变化,不需要单独配置。 +四个数据位共用一张 **125 项** 的字段表,编号沿用 [watchface.io/docs/datafields](https://watchface.io/docs/datafields),两边可以直接对照。底部两格的小标签跟随所选字段自动变化,不需要单独配置。 | 分类 | 字段 | |------|------| -| 日期时间 | 时间、UTC 时间、第二时区 1/2、星期月日、星期日、月日、日、星期(简写/全称)、月份(名称/数字)、上午下午、周数(ISO / 日历) | -| 活动 | 步数、步数取整、剩余步数、卡路里、活动卡路里、活动分钟(总/中强度/高强度/本周)、距离、上楼层数、下楼层数、爬升高度、推行距离、推行次数 | +| 日期时间 | 秒(前导零两种)、时间、UTC 时间、第二时区 1/2、星期月日、星期日、月日、日、星期(简写/全称)、月份(名称/数字)、上午下午、周数(ISO / 日历) | +| 活动 | 步数、步数取整、剩余步数、卡路里、活动卡路里、活动分钟(总/中强度/高强度/本周)、距离、上楼层数、下楼层数、爬升高度、推行距离、推行次数、**本周与近 7 天累计**(步数/卡路里/距离/楼层/活动分钟) | | 身体 | 心率、静息心率、压力指数、血氧、呼吸频率、身体电量、活动条(正/反向)、体重、BMI、恢复时间、最大摄氧量(跑/骑) | | 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 | -| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明 | -| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间 | +| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明、民用/航海/天文晨昏蒙影(各 2 项)、黄金时刻(晨/昏)、蓝调时刻(晨/昏) | +| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间、海平面气压、紫外线、能见度、露点 | +| OpenWeatherMap | 气温、体感、最高/最低、天气描述(简/详)、湿度、气压、能见度、风速、风向角、风向、阵风、1h 降雨、1h 降雪、云量、降水概率、露点、明日与后日气温及高低温、城市名、更新时间(共 25 项) | | 自定义 | 自定义文字 1 / 2 / 3 | 加字段:在 `tools/gen_fields.py` 的 `FIELDS` 表里加一行,跑一遍生成器,再到 `source/Fields.mc` 的 `value()` 里补一个分支。设置项、两种语言的文案、表盘标签会自动跟上。 -### ⚠️ 没有实现的字段,以及原因 +### 秒针与后台天气 -watchface.io 的完整目录约 300 项,本表只收了 **手表本机能算出来的** 那些。其余几类不是没做,是一个自包含的 Connect IQ 表盘做不了: +**秒**(850/869)通过 `onPartialUpdate` 每秒重绘:只有当某个数据位真的选了秒时才请求这个回调,并且只在该格的裁剪区内重绘,不动整个表盘。常亮的 AMOLED 机型不参与——那时数据位本来就不显示。 + +**OpenWeatherMap** 需要用户在设置里填自己的 API key(免费档即可)。没填就完全不联网,25 个 OWM 字段都显示 `--`。填了之后由后台服务每 30 分钟拉一次(当前天气 + `cnt=16` 的短预报,够算出明日/后日高低温),结果压平成一个小字典存进 `Application.Storage`,表盘只读不联网。 + +位置来自天气观测点或最近一次 GPS 定位,并缓存在 Storage 里——两个来源都会间歇性为空,缓存让日出和晨昏蒙影不会一会儿有一会儿没有。 + +### ⚠️ 仍然没有实现的,以及原因 | 没做的 | 原因 | |--------|------| -| OpenWeatherMap(约 30 项)、StormGlass.io(5 项) | 需要 `Communications.makeWebRequest` + 用户自备 API key + 后台服务定时拉取与缓存。是另一个子系统,且要求用户自己去注册 key | -| 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 不暴露按运动分类的历史累计。这些数据在 GreenBlack 那边是通过手机侧与 Garmin Connect 的集成同步来的,本机 API 只有 `ActivityMonitor.getHistory()` 的最近几天步数/卡路里/距离 | +| 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 只有 `ActivityMonitor.getHistory()` 的按天总计(步数/卡路里/距离/楼层/活动分钟),**没有**按运动分类的历史。所以通用的本周/近 7 天累计已经做了,按跑步/骑行/游泳分开的做不了——那些在 GreenBlack 那边是手机侧与 Garmin Connect 集成同步来的 | +| StormGlass.io 潮汐(5 项) | 需要额外的付费 API key,且与本表盘的定位无关 | | 第三方 App 复杂功能(CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App,并通过 Complications API 订阅 | | 日历事件、计时器、倒计时 | 日历不对 CIQ 开放;表盘拿不到触摸输入,没法做启停 | -| 秒数 | 需要 `onPartialUpdate` 每秒重绘。技术上可行,但会明显吃电,暂未做 | -| 晨昏蒙影、黄金/蓝调时刻(约 14 项) | 可以自己算太阳高度角推出来,只是还没写;月龄和月球照明已经是这样算的 | -| 睡眠得分、训练状态 | `ActivityMonitor.Info` 上没有这两个字段(SDK 9.1.0 实测) | +| 睡眠得分、训练状态、完赛/配速预测 | `ActivityMonitor.Info` 与 `UserProfile` 上没有这些字段(SDK 9.1.0 实测) | +| 高尔夫成绩 | 无对应 API | | 标记类字段(Marker,约 35 项) | 那是给刻度型表盘做环形标记用的,本表盘的进度环用的是自己的指标体系 | +| OWM 的 AQI 与 UVI | 免费档的 `/data/2.5/weather` 不返回;要 One Call 3.0 订阅。露点已用 Magnus 公式从温湿度算出 | --- @@ -228,7 +238,7 @@ watchface.io 的完整目录约 300 项,本表只收了 **手表本机能算 ### 权限 -`manifest.xml` 声明两项:`UserProfile`(静息心率、体重、VO2max)与 `SensorHistory`(血氧、身体电量)。用户安装时会看到授权提示。 +`manifest.xml` 声明四项:`UserProfile`(静息心率、体重、VO2max)、`SensorHistory`(血氧、身体电量)、`Communications` 与 `Background`(OpenWeatherMap)。用户安装时会看到授权提示。 > `Sensor` 权限**不能**用于 `type="watchface"`(除非同时申请 `Background`),编译器会直接拒绝。所以海拔与气压走 `Activity.Info` 而不是 `Sensor.getInfo()`。 diff --git a/manifest.xml b/manifest.xml index c7cbb4c..eff6965 100644 --- a/manifest.xml +++ b/manifest.xml @@ -38,6 +38,8 @@ requested, so altitude and pressure come from Activity.Info instead. --> + + diff --git a/resources-chn/strings/fields.xml b/resources-chn/strings/fields.xml index bba8ac3..04a1123 100644 --- a/resources-chn/strings/fields.xml +++ b/resources-chn/strings/fields.xml @@ -15,6 +15,8 @@ 月份数字 上午/下午 周数 (ISO) + + 秒 (前导零) 周数 步数 步数 (取整) @@ -31,6 +33,15 @@ 爬升高度 推行距离 推行次数 + 本周步数 + 近 7 天步数 + 本周卡路里 + 近 7 天卡路里 + 本周距离 + 近 7 天距离 + 本周楼层 + 近 7 天楼层 + 近 7 天活动分钟 心率 静息心率 压力指数 @@ -61,6 +72,16 @@ 距太阳事件 月龄 月球照明 + 民用晨光始 + 航海晨光始 + 天文晨光始 + 蓝调时刻 (晨) + 黄金时刻 (晨) + 黄金时刻 (昏) + 蓝调时刻 (昏) + 民用昏影终 + 航海昏影终 + 天文昏影终 气温 最高/最低气温 体感温度 @@ -72,12 +93,41 @@ 风向角 风向 天气更新时间 + 海平面气压 + 紫外线指数 + 能见度 + 露点 + OWM 气温 + OWM 最高/最低 + OWM 体感温度 + OWM 最高温 + OWM 最低温 + OWM 天气 + OWM 天气详述 + OWM 湿度 + OWM 气压 + OWM 能见度 + OWM 风速 + OWM 风向角 + OWM 风向 + OWM 阵风 + OWM 1h 降雨 + OWM 1h 降雪 + OWM 云量 + OWM 降水概率 + OWM 露点 + OWM 明日气温 + OWM 明日高低温 + OWM 后日气温 + OWM 后日高低温 + OWM 城市 + OWM 更新时间 自定义文字 1 自定义文字 2 自定义文字 3 - ,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,,, + ,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,秒,秒,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,周步数,7天,周千卡,7天,周距离,7天,周楼层,7天,7天,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,民用晨,航海晨,天文晨,蓝调晨,黄金晨,黄金昏,蓝调昏,民用昏,航海昏,天文昏,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,气压,紫外线,能见度,露点,气温,高低温,体感,最高,最低,天气,天气,湿度,气压,能见度,风速,风向,风向,阵风,降雨,降雪,云量,降水,露点,明日,明日,后日,后日,城市,更新,,, 步数 卡路里 diff --git a/resources-chn/strings/strings.xml b/resources-chn/strings/strings.xml index c94db93..09ce2aa 100644 --- a/resources-chn/strings/strings.xml +++ b/resources-chn/strings/strings.xml @@ -13,6 +13,7 @@ 温度单位 第二时区 1(相对本地分钟数) 第二时区 2(相对本地分钟数) + OpenWeatherMap API 密钥 自定义文字 1 自定义文字 2 自定义文字 3 diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index 20ed93d..748dd71 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -13,6 +13,7 @@ 0 0 0 + diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml index 464ed79..d3f27ff 100644 --- a/resources/settings/settings.xml +++ b/resources/settings/settings.xml @@ -29,6 +29,8 @@ @Strings.FldMonthNum @Strings.FldMeridiem @Strings.FldWeekIso + @Strings.FldSeconds + @Strings.FldSeconds0 @Strings.FldWeekCal @Strings.FldSteps @Strings.FldStepsRound @@ -45,6 +47,15 @@ @Strings.FldMetersUp @Strings.FldPushDist @Strings.FldPushes + @Strings.FldStepsWeek + @Strings.FldSteps7d + @Strings.FldCalWeek + @Strings.FldCal7d + @Strings.FldDistWeek + @Strings.FldDist7d + @Strings.FldFloorsWeek + @Strings.FldFloors7d + @Strings.FldActiveMin7d @Strings.FldHeartRate @Strings.FldRestingHr @Strings.FldStress @@ -75,6 +86,16 @@ @Strings.FldUntilSun @Strings.FldMoonAge @Strings.FldMoonLit + @Strings.FldCivilAm + @Strings.FldNauticalAm + @Strings.FldAstroAm + @Strings.FldBlueAm + @Strings.FldGoldenAm + @Strings.FldGoldenPm + @Strings.FldBluePm + @Strings.FldCivilPm + @Strings.FldNauticalPm + @Strings.FldAstroPm @Strings.FldTemp @Strings.FldTempHiLo @Strings.FldFeelsLike @@ -86,6 +107,35 @@ @Strings.FldWindBearing @Strings.FldWindDir @Strings.FldWeatherTime + @Strings.FldPressureSea + @Strings.FldUvi + @Strings.FldVisibility + @Strings.FldDewPoint + @Strings.FldOwmTemp + @Strings.FldOwmMinMax + @Strings.FldOwmFeels + @Strings.FldOwmMax + @Strings.FldOwmMin + @Strings.FldOwmDescShort + @Strings.FldOwmDescLong + @Strings.FldOwmHumidity + @Strings.FldOwmPressure + @Strings.FldOwmVisibility + @Strings.FldOwmWind + @Strings.FldOwmWindDeg + @Strings.FldOwmWindDir + @Strings.FldOwmGust + @Strings.FldOwmRain + @Strings.FldOwmSnow + @Strings.FldOwmClouds + @Strings.FldOwmPrecip + @Strings.FldOwmDew + @Strings.FldOwmTomorrow + @Strings.FldOwmMinMaxTmrw + @Strings.FldOwmDayAfter + @Strings.FldOwmMinMaxD2 + @Strings.FldOwmCity + @Strings.FldOwmUpdated @Strings.FldCustom1 @Strings.FldCustom2 @Strings.FldCustom3 @@ -109,6 +159,8 @@ @Strings.FldMonthNum @Strings.FldMeridiem @Strings.FldWeekIso + @Strings.FldSeconds + @Strings.FldSeconds0 @Strings.FldWeekCal @Strings.FldSteps @Strings.FldStepsRound @@ -125,6 +177,15 @@ @Strings.FldMetersUp @Strings.FldPushDist @Strings.FldPushes + @Strings.FldStepsWeek + @Strings.FldSteps7d + @Strings.FldCalWeek + @Strings.FldCal7d + @Strings.FldDistWeek + @Strings.FldDist7d + @Strings.FldFloorsWeek + @Strings.FldFloors7d + @Strings.FldActiveMin7d @Strings.FldHeartRate @Strings.FldRestingHr @Strings.FldStress @@ -155,6 +216,16 @@ @Strings.FldUntilSun @Strings.FldMoonAge @Strings.FldMoonLit + @Strings.FldCivilAm + @Strings.FldNauticalAm + @Strings.FldAstroAm + @Strings.FldBlueAm + @Strings.FldGoldenAm + @Strings.FldGoldenPm + @Strings.FldBluePm + @Strings.FldCivilPm + @Strings.FldNauticalPm + @Strings.FldAstroPm @Strings.FldTemp @Strings.FldTempHiLo @Strings.FldFeelsLike @@ -166,6 +237,35 @@ @Strings.FldWindBearing @Strings.FldWindDir @Strings.FldWeatherTime + @Strings.FldPressureSea + @Strings.FldUvi + @Strings.FldVisibility + @Strings.FldDewPoint + @Strings.FldOwmTemp + @Strings.FldOwmMinMax + @Strings.FldOwmFeels + @Strings.FldOwmMax + @Strings.FldOwmMin + @Strings.FldOwmDescShort + @Strings.FldOwmDescLong + @Strings.FldOwmHumidity + @Strings.FldOwmPressure + @Strings.FldOwmVisibility + @Strings.FldOwmWind + @Strings.FldOwmWindDeg + @Strings.FldOwmWindDir + @Strings.FldOwmGust + @Strings.FldOwmRain + @Strings.FldOwmSnow + @Strings.FldOwmClouds + @Strings.FldOwmPrecip + @Strings.FldOwmDew + @Strings.FldOwmTomorrow + @Strings.FldOwmMinMaxTmrw + @Strings.FldOwmDayAfter + @Strings.FldOwmMinMaxD2 + @Strings.FldOwmCity + @Strings.FldOwmUpdated @Strings.FldCustom1 @Strings.FldCustom2 @Strings.FldCustom3 @@ -189,6 +289,8 @@ @Strings.FldMonthNum @Strings.FldMeridiem @Strings.FldWeekIso + @Strings.FldSeconds + @Strings.FldSeconds0 @Strings.FldWeekCal @Strings.FldSteps @Strings.FldStepsRound @@ -205,6 +307,15 @@ @Strings.FldMetersUp @Strings.FldPushDist @Strings.FldPushes + @Strings.FldStepsWeek + @Strings.FldSteps7d + @Strings.FldCalWeek + @Strings.FldCal7d + @Strings.FldDistWeek + @Strings.FldDist7d + @Strings.FldFloorsWeek + @Strings.FldFloors7d + @Strings.FldActiveMin7d @Strings.FldHeartRate @Strings.FldRestingHr @Strings.FldStress @@ -235,6 +346,16 @@ @Strings.FldUntilSun @Strings.FldMoonAge @Strings.FldMoonLit + @Strings.FldCivilAm + @Strings.FldNauticalAm + @Strings.FldAstroAm + @Strings.FldBlueAm + @Strings.FldGoldenAm + @Strings.FldGoldenPm + @Strings.FldBluePm + @Strings.FldCivilPm + @Strings.FldNauticalPm + @Strings.FldAstroPm @Strings.FldTemp @Strings.FldTempHiLo @Strings.FldFeelsLike @@ -246,6 +367,35 @@ @Strings.FldWindBearing @Strings.FldWindDir @Strings.FldWeatherTime + @Strings.FldPressureSea + @Strings.FldUvi + @Strings.FldVisibility + @Strings.FldDewPoint + @Strings.FldOwmTemp + @Strings.FldOwmMinMax + @Strings.FldOwmFeels + @Strings.FldOwmMax + @Strings.FldOwmMin + @Strings.FldOwmDescShort + @Strings.FldOwmDescLong + @Strings.FldOwmHumidity + @Strings.FldOwmPressure + @Strings.FldOwmVisibility + @Strings.FldOwmWind + @Strings.FldOwmWindDeg + @Strings.FldOwmWindDir + @Strings.FldOwmGust + @Strings.FldOwmRain + @Strings.FldOwmSnow + @Strings.FldOwmClouds + @Strings.FldOwmPrecip + @Strings.FldOwmDew + @Strings.FldOwmTomorrow + @Strings.FldOwmMinMaxTmrw + @Strings.FldOwmDayAfter + @Strings.FldOwmMinMaxD2 + @Strings.FldOwmCity + @Strings.FldOwmUpdated @Strings.FldCustom1 @Strings.FldCustom2 @Strings.FldCustom3 @@ -269,6 +419,8 @@ @Strings.FldMonthNum @Strings.FldMeridiem @Strings.FldWeekIso + @Strings.FldSeconds + @Strings.FldSeconds0 @Strings.FldWeekCal @Strings.FldSteps @Strings.FldStepsRound @@ -285,6 +437,15 @@ @Strings.FldMetersUp @Strings.FldPushDist @Strings.FldPushes + @Strings.FldStepsWeek + @Strings.FldSteps7d + @Strings.FldCalWeek + @Strings.FldCal7d + @Strings.FldDistWeek + @Strings.FldDist7d + @Strings.FldFloorsWeek + @Strings.FldFloors7d + @Strings.FldActiveMin7d @Strings.FldHeartRate @Strings.FldRestingHr @Strings.FldStress @@ -315,6 +476,16 @@ @Strings.FldUntilSun @Strings.FldMoonAge @Strings.FldMoonLit + @Strings.FldCivilAm + @Strings.FldNauticalAm + @Strings.FldAstroAm + @Strings.FldBlueAm + @Strings.FldGoldenAm + @Strings.FldGoldenPm + @Strings.FldBluePm + @Strings.FldCivilPm + @Strings.FldNauticalPm + @Strings.FldAstroPm @Strings.FldTemp @Strings.FldTempHiLo @Strings.FldFeelsLike @@ -326,6 +497,35 @@ @Strings.FldWindBearing @Strings.FldWindDir @Strings.FldWeatherTime + @Strings.FldPressureSea + @Strings.FldUvi + @Strings.FldVisibility + @Strings.FldDewPoint + @Strings.FldOwmTemp + @Strings.FldOwmMinMax + @Strings.FldOwmFeels + @Strings.FldOwmMax + @Strings.FldOwmMin + @Strings.FldOwmDescShort + @Strings.FldOwmDescLong + @Strings.FldOwmHumidity + @Strings.FldOwmPressure + @Strings.FldOwmVisibility + @Strings.FldOwmWind + @Strings.FldOwmWindDeg + @Strings.FldOwmWindDir + @Strings.FldOwmGust + @Strings.FldOwmRain + @Strings.FldOwmSnow + @Strings.FldOwmClouds + @Strings.FldOwmPrecip + @Strings.FldOwmDew + @Strings.FldOwmTomorrow + @Strings.FldOwmMinMaxTmrw + @Strings.FldOwmDayAfter + @Strings.FldOwmMinMaxD2 + @Strings.FldOwmCity + @Strings.FldOwmUpdated @Strings.FldCustom1 @Strings.FldCustom2 @Strings.FldCustom3 @@ -397,6 +597,10 @@ + + + + diff --git a/resources/strings/fields.xml b/resources/strings/fields.xml index 99e8030..a5bde20 100644 --- a/resources/strings/fields.xml +++ b/resources/strings/fields.xml @@ -15,6 +15,8 @@ Month (6) AM/PM Week Number (ISO) + Seconds + Seconds (09) Week Number Steps Steps Rounded @@ -31,6 +33,15 @@ Distance Climbed Push Distance Pushes + Steps (Week to Date) + Steps (Last 7 Days) + Calories (Week to Date) + Calories (Last 7 Days) + Distance (Week to Date) + Distance (Last 7 Days) + Floors (Week to Date) + Floors (Last 7 Days) + Active Minutes (7 Days) Heart Rate Resting Heart Rate Stress Score @@ -61,6 +72,16 @@ Time Until Sun Event Moon Age Moon Illumination + Civil Twilight Start + Nautical Twilight Start + Astronomical Twilight Start + Blue Hour (AM) + Golden Hour (AM) + Golden Hour (PM) + Blue Hour (PM) + Civil Twilight End + Nautical Twilight End + Astronomical Twilight End Temperature Temperature High/Low Feels Like @@ -72,12 +93,41 @@ Wind Bearing Wind Direction Weather Update Time + Pressure (Sea Level) + UV Index + Visibility + Dew Point + OWM Temperature + OWM Max/Min Temp + OWM Feels Like + OWM Temp Max + OWM Temp Min + OWM Description + OWM Description (Long) + OWM Humidity + OWM Pressure + OWM Visibility + OWM Wind Speed + OWM Wind Degrees + OWM Wind Direction + OWM Wind Gust + OWM Rain 1h + OWM Snow 1h + OWM Cloud Cover + OWM Precipitation % + OWM Dew Point + OWM Temp Tomorrow + OWM Min/Max Tomorrow + OWM Temp Day After + OWM Min/Max Day After + OWM City Name + OWM Update Time Custom Text 1 Custom Text 2 Custom Text 3 - ,TIME,UTC,TZ1,TZ2,DATE,DATE,DATE,DAY,DAY,DAY,MON,MON,,WEEK,WEEK,STEP,STEP,TO GO,KCAL,ACAL,MIN,MOD,VIG,WEEK,DST,FLR,DESC,ASC,PUSH,PUSH,HR,RHR,STRS,SPO2,RESP,BODY,MOVE,MOVE,WT,BMI,RCVR,VO2,VO2,BATT,DAYS,BATT,ALRM,MSG,DND,BT,SOLR,ALT,hPa,hPa,RISE,SET,SUN,SUN,MOON,MOON,TEMP,HI/LO,FEEL,HIGH,LOW,HUM,RAIN,WIND,WIND,WIND,WX,,, + ,TIME,UTC,TZ1,TZ2,DATE,DATE,DATE,DAY,DAY,DAY,MON,MON,,WEEK,SEC,SEC,WEEK,STEP,STEP,TO GO,KCAL,ACAL,MIN,MOD,VIG,WEEK,DST,FLR,DESC,ASC,PUSH,PUSH,WEEK,7D,WEEK,7D,WEEK,7D,WEEK,7D,7D,HR,RHR,STRS,SPO2,RESP,BODY,MOVE,MOVE,WT,BMI,RCVR,VO2,VO2,BATT,DAYS,BATT,ALRM,MSG,DND,BT,SOLR,ALT,hPa,hPa,RISE,SET,SUN,SUN,MOON,MOON,CIVIL,NAUT,ASTR,BLUE,GOLD,GOLD,BLUE,CIVIL,NAUT,ASTR,TEMP,HI/LO,FEEL,HIGH,LOW,HUM,RAIN,WIND,WIND,WIND,WX,hPa,UVI,VIS,DEW,TEMP,HI/LO,FEEL,HIGH,LOW,WX,WX,HUM,hPa,VIS,WIND,WIND,WIND,GUST,RAIN,SNOW,CLD,RAIN,DEW,TMRW,TMRW,D+2,D+2,CITY,OWM,,, Steps Calories diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index babfaf1..061a754 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -13,6 +13,7 @@ Temperature Unit Alternate Time 1 (minutes from local) Alternate Time 2 (minutes from local) + OpenWeatherMap API Key Custom Text 1 Custom Text 2 Custom Text 3 diff --git a/source/Fenix8V3App.mc b/source/Fenix8V3App.mc index b9bfa66..21981fa 100644 --- a/source/Fenix8V3App.mc +++ b/source/Fenix8V3App.mc @@ -1,12 +1,25 @@ import Toybox.Application; +import Toybox.Background; import Toybox.Lang; +import Toybox.System; +import Toybox.Time; import Toybox.WatchUi; +import Owm; -// Application shell. A watch face has no menus or input of its own, so this -// only hands back the view and forces a repaint when the wearer changes -// something in the Connect IQ settings. +// ============================================================================ +// 应用入口 +// ---------------------------------------------------------------------------- +// 表盘没有菜单也拿不到输入,所以这个壳子只做三件事: +// 1. 交出 view +// 2. 设置变更时请求重绘 +// 3. 管理拉取 OpenWeatherMap 的后台服务 +// ============================================================================ class Fenix8V3App extends Application.AppBase { + // OWM 免费档的调用额度很宽裕,但表盘每分钟才重绘一次、数据本身也是小时级的, + // 拉太勤只是白白唤醒射频费电。半小时是个合适的折中。 + const OWM_PERIOD_MIN = 30; + function initialize() { AppBase.initialize(); } @@ -17,15 +30,55 @@ class Fenix8V3App extends Application.AppBase { function onStop(state as Dictionary?) as Void { } + // ⚠️ 定时器的注册必须放在 getInitialView 里,不能放 onStart。 + // 因为 onStart **在后台服务进程里也会被调用一次**,而在后台进程里调 + // registerForTemporalEvent 会抛 Out of Bounds。 + // getInitialView 只在前台进程运行,是安全的位置。 function getInitialView() as [Views] or [Views, InputDelegates] { + scheduleWeather(); return [new Fenix8V3View()]; } - // Fires when the phone pushes new settings down. The view reads every - // preference fresh on each draw, so a repaint is all that is needed. + function getServiceDelegate() as [System.ServiceDelegate] { + return [new Fenix8V3Background()]; + } + + // 只有用户真的填了 API key 才注册定时器 —— 没填就没什么可拉的, + // 注册了纯属浪费电。反过来,用户把 key 清空时也要及时注销。 + function scheduleWeather() as Void { + if (!(Background has :registerForTemporalEvent)) { return; } + // ⚠️ deleteTemporalEvent 在「本来就没注册」时会抛 Out of Bounds, + // 而这个错误是 VM 级的,**try/catch 抓不住**。 + // 所以这里不去问系统当前状态,而是自己在 Storage 里记一个标志位。 + // 重复注册本身是无害的(只是重置周期),所以只在状态真的变化时动手。 + var want = Owm.enabled(); + var have = Application.Storage.getValue("bgOn"); + have = (have != null) && (have as Boolean); + if (want == have) { return; } + if (want) { + Background.registerForTemporalEvent( + new Time.Duration(OWM_PERIOD_MIN * 60)); + } else { + Background.deleteTemporalEvent(); + } + Application.Storage.setValue("bgOn", want); + } + + // 手机把新设置推下来时触发。 + // view 每次绘制都是现读设置,所以重绘一下就够了; + // 但天气定时器要跟着新的 key 状态启停,所以先过一遍 scheduleWeather。 function onSettingsChanged() as Void { + scheduleWeather(); WatchUi.requestUpdate(); } + + // 后台服务把结果通过这里交回前台。存进 Storage 供表盘读取,然后重绘。 + function onBackgroundData(data as Application.PersistableType) as Void { + if (data != null) { + Application.Storage.setValue(Owm.STORE, data); + WatchUi.requestUpdate(); + } + } } function getApp() as Fenix8V3App { diff --git a/source/Fenix8V3Background.mc b/source/Fenix8V3Background.mc new file mode 100644 index 0000000..b1244c2 --- /dev/null +++ b/source/Fenix8V3Background.mc @@ -0,0 +1,167 @@ +import Toybox.Lang; +import Toybox.Application; +import Toybox.Background; +import Toybox.Communications; +import Toybox.System; +import Toybox.Time; +import Toybox.Position; + +// ============================================================================ +// OpenWeatherMap —— 后台拉取端 +// ---------------------------------------------------------------------------- +// 表盘进程绝大多数时间没在运行,onUpdate 又必须够快,不可能在绘制路径里发 +// HTTP 请求。Connect IQ 的方案是**后台服务**:系统按定时器唤醒一个独立进程, +// 在里面发请求,把结果通过 Background.exit() 交回前台,前台再写进 Storage。 +// +// 每次唤醒发两个请求: +// 1. 当前天气 /data/2.5/weather +// 2. 短期预报 /data/2.5/forecast?cnt=16(约 48 小时,够算明日/后日高低温) +// +// ⚠️ 预报接口必须带 cnt 限制条数。后台进程的内存预算非常小(约 32KB), +// 完整的 5 天 / 3 小时预报解析出来能有几十 KB,会直接爆掉。 +// +// 响应会被**压平成一个扁平字典**再交回去,键名是短名("temp"/"hi1"…), +// 与 Owm.get() 的取值一一对应 —— 这样表盘侧永远不用扛一棵 JSON 树。 +// ============================================================================ +(:background) +class Fenix8V3Background extends System.ServiceDelegate { + + private var mResult as Dictionary = {}; + private var mPending = 0; + + function initialize() { + ServiceDelegate.initialize(); + } + + // 系统按定时器唤醒后台进程时调用。 + // 注意这里是**独立进程**,内存预算比前台小得多,而且不能碰 UI。 + function onTemporalEvent() as Void { + if (!Owm.enabled()) { + Background.exit(null); + return; + } + var lat = Application.Storage.getValue("lastLat"); + var lon = Application.Storage.getValue("lastLon"); + if (lat == null || lon == null) { + // 表盘一拿到位置就会存进 Storage(见 Fields.Ctx.where)。 + // 在那之前没有坐标可问,直接退出。 + Background.exit(null); + return; + } + mResult = {}; + mPending = 2; + // 一律用 units=metric 请求,单位换算统一放到 Fields 里按用户设置做, + // 这样切换单位不用重新联网。 + var common = {"lat" => lat, "lon" => lon, + "appid" => Owm.key(), "units" => "metric"}; + var opts = { + :method => Communications.HTTP_REQUEST_METHOD_GET, + :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON + }; + Communications.makeWebRequest( + "https://api.openweathermap.org/data/2.5/weather", + common, opts, method(:onCurrent)); + var fc = {"lat" => lat, "lon" => lon, "appid" => Owm.key(), + "units" => "metric", "cnt" => 16}; + Communications.makeWebRequest( + "https://api.openweathermap.org/data/2.5/forecast", + fc, opts, method(:onForecast)); + } + + // 当前天气的回调。OWM 的 JSON 是嵌套的,逐层取值时每层都要判空 —— + // 缺字段是常态(比如没下雨就没有 rain 节点)。 + function onCurrent(code as Number, body as Dictionary?) as Void { + if (code == 200 && body != null) { + var main = body.get("main") as Dictionary?; + if (main != null) { + put("temp", main.get("temp")); + put("feels", main.get("feels_like")); + put("tmin", main.get("temp_min")); + put("tmax", main.get("temp_max")); + put("pressure", main.get("pressure")); + put("humidity", main.get("humidity")); + } + put("visibility", body.get("visibility")); + var wind = body.get("wind") as Dictionary?; + if (wind != null) { + put("wind", wind.get("speed")); + put("winddeg", wind.get("deg")); + put("gust", wind.get("gust")); + } + var clouds = body.get("clouds") as Dictionary?; + if (clouds != null) { put("clouds", clouds.get("all")); } + var rain = body.get("rain") as Dictionary?; + if (rain != null) { put("rain", rain.get("1h")); } + var snow = body.get("snow") as Dictionary?; + if (snow != null) { put("snow", snow.get("1h")); } + var wx = body.get("weather") as Array?; + if (wx != null && wx.size() > 0) { + var w0 = wx[0] as Dictionary; + put("short", w0.get("main")); + put("long", w0.get("description")); + } + put("city", body.get("name")); + put("dt", Time.now().value()); + } + done(); + } + + // 预报返回的是 3 小时粒度的列表。这里按「距今天几天」把条目分到 + // 明天 / 后天两个桶里,各自取最高最低温;降水概率取明天的最大值。 + function onForecast(code as Number, body as Dictionary?) as Void { + if (code == 200 && body != null) { + var list = body.get("list") as Array?; + if (list != null) { + var today = dayNumber(Time.now().value()); + var lo1 = null; var hi1 = null; var lo2 = null; var hi2 = null; + var pop1 = null; + var arr = list; + for (var i = 0; i < arr.size(); i++) { + var e = arr[i] as Dictionary; + var dt = e.get("dt"); + if (dt == null) { continue; } + var day = dayNumber(dt as Number) - today; + var m = e.get("main") as Dictionary?; + if (m == null) { continue; } + var tmin = m.get("temp_min"); + var tmax = m.get("temp_max"); + if (day == 1) { + if (lo1 == null || tmin < lo1) { lo1 = tmin; } + if (hi1 == null || tmax > hi1) { hi1 = tmax; } + var pop = e.get("pop"); + if (pop != null && (pop1 == null || pop > pop1)) { pop1 = pop; } + } else if (day == 2) { + if (lo2 == null || tmin < lo2) { lo2 = tmin; } + if (hi2 == null || tmax > hi2) { hi2 = tmax; } + } + } + put("lo1", lo1); put("hi1", hi1); + put("lo2", lo2); put("hi2", hi2); + put("pop", pop1); + } + } + done(); + } + + // 距 Unix 纪元的整天数,用来给预报条目分桶。 + function dayNumber(unix as Number) as Number { + return unix / 86400; + } + + // 只写入非空值,免得用 null 覆盖字典里已有的内容。 + function put(k as String, v) as Void { + if (v != null) { mResult.put(k, v); } + } + + // 两个请求都回来了才算一轮完整数据,谁最后完成谁负责退出服务。 + // 一条都没拿到就传 null,避免用空字典覆盖掉上一次的好数据。 + function done() as Void { + mPending--; + if (mPending > 0) { return; } + if (mResult.size() == 0) { + Background.exit(null); + } else { + Background.exit(mResult); + } + } +} diff --git a/source/Fenix8V3View.mc b/source/Fenix8V3View.mc index 72013ec..eca7d7a 100644 --- a/source/Fenix8V3View.mc +++ b/source/Fenix8V3View.mc @@ -13,16 +13,24 @@ import Layout; import Icons; import Settings; -// The face itself: the progress ring, the elements laid over it, and the -// stripped-down variant drawn while an AMOLED watch is asleep. +// ============================================================================ +// 表盘主体 +// ---------------------------------------------------------------------------- +// 负责:进度环、盖在环上的各个元素、以及 AMOLED 常亮时的简化版画面。 // -// Anything about *where* things go lives in Layout, anything about *what* a -// data slot says lives in Fields, and the colours come from Themes. What is -// left here is the drawing order. +// 分工约定(改代码前先看清楚该动哪个文件): +// 位置尺寸 → Layout.mc +// 数据取值 → Fields.mc +// 配色 → Themes.mc(自动生成) +// 设置读取 → Settings.mc +// 图标 → Icons.mc +// 留在本文件里的只有「按什么顺序画」。 +// ============================================================================ class Fenix8V3View extends WatchUi.WatchFace { - // Always-on display state. AMOLED panels have to cut their lit pixel - // count while asleep; MIP panels do not care and are left alone. + // 常亮显示状态。 + // AMOLED 屏在息屏常亮时必须大幅减少点亮像素(Garmin 上架审核会卡这一条, + // 而且不这么做既费电又有烧屏风险);MIP 屏不受影响,照常全量绘制。 private var mSleeping = false; private var mBurnIn = false; @@ -30,18 +38,20 @@ class Fenix8V3View extends WatchUi.WatchFace { WatchFace.initialize(); } + // 进入低功耗(息屏)。AMOLED 机型从这一刻起改画简化版面。 function onEnterSleep() as Void { mSleeping = true; WatchUi.requestUpdate(); } + // 抬腕唤醒,恢复完整版面。 function onExitSleep() as Void { mSleeping = false; WatchUi.requestUpdate(); } - // Called once when the face is loaded, and again by onUpdate if anything - // it caches has gone stale (a resolution change, or resources not yet in). + // 表盘加载时调一次;如果 onUpdate 发现缓存失效(分辨率变了,或资源还没 + // 加载好)会再调一次。所有「一次性、开销大」的准备工作都放这里。 function onLayout(dc as Dc) as Void { Layout.init(dc); Fields.loadStrings(); @@ -51,27 +61,28 @@ class Fenix8V3View extends WatchUi.WatchFace { && ds.requiresBurnInProtection; } - // ------------------------------------------------------------- ring - // Progress for one of the four arcs, 0..1. These ids are the ring's own - // short list, not the data field catalogue. + // ------------------------------- 进度环 ------------------------------- + // 计算四段弧各自的进度,0..1。 + // ⚠️ 这里的 idx 是**进度环自己的一张短表**(见 Settings.ringTR 的注释), + // 和数据位那套 125 项的字段编号完全是两回事,别混。 function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float { if (info == null) { return 0.0f; } var p = 0.0f; - if (idx == 0) { // steps + if (idx == 0) { // 步数:对每日目标 if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) { p = info.steps.toFloat() / info.stepGoal.toFloat(); } - } else if (idx == 1) { // calories + } else if (idx == 1) { // 卡路里:固定 2000 kcal 为满 if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; } - } else if (idx == 2) { // distance, 10 km ring + } else if (idx == 2) { // 距离:10 km 为满(distance 单位是厘米) if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; } - } else if (idx == 3) { // floors + } else if (idx == 3) { // 楼层:对每日目标,取不到目标就按 10 层 if (info.floorsClimbed != null) { var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0) ? info.floorsClimbedGoal.toFloat() : 10.0f; p = info.floorsClimbed.toFloat() / goal; } - } else if (idx == 5) { // active minutes vs week goal + } else if (idx == 5) { // 活动分钟:对**每周**目标 if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null && (info has :activeMinutesWeekGoal) && info.activeMinutesWeekGoal != null @@ -79,10 +90,13 @@ class Fenix8V3View extends WatchUi.WatchFace { p = info.activeMinutesWeek.total.toFloat() / info.activeMinutesWeekGoal.toFloat(); } - } else if (idx == 6) { // body battery is already 0..100 + } else if (idx == 6) { // 身体电量:本身就是 0..100 var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1})); if (bb != null) { p = bb.toFloat() / 100.0; } - } else if (idx == 4) { // move bar: a level, not a ratio + } else if (idx == 4) { + // ⚠️ 动动条是**整数等级**(0..MOVE_BAR_LEVEL_MAX,通常是 5), + // 不是 0..1 的比例。初版直接当比例用,结果等级≥1 就被钳成满环, + // 中间状态全丢了。 if (info.moveBarLevel != null) { var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX - ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat(); @@ -96,8 +110,9 @@ class Fenix8V3View extends WatchUi.WatchFace { return p; } - // One tick: an annular sector drawn as a quad. At three degrees the chord - // sagitta is under a tenth of a pixel, so straight edges are exact enough. + // 画一根刻度:环形扇区,用四点多边形填充。 + // 扇区两条弧边其实是曲线,但 3° 弧的弓高不到 0.1 像素,用直边完全够精确, + // 而 fillPolygon 比 drawArc 行为更确定(各机型的 drawArc 端点样式不一致)。 function tick(dc as Dc, ang as Float, hw as Float) as Void { var a0 = ang - hw; var a1 = ang + hw; @@ -109,8 +124,10 @@ class Fenix8V3View extends WatchUi.WatchFace { [Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]); } - // Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs - // fill outwards from 6 o'clock. That is what the comps show. + // 通用画弧。点亮方向与设计稿一致: + // 两条顶弧:从 9 点 / 3 点那一端朝 12 点方向填充(fromEnd=true) + // 两条底弧:从 6 点朝两侧填充(fromEnd=false) + // mirror 用来把同一组角度镜像到左半边,这样左右弧共用一份角度表和配色表。 function arc(dc as Dc, ramp as Array, off as Number, lit as Number, phase as Float, pitch as Float, hw as Float, n as Number, base as Float, mirror as Boolean, fromEnd as Boolean) as Void { @@ -124,6 +141,8 @@ class Fenix8V3View extends WatchUi.WatchFace { function drawRing(dc as Dc, ti as Number, pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { + // 四段弧共用两份配色表:两条顶弧用 RINGTOP,两条底弧用 RINGBOTTOM。 + // 左右弧靠 mirror 参数镜像,颜色按刻度下标取,所以左右对称位置同色。 var top = Themes.RINGTOP[ti] as Array; var bot = Themes.RINGBOTTOM[ti] as Array; var off = Themes.TICKOFF[ti] as Number; @@ -137,6 +156,7 @@ class Fenix8V3View extends WatchUi.WatchFace { arc(dc, bot, off, (pBR * Layout.BOT_N).toNumber(), Layout.BOT_PH, Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, true, false); + // 四个锚点圆点,压在四段弧的接缝处。 dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT); var r = Layout.px(Layout.DOT_R); var rad = Layout.DOT_RAD * Layout.scale; @@ -146,7 +166,8 @@ class Fenix8V3View extends WatchUi.WatchFace { } } - // ---------------------------------------------------------- elements + // ------------------------------- 各元素 ------------------------------- + // 顶部中央:电池图标 + 百分比。 function drawBattery(dc as Dc, ti as Number, level as Float) as Void { Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0, Themes.ACCENT[ti] as Number, level); @@ -155,6 +176,8 @@ class Fenix8V3View extends WatchUi.WatchFace { Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number); } + // 顶部单个数据位:上方图标 + 下方数值。 + // 数值走 fittedText,太长会自动降字号,不会冲出表盘。 function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean, ctx as Fields.Ctx, level as Float) as Void { var x = isLeft ? Layout.LEFT_X : Layout.RIGHT_X; @@ -164,18 +187,22 @@ class Fenix8V3View extends WatchUi.WatchFace { Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W); } + // 日期带:整条横幅底色 + 蓝牙符号 + 星期 + 月日 + 上午/下午。 + // 24 小时制下不显示上午/下午(跟随手表设置)。 function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void { var fill = Themes.BANDFILL[ti] as Number; dc.setColor(fill, fill); dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width, Layout.px(Layout.BAND_H)); var col = Themes.BANDTEXT[ti] as Number; - // Dim the rune rather than hide it, so the band does not change shape - // every time the phone wanders out of range. + // 手机断连时把蓝牙符号**压暗**而不是隐藏 —— 否则手机一走远日期带就 + // 少一块,整条带子的视觉重心跟着变,很干扰。 Icons.bluetooth(dc, (ds != null && !ds.phoneConnected) ? blend(col, fill, 0.6) : col); - // FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns - // day_of_week and month as numbers rather than localised strings. + // ⚠️ 必须用 FORMAT_SHORT,不能用 FORMAT_MEDIUM。 + // 只有短格式的 day_of_week / month 返回的是**数字**;中格式返回的是 + // 本地化字符串,拿去做数组下标会直接抛类型异常 —— 初版就是这么崩的, + // 而且是每次更新都崩。 var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand, Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col); @@ -187,6 +214,8 @@ class Fenix8V3View extends WatchUi.WatchFace { } } + // 中央时间。小时和分钟各自带竖向渐变(gradText 内部会分带多次绘制), + // 中间的冒号是两个实心方块,位置写死在设计稿坐标上。 function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void { var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); var h = g.hour; @@ -206,6 +235,8 @@ class Fenix8V3View extends WatchUi.WatchFace { Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); } + // 底部两格:各自「数值 + 小标签」。 + // 标签跟随所选字段自动变化,不需要单独配置。 function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void { var value = Themes.TEXTPRIMARY[ti] as Number; var label = Themes.ACCENT[ti] as Number; @@ -225,7 +256,8 @@ class Fenix8V3View extends WatchUi.WatchFace { label, Layout.BOT_MAX_W); } - // ------------------------------------------------------------ colour + // ------------------------------- 颜色工具 ------------------------------ + // 两色按比例混合,f=0 取 c1,f=1 取 c2。 function blend(c1 as Number, c2 as Number, f as Float) as Number { var r1 = (c1 >> 16) & 0xFF; var g1 = (c1 >> 8) & 0xFF; @@ -236,6 +268,7 @@ class Fenix8V3View extends WatchUi.WatchFace { return (r << 16) | (g << 8) | b; } + // 整体压暗到 f 倍(常亮模式用)。 function dim(col as Number, f as Float) as Number { var r = (((col >> 16) & 0xFF) * f).toNumber(); var g = (((col >> 8) & 0xFF) * f).toNumber(); @@ -243,14 +276,18 @@ class Fenix8V3View extends WatchUi.WatchFace { return (r << 16) | (g << 8) | b; } - // --------------------------------------------------------------- AOD - // Low-power face: no band fill, no unlit ticks, no data slots -- just a - // dimmed time, the lit part of the ring, and the date. The whole thing - // walks a few pixels each minute so no pixel stays lit in one place. + // ------------------------------- 常亮模式 ------------------------------ + // 低功耗画面。相比正常画面砍掉的东西: + // - 日期带底色(整条亮橙色横幅是最费电的元素,直接不画) + // - 未点亮的刻度(只画有数据的那部分,且压暗到 45%) + // - 四个数据位、电池(全部隐藏) + // 保留的时间和日期压暗到 55%。 + // + // 另外整个画面按分钟在 ±4 像素内游走,避免同一个像素长期点亮导致烧屏。 function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?, pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); - var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // +/- 4 device px + var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // ±4 设备像素,换算回设计像素 var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale; var top = Themes.RINGTOP[ti] as Array; @@ -307,7 +344,79 @@ class Fenix8V3View extends WatchUi.WatchFace { Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band); } - // -------------------------------------------------------------- main + // ------------------------------- 秒针刷新 ------------------------------ + // 表盘正常只有每分钟一次的 onUpdate,秒数根本走不动。 + // 这里实现 onPartialUpdate(系统每秒回调一次),但要非常克制: + // - 只有当真的有数据位选了「秒」时才做事,否则直接返回 + // - 只重绘那一格,用 setClip 把绘制限制在格子的包围盒内 + // (每秒重绘整个表盘的功耗预算是不够的) + // - 常亮的 AMOLED 机型直接跳过:那时数据位本来就不显示 + function isSeconds(id as Number) as Boolean { + return id == 850 || id == 869; + } + + function wantsSeconds() as Boolean { + if (Settings.showTop() + && (isSeconds(Settings.leftTop()) || isSeconds(Settings.rightTop()))) { + return true; + } + return Settings.showBottom() + && (isSeconds(Settings.bottomLeft()) || isSeconds(Settings.bottomRight())); + } + + // 重绘单个格子:先把它的包围盒涂黑,再把新值画回去。 + function repaintSlot(dc as Dc, ti as Number, ctx as Fields.Ctx, id as Number, + x as Numeric, cyDesign as Numeric, just as Number, + maxW as Float, col as Number) as Void { + var half = maxW / 2.0; + var x0 = x - half; + if (just == Graphics.TEXT_JUSTIFY_RIGHT) { x0 = x - maxW; } + else if (just == Graphics.TEXT_JUSTIFY_LEFT) { x0 = x; } + var h = Layout.CAP_COMP * 1.6; + dc.setClip(Layout.px(x0), Layout.px(cyDesign - h / 2), + Layout.px(maxW), Layout.px(h)); + // 全屏涂黑。设计稿的底色就是纯黑,AMOLED 上黑像素也最省电。 + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); + dc.clear(); + Layout.fittedText(dc, x, cyDesign, Layout.fontComp, Fields.value(id, ctx), + just, col, maxW); + dc.clearClip(); + } + + function onPartialUpdate(dc as Dc) as Void { + if (mBurnIn || !Layout.ready() || !Fields.ready()) { return; } + if (!wantsSeconds()) { return; } + var ti = Settings.themeIndex(Themes.NAMES.size()); + var ctx = new Fields.Ctx(System.getDeviceSettings(), Settings.tempUnit()); + var col = Themes.TEXTPRIMARY[ti] as Number; + if (Settings.showTop()) { + if (isSeconds(Settings.leftTop())) { + repaintSlot(dc, ti, ctx, Settings.leftTop(), Layout.LEFT_X, + Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER, + Layout.TOP_MAX_W, col); + } + if (isSeconds(Settings.rightTop())) { + repaintSlot(dc, ti, ctx, Settings.rightTop(), Layout.RIGHT_X, + Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER, + Layout.TOP_MAX_W, col); + } + } + if (Settings.showBottom()) { + if (isSeconds(Settings.bottomLeft())) { + repaintSlot(dc, ti, ctx, Settings.bottomLeft(), Layout.DIST_R, + Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_RIGHT, + Layout.BOT_MAX_W, col); + } + if (isSeconds(Settings.bottomRight())) { + repaintSlot(dc, ti, ctx, Settings.bottomRight(), Layout.STEP_L, + Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_LEFT, + Layout.BOT_MAX_W, col); + } + } + } + + // ------------------------------- 主绘制 -------------------------------- + // 每分钟调用一次(以及唤醒、设置变更时)。 function onUpdate(dc as Dc) as Void { if (!Layout.ready() || Layout.width != dc.getWidth() || !Fields.ready() || !Icons.ready()) { @@ -315,6 +424,8 @@ class Fenix8V3View extends WatchUi.WatchFace { } if (dc has :setAntiAlias) { dc.setAntiAlias(true); } + // 每次绘制只构造一个 Ctx,四个数据位共用 —— 天气、用户档案这些 + // 开销大的查询因此每帧最多做一次,且只在真有格子要用时才做。 var ti = Settings.themeIndex(Themes.NAMES.size()); var ds = System.getDeviceSettings(); var ctx = new Fields.Ctx(ds, Settings.tempUnit()); @@ -333,6 +444,7 @@ class Fenix8V3View extends WatchUi.WatchFace { var pBR = ringProgress(Settings.ringBR(), info); var pBL = ringProgress(Settings.ringBL(), info); + // 常亮模式走另一条绘制路径,画完直接返回。 if (mSleeping && mBurnIn) { drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL); return; diff --git a/source/FieldTable.mc b/source/FieldTable.mc index 00b1cfb..d7702a5 100644 --- a/source/FieldTable.mc +++ b/source/FieldTable.mc @@ -1,18 +1,25 @@ -// AUTO-GENERATED by tools/gen_fields.py -- do not edit by hand. -// Ids follow https://watchface.io/docs/datafields; only the fields a -// Connect IQ watch face can source on-device are listed. +// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。 +// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器, +// 再到 source/Fields.mc 对应分类的函数里补一个分支。 +// +// 字段编号沿用 https://watchface.io/docs/datafields,只收录 Connect IQ +// 表盘在本机能算出来的那些。 import Toybox.Lang; module FieldTable { - // Field ids, in the same order as the Labels resource string. + // 全部字段 id,顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 —— + // Fields.label() 就是靠这个下标去取对应标签的。 const IDS = [ - 0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,873,1,14,707,2,603,150, - 152,154,151,3,4,5,6,9,10,618,276,12,635,11,623,8,15,281,742,13,278,277,250,251,257, - 253,255,254,256,252,615,616,733,712,717,700,731,706,732,306,639,300,301,302,305,304, - 308,307,310,309,703,704,705 + 0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,1,14,707,2, + 603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,618,276,12,635, + 11,623,8,15,281,742,13,278,277,250,251,257,253,255,254,256,252,615,616,733,712,717, + 700,731,706,732,710,709,708,711,714,715,718,719,720,721,306,639,300,301,302,305,304, + 308,307,310,309,312,313,314,315,500,515,503,504,505,507,502,501,506,509,510,511,518, + 512,513,532,536,537,530,534,522,535,523,516,517,703,704,705 ]; + // 由字段 id 反查它在 IDS 里的下标;找不到返回 0(对应「关闭」)。 function indexOf(id as Number) as Number { for (var i = 0; i < IDS.size(); i++) { if (IDS[i] == id) { return i; } diff --git a/source/Fields.mc b/source/Fields.mc index 96cac87..202d2d3 100644 --- a/source/Fields.mc +++ b/source/Fields.mc @@ -13,16 +13,27 @@ import Toybox.Position; import Toybox.WatchUi; import FieldTable; import Settings; +import Solar; +import Owm; -// Everything a complication slot can show. Ids match -// https://watchface.io/docs/datafields so the two catalogues line up; see -// tools/gen_fields.py for the table that drives the settings and the labels. +// ============================================================================ +// 数据字段 +// ---------------------------------------------------------------------------- +// 四个数据位能显示的全部内容,共 125 项。 // -// Only fields Connect IQ can answer from the watch itself are here. The -// week-to-date and 7/28-day per-sport aggregates in that catalogue come from a -// phone-side Garmin Connect integration, and the OpenWeatherMap / StormGlass / -// third-party-app fields need network calls and API keys; neither is something -// a self-contained watch face can produce. +// 字段编号沿用 https://watchface.io/docs/datafields,两边可以直接对照。 +// 驱动设置项和标签文案的那张表在 tools/gen_fields.py —— 加字段要改那里, +// 然后在本文件对应分类的函数里补一个分支。 +// +// 只收录**手表本机能算出来**的字段。没做的几类及原因见 README 第 7 节,简单说: +// - 按运动分类的周/月累计:CIQ 只有按天的总计,没有分运动的历史 +// - StormGlass 潮汐、第三方 App 复杂功能:需要额外 key 或用户装了对应 App +// - 日历、计时器:CIQ 不开放日历,表盘也拿不到触摸输入 +// +// 组织方式:value() 按分类依次尝试 7 个函数,每个函数不认识编号就返回 null。 +// 之所以不按编号区间派发,是因为编号是**交错**的 —— 1/2/3 是活动,8/11/12 是 +// 身体,14 又回到活动。 +// ============================================================================ module Fields { const DASH = "--"; @@ -43,7 +54,7 @@ module Fields { function ready() as Boolean { return mDow != null; } - // Monkey C has no String.split. + // Monkey C 没有 String.split,只能自己按逗号切。 function splitCsv(str as String) as Array { var out = [] as Array; var rest = str; @@ -69,8 +80,8 @@ module Fields { return Lang.format(mDateFmt, [month(g), g.day.format("%d")]); } - // "10" in English, "10日" in Chinese -- the format string carries the - // suffix, so feed it an empty month. + // 英文出 "10",中文出 "10日"。 + // 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。 function dayOnly(g as Gregorian.Info) as String { return Lang.format(mDateFmt, ["", g.day.format("%d")]); } @@ -79,16 +90,18 @@ module Fields { return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0]; } - // Short caption printed under a bottom-row value. + // 底部格子数值下方的小标签。按字段编号查 FieldTable 的下标, + // 再到本地化的标签表里取词,所以中英文会自动跟随系统语言。 function label(id as Number) as String { if (mLabels == null) { return ""; } var i = FieldTable.indexOf(id); return i < mLabels.size() ? mLabels[i] : ""; } - // One of these is built per onUpdate and handed to every slot, so the - // expensive lookups (forecast, sensor history, user profile) happen at most - // once per draw and only if some slot actually asks for them. + // 每次绘制构造一个,传给所有数据位共用。 + // 目的是让开销大的查询(天气预报、传感器历史、用户档案)**每次绘制最多做 + // 一次,且只在真的有格子要用时才做** —— 四个格子可能都要天气,但只查一次; + // 没有格子用到 UserProfile 时就完全不去碰它。 class Ctx { var greg as Gregorian.Info; var is24 as Boolean = true; @@ -152,18 +165,40 @@ module Fields { return mProf; } + // 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空: + // - 天气在同步之前没有观测点坐标 + // - currentLocation 要有东西刚定过位才有值 + // 所以每次拿到位置就缓存进 Storage,两个来源都空时用缓存兜底, + // 免得这些字段一会儿有一会儿变 "--"。 function where() as Position.Location? { var w = wx(); - if (w != null && w.observationLocationPosition != null) { - return w.observationLocationPosition; + if (w != null && (w has :observationLocationPosition) + && w.observationLocationPosition != null) { + return remember(w.observationLocationPosition); } var a = act(); - if (a != null && a.currentLocation != null) { return a.currentLocation; } + if (a != null && a.currentLocation != null) { + return remember(a.currentLocation); + } + var lat = Application.Storage.getValue("lastLat"); + var lon = Application.Storage.getValue("lastLon"); + if (lat != null && lon != null) { + return new Position.Location({:latitude => lat as Double, + :longitude => lon as Double, + :format => :degrees}); + } return null; } + + function remember(loc as Position.Location) as Position.Location { + var d = loc.toDegrees(); + Application.Storage.setValue("lastLat", d[0]); + Application.Storage.setValue("lastLon", d[1]); + return loc; + } } - // ------------------------------------------------------------- helpers + // ------------------------------- 格式化工具 ---------------------------- function num(v as Numeric?) as String { return v == null ? DASH : v.format("%d"); } @@ -197,7 +232,7 @@ module Fields { return t.format("%d") + "°"; } - // ActivityMonitor distances are centimetres. + // ActivityMonitor 的距离单位是**厘米**,先换算成公里再按单位制转换。 function dist(cm as Numeric?, c as Ctx) as String { if (cm == null) { return DASH; } var km = cm.toFloat() / 100000.0; @@ -217,7 +252,7 @@ module Fields { return (n / 1000.0).format("%.1f") + "k"; } - // Newest reading out of a SensorHistory iterator, or null. + // 从 SensorHistory 迭代器里取最新一条读数;没有则 null。 function latest(iter) as Numeric? { if (iter == null) { return null; } var s = iter.next(); @@ -225,6 +260,53 @@ module Fields { return s.data; } + // 历史累计。 + // ActivityMonitor.getHistory() 返回过去若干天(最新在前),**不含今天**。 + // 把它加起来是本机唯一能做出「本周累计 / 近 7 天累计」的路子; + // 按运动项目细分的那些仍然做不到,那需要手机侧与 Garmin Connect 的集成。 + // + // which:0 步数 / 1 卡路里 / 2 距离(厘米) / 3 楼层 / 4 活动分钟 + // days :往前折算几天,今天总是算在内 + function sumHistory(c as Ctx, which as Number, days as Number) as Numeric? { + var today = todayValue(c, which); + if (today == null) { return null; } + var total = today; + if (days <= 0) { return total; } + var hist = ActivityMonitor.getHistory(); + if (hist == null) { return total; } + var n = days < hist.size() ? days : hist.size(); + for (var k = 0; k < n; k++) { + var v = sampleValue(hist[k], which); + if (v != null) { total = total + v; } + } + return total; + } + + function todayValue(c as Ctx, which as Number) as Numeric? { + var i = c.info(); + if (i == null) { return null; } + return sampleValue(i, which); + } + + function sampleValue(d, which as Number) as Numeric? { + if (which == 0) { return (d has :steps) ? d.steps : null; } + if (which == 1) { return (d has :calories) ? d.calories : null; } + if (which == 2) { return (d has :distance) ? d.distance : null; } + if (which == 3) { return (d has :floorsClimbed) ? d.floorsClimbed : null; } + if (which == 4) { + if (!(d has :activeMinutesDay) || d.activeMinutesDay == null) { return null; } + return d.activeMinutesDay.total; + } + return null; + } + + // 距本周一过了几天,本周累计据此决定往前取多少天。 + // day_of_week 是 1=周日,所以周日要特判成 6。 + function daysIntoWeek(g as Gregorian.Info) as Number { + var dow = g.day_of_week; // 1 = Sunday + return dow == 1 ? 6 : dow - 2; + } + function dayOfYear(g as Gregorian.Info) as Number { var cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; var d = cum[g.month - 1] + g.day; @@ -234,7 +316,7 @@ module Fields { return d; } - // ISO 8601 week: weeks start Monday, week 1 holds the first Thursday. + // ISO 8601 周数:周一为一周之始,含当年第一个周四的那周为第 1 周。 function isoWeek(g as Gregorian.Info) as Number { var dow = g.day_of_week - 1; // 0 = Sunday var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday @@ -244,7 +326,7 @@ module Fields { return w; } - // Days since the new moon of 2000-01-06, folded into one synodic month. + // 月龄:距 2000-01-06 那次新月的天数,对朔望月(29.530588853 天)取余。 function moonAge(g as Gregorian.Info) as Float { var y = g.year; var m = g.month; @@ -253,7 +335,7 @@ module Fields { var b = 2 - a + a / 4; var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber() + g.day + b - 1524.5 + g.hour / 24.0; - // Monkey C has no float modulo. + // ⚠️ Monkey C 的 % 不支持浮点,只能手写取余。 var syn = 29.530588853; var x = jd - 2451550.1; var age = x - (x / syn).toNumber() * syn; @@ -271,113 +353,136 @@ module Fields { return pts[i]; } - // --------------------------------------------------------------- value - // Clock, calendar and week-number fields. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ------------------------------- 分类取值 ------------------------------ + // ---- 日期时间类 ---- + // 不属于本类的编号返回 null,交给 value() 试下一个分类。 function dateTime(id as Number, c as Ctx) as String? { - if (id == 860) { return clockInfo(c.greg, c); } - if (id == 880) { + if (id == 860) { return clockInfo(c.greg, c); } // 时间 + if (id == 880) { // UTC 时间 var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT); return clockInfo(utc, c); } + // 第二时区 1 if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); } + // 第二时区 2 if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); } - if (id == 855) { return c.greg.day.format("%d"); } - if (id == 868) { return c.greg.month.format("%d"); } - if (id == 859) { return isoWeek(c.greg).format("%d"); } + if (id == 855) { return c.greg.day.format("%d"); } // 日 + if (id == 868) { return c.greg.month.format("%d"); } // 月份数字 + if (id == 859) { return isoWeek(c.greg).format("%d"); } // 周数 (ISO) + // 周数 if (id == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); } - if (id == 870) { return dow(c.greg); } - if (id == 856) { return dow(c.greg); } - if (id == 853) { return month(c.greg); } - if (id == 852) { return monthDay(c.greg); } - if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); } - if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } - if (id == 858) { return meridiem(c.greg); } + // 秒数要动起来依赖设备支持 onPartialUpdate; + // view 只在有格子选了秒时才去请求那个回调。 + if (id == 850) { return c.greg.sec.format("%d"); } // 秒 + if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零) + if (id == 870) { return dow(c.greg); } // 星期简写 + if (id == 856) { return dow(c.greg); } // 星期全称 + if (id == 853) { return month(c.greg); } // 月份 + if (id == 852) { return monthDay(c.greg); } // 月 日 + if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); } // 星期 日 + if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } // 星期 月 日 + if (id == 858) { return meridiem(c.greg); } // 上午/下午 return null; } - // Steps, calories, distance, floors and active minutes. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ---- 活动类 ---- + // 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 7 天累计。 function activity(id as Number, c as Ctx) as String? { var i = c.info(); - if (id == 1) { return i == null ? DASH : num(i.steps); } - if (id == 14) { return i == null ? DASH : rounded(i.steps); } - if (id == 707) { + if (id == 1) { return i == null ? DASH : num(i.steps); } // 步数 + if (id == 14) { return i == null ? DASH : rounded(i.steps); } // 步数 (取整) + if (id == 707) { // 剩余步数 if (i == null || i.steps == null || i.stepGoal == null) { return DASH; } var left = i.stepGoal - i.steps; return left > 0 ? left.format("%d") : "0"; } - if (id == 2) { return i == null ? DASH : num(i.calories); } - if (id == 603) { + if (id == 2) { return i == null ? DASH : num(i.calories); } // 卡路里 + if (id == 603) { // 活动卡路里 if (i == null || i.calories == null) { return DASH; } var p = c.profile(); if (p == null || p.weight == null || p.height == null) { return num(i.calories); } return num(i.calories); // device reports total only } - if (id == 150 || id == 152 || id == 154) { + if (id == 150 || id == 152 || id == 154) { // 活动分钟 / 中强度分钟 / 高强度分钟 if (i == null || !(i has :activeMinutesDay) || i.activeMinutesDay == null) { return DASH; } var am = i.activeMinutesDay; - if (id == 152) { return num(am.moderate); } - if (id == 154) { return num(am.vigorous); } + if (id == 152) { return num(am.moderate); } // 中强度分钟 + if (id == 154) { return num(am.vigorous); } // 高强度分钟 return num(am.total); } - if (id == 151) { + if (id == 151) { // 本周活动分钟 if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) { return DASH; } return num(i.activeMinutesWeek.total); } + // 距离 if (id == 3) { return i == null ? DASH : dist(i.distance, c); } + // 上楼层数 if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); } - // Several ActivityMonitor.Info members compile everywhere but only - // exist on some devices and firmware -- the wheelchair counters in - // particular threw "Symbol Not Found" on a fenix 8. Check before use. - if (id == 5) { + // ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有 + // 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛 + // "Symbol Not Found",用户一选这个字段表盘就崩。 + // 所以凡是非通用成员,用之前一律先 has 检查。 + if (id == 5) { // 下楼层数 return (i == null || !(i has :floorsDescended)) ? DASH : num(i.floorsDescended); } - if (id == 6) { + if (id == 6) { // 爬升高度 return (i == null || !(i has :metersClimbed)) ? DASH : metres(i.metersClimbed, c); } - if (id == 9) { + if (id == 9) { // 推行距离 return (i == null || !(i has :pushDistance)) ? DASH : dist(i.pushDistance, c); } - if (id == 10) { + if (id == 10) { // 推行次数 return (i == null || !(i has :pushes)) ? DASH : num(i.pushes); } - if (id == 8 || id == 15) { + if (id == 219 || id == 211 || id == 213 || id == 203) { // 本周步数 / 本周卡路里 / 本周距离 / 本周楼层 + var which = (id == 219) ? 0 : ((id == 211) ? 1 : ((id == 213) ? 2 : 3)); + var back = daysIntoWeek(c.greg); + var v = sumHistory(c, which, back); + if (v == null) { return DASH; } + return (id == 213) ? dist(v, c) : rounded(v); + } + // 近 7 天步数 / 近 7 天卡路里 / 近 7 天距离 / 近 7 天楼层 / 近 7 天活动分钟 + if (id == 220 || id == 212 || id == 214 || id == 204 || id == 200) { + var which = (id == 220) ? 0 : ((id == 212) ? 1 + : ((id == 214) ? 2 : ((id == 204) ? 3 : 4))); + var v = sumHistory(c, which, 6); + if (v == null) { return DASH; } + return (id == 214) ? dist(v, c) : rounded(v); + } + if (id == 8 || id == 15) { // 活动条 / 活动条 (反向) if (i == null || i.moveBarLevel == null) { return DASH; } var lvl = i.moveBarLevel; + // 活动条 (反向) if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; } return lvl.format("%d"); } - if (id == 13) { + if (id == 13) { // 恢复时间 return (i == null || !(i has :timeToRecovery)) ? DASH : num(i.timeToRecovery); } - if (id == 11) { + if (id == 11) { // 呼吸频率 return (i == null || !(i has :respirationRate)) ? DASH : num(i.respirationRate); } - if (id == 12) { + if (id == 12) { // 压力指数 return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore); } return null; } - // Heart, stress, oxygen, body battery and profile-derived values. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ---- 身体类 ---- + // 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。 function body(id as Number, c as Ctx) as String? { - if (id == 618) { + if (id == 618) { // 心率 var a = c.act(); if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); } var h = ActivityMonitor.getHeartRateHistory(1, true); @@ -390,34 +495,34 @@ module Fields { } return DASH; } - if (id == 276) { + if (id == 276) { // 静息心率 var p = c.profile(); return (p == null || !(p has :restingHeartRate)) ? DASH : num(p.restingHeartRate); } - if (id == 635) { + if (id == 635) { // 血氧 if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; } return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1}))); } - if (id == 623) { + if (id == 623) { // 身体电量 if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; } return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1}))); } - if (id == 281 || id == 742) { + if (id == 281 || id == 742) { // 体重 / BMI var p = c.profile(); if (p == null || !(p has :weight) || p.weight == null) { return DASH; } var kg = p.weight.toFloat() / 1000.0; - if (id == 281) { + if (id == 281) { // 体重 return (c.metric ? kg : kg * 2.20462).format("%.1f"); } if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; } var m = p.height.toFloat() / 100.0; return (kg / (m * m)).format("%.1f"); } - if (id == 277 || id == 278) { + if (id == 277 || id == 278) { // 最大摄氧量 (骑) / 最大摄氧量 (跑) var p = c.profile(); if (p == null) { return DASH; } - if (id == 278) { + if (id == 278) { // 最大摄氧量 (跑) return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH; } return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH; @@ -426,84 +531,87 @@ module Fields { return null; } - // Battery, alarms, notifications and radio state. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ---- 系统类 ---- + // 电量、闹钟、通知、勿扰、蓝牙、太阳能强度。 function system(id as Number, c as Ctx) as String? { var st = c.stats(); - if (id == 250) { + if (id == 250) { // 电量 return st == null || st.battery == null ? DASH : st.battery.format("%d") + "%"; } - if (id == 251) { + if (id == 251) { // 剩余天数 if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) { return DASH; } return st.batteryInDays.format("%d") + "d"; } - if (id == 257) { + if (id == 257) { // 电量 / 天数 var pct = (st == null || st.battery == null) ? DASH : st.battery.format("%d"); var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null) ? DASH : st.batteryInDays.format("%d"); return pct + "/" + d; } - if (id == 252) { + if (id == 252) { // 太阳能强度 if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) { return DASH; } return st.solarIntensity.format("%d") + "%"; } var ds = c.ds(); + // 闹钟 if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); } + // 通知 if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); } - if (id == 254) { + if (id == 254) { // 勿扰模式 return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF"); } - if (id == 256) { + if (id == 256) { // 蓝牙 return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF"); } return null; } - // Altitude, pressure, sun events and moon phase. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ---- 环境类 ---- + // 海拔、气压、各类太阳事件(含晨昏蒙影与黄金/蓝调时刻)、月相。 function environment(id as Number, c as Ctx) as String? { - if (id == 615) { + if (id == 615) { // 海拔 var a = c.act(); return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c); } - if (id == 616 || id == 733) { + if (id == 616 || id == 733) { // 海平面气压 / 环境气压 var a = c.act(); if (a == null) { return DASH; } var pa = null; - if (id == 616 && (a has :meanSeaLevelPressure)) { + if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压 pa = a.meanSeaLevelPressure; - } else if (id == 733 && (a has :ambientPressure)) { + } else if (id == 733 && (a has :ambientPressure)) { // 环境气压 pa = a.ambientPressure; } if (pa == null) { return DASH; } return (pa.toFloat() / 100.0).format("%d"); } - if (id == 712 || id == 717 || id == 700 || id == 731) { + if (id == 712 || id == 717 || id == 700 || id == 731) { // 日出 / 日落 / 下一太阳事件 / 距太阳事件 var loc = c.where(); if (loc == null) { return DASH; } var now = Time.now(); var rise = Weather.getSunrise(loc, now); var set = Weather.getSunset(loc, now); - if (id == 712) { return clock(rise, c); } - if (id == 717) { return clock(set, c); } + if (id == 712) { return clock(rise, c); } // 日出 + if (id == 717) { return clock(set, c); } // 日落 if (rise == null || set == null) { return DASH; } var next = (now.compare(rise) < 0) ? rise : ((now.compare(set) < 0) ? set : rise); - if (id == 700) { return clock(next, c); } + if (id == 700) { return clock(next, c); } // 下一太阳事件 var secs = next.compare(now); if (secs < 0) { secs = secs + 86400; } return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d"); } - if (id == 706) { return moonAge(c.greg).format("%.1f"); } - if (id == 732) { + if (Solar.owns(id)) { + return clock(Solar.byId(id, c.where()), c); + } + if (id == 706) { return moonAge(c.greg).format("%.1f"); } // 月龄 + if (id == 732) { // 月球照明 var age = moonAge(c.greg); var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0; return (lit * 100.0).format("%d") + "%"; @@ -512,66 +620,170 @@ module Fields { return null; } - // Garmin's cached conditions and today's forecast. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ---- Garmin 原生天气 ---- + // 手表同步下来的当前天气与今日预报。与 OWM 那套完全独立。 function weather(id as Number, c as Ctx) as String? { var w = c.wx(); + // 气温 if (id == 306) { return w == null ? DASH : temp(w.temperature, c); } - if (id == 300) { + if (id == 300) { // 体感温度 return (w == null || !(w has :feelsLikeTemperature)) ? DASH : temp(w.feelsLikeTemperature, c); } - if (id == 305) { + if (id == 305) { // 湿度 return (w == null || !(w has :relativeHumidity)) ? DASH : num(w.relativeHumidity) + "%"; } - if (id == 304) { + if (id == 304) { // 降水概率 if (w == null || !(w has :precipitationChance)) { return DASH; } return num(w.precipitationChance) + "%"; } - if (id == 308) { + if (id == 308) { // 风速 if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; } var kmh = w.windSpeed.toFloat() * 3.6; return (c.metric ? kmh : kmh * 0.621371).format("%d"); } - if (id == 307) { + if (id == 307) { // 风向角 return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing); } - if (id == 310) { + if (id == 310) { // 风向 return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing); } - if (id == 309) { + if (id == 309) { // 天气更新时间 if (w == null || !(w has :observationTime) || w.observationTime == null) { return DASH; } return clock(w.observationTime, c); } - if (id == 301 || id == 302 || id == 639) { + if (id == 312) { // 海平面气压 + if (w == null || !(w has :pressure) || w.pressure == null) { return DASH; } + return (w.pressure.toFloat() / 100.0).format("%d"); + } + if (id == 313) { // 紫外线指数 + return (w == null || !(w has :uvIndex)) ? DASH : num(w.uvIndex); + } + if (id == 314) { // 能见度 + if (w == null || !(w has :visibility) || w.visibility == null) { return DASH; } + var km = w.visibility.toFloat() / 1000.0; + return (c.metric ? km : km * 0.621371).format("%.1f"); + } + if (id == 315) { // 露点 + // CIQ 没有露点字段,用 Magnus 公式从温度和湿度反推, + // 误差在零点几度以内,够用。 + if (w == null || w.temperature == null + || !(w has :relativeHumidity) || w.relativeHumidity == null) { + return DASH; + } + var t = w.temperature.toFloat(); + var rh = w.relativeHumidity.toFloat(); + if (rh <= 0.0) { return DASH; } + var g2 = (17.62 * t) / (243.12 + t) + Math.ln(rh / 100.0); + return temp(243.12 * g2 / (17.62 - g2), c); + } + if (id == 301 || id == 302 || id == 639) { // 最高气温 / 最低气温 / 最高/最低气温 var f = c.forecast(); if (f == null) { return DASH; } var hi = (f has :highTemperature) ? f.highTemperature : null; var lo = (f has :lowTemperature) ? f.lowTemperature : null; - if (id == 301) { return temp(hi, c); } - if (id == 302) { return temp(lo, c); } + if (id == 301) { return temp(hi, c); } // 最高气温 + if (id == 302) { return temp(lo, c); } // 最低气温 return temp(hi, c) + "/" + temp(lo, c); } return null; } - // Free text the wearer typed into the settings. - // Returns null when the id is not one of ours, so value() - // can try the next category. + // ---- OpenWeatherMap ---- + // 读后台服务上次存下来的数据(本函数不联网,见 Owm.mc / Fenix8V3Background.mc)。 + // 后台一律用公制请求,单位换算和其他字段一样在这里做。 + function owm(id as Number, c as Ctx) as String? { + if (!Owm.owns(id)) { return null; } + if (!Owm.enabled()) { return DASH; } + if (id == 500) { return temp(Owm.get("temp"), c); } // OWM 气温 + if (id == 503) { return temp(Owm.get("feels"), c); } // OWM 体感温度 + if (id == 504) { return temp(Owm.get("tmax"), c); } // OWM 最高温 + if (id == 505) { return temp(Owm.get("tmin"), c); } // OWM 最低温 + if (id == 515) { // OWM 最高/最低 + return temp(Owm.get("tmax"), c) + "/" + temp(Owm.get("tmin"), c); + } + if (id == 507) { return str(Owm.get("short")); } // OWM 天气 + if (id == 502) { return str(Owm.get("long")); } // OWM 天气详述 + if (id == 501) { // OWM 湿度 + var h = Owm.get("humidity"); + return h == null ? DASH : num(h) + "%"; + } + if (id == 506) { return num(Owm.get("pressure")); } // OWM 气压 + if (id == 509) { // OWM 能见度 + var v = Owm.get("visibility"); + if (v == null) { return DASH; } + var km = v.toFloat() / 1000.0; + return (c.metric ? km : km * 0.621371).format("%.1f"); + } + if (id == 510 || id == 512) { // OWM 风速 / OWM 阵风 + var w = Owm.get(id == 510 ? "wind" : "gust"); + if (w == null) { return DASH; } + var kmh = w.toFloat() * 3.6; + return (c.metric ? kmh : kmh * 0.621371).format("%d"); + } + if (id == 511) { return num(Owm.get("winddeg")); } // OWM 风向角 + if (id == 518) { return windDir(Owm.get("winddeg")); } // OWM 风向 + if (id == 513) { // OWM 1h 降雨 + var r = Owm.get("rain"); + return r == null ? "0" : r.toFloat().format("%.1f"); + } + if (id == 532) { // OWM 1h 降雪 + var sn = Owm.get("snow"); + return sn == null ? "0" : sn.toFloat().format("%.1f"); + } + if (id == 536) { // OWM 云量 + var cl = Owm.get("clouds"); + return cl == null ? DASH : num(cl) + "%"; + } + if (id == 537) { // OWM 降水概率 + var pop = Owm.get("pop"); + return pop == null ? DASH : (pop.toFloat() * 100.0).format("%d") + "%"; + } + if (id == 530) { // OWM 露点 + // OWM 免费接口不返回露点,同样用 Magnus 公式从温湿度算。 + var t = Owm.get("temp"); + var rh = Owm.get("humidity"); + if (t == null || rh == null || rh.toFloat() <= 0.0) { return DASH; } + var tf = t.toFloat(); + var gg = (17.62 * tf) / (243.12 + tf) + Math.ln(rh.toFloat() / 100.0); + return temp(243.12 * gg / (17.62 - gg), c); + } + if (id == 534) { return temp(Owm.get("hi1"), c); } // OWM 明日气温 + if (id == 535) { return temp(Owm.get("hi2"), c); } // OWM 后日气温 + if (id == 522) { // OWM 明日高低温 + return temp(Owm.get("hi1"), c) + "/" + temp(Owm.get("lo1"), c); + } + if (id == 523) { // OWM 后日高低温 + return temp(Owm.get("hi2"), c) + "/" + temp(Owm.get("lo2"), c); + } + if (id == 516) { return str(Owm.get("city")); } // OWM 城市 + if (id == 517) { // OWM 更新时间 + var dt = Owm.get("dt"); + return dt == null ? DASH : clock(new Time.Moment(dt as Number), c); + } + return DASH; + } + + function str(v) as String { + return v == null ? DASH : v.toString(); + } + + // ---- 自定义文字 ---- + // 用户在设置里自己填的三段文字。 function custom(id as Number, c as Ctx) as String? { - if (id == 703) { return Settings.text("Custom1"); } - if (id == 704) { return Settings.text("Custom2"); } - if (id == 705) { return Settings.text("Custom3"); } + if (id == 703) { return Settings.text("Custom1"); } // 自定义文字 1 + if (id == 704) { return Settings.text("Custom2"); } // 自定义文字 2 + if (id == 705) { return Settings.text("Custom3"); } // 自定义文字 3 return null; } - // Text for one data slot. Categories are tried in turn rather - // than dispatched on an id range, because the ids interleave. + // 取某个字段的显示文本。 + // 按分类依次尝试,而不是按编号区间派发 —— 编号是交错的(1/2/3 活动、 + // 8/11/12 身体、14 又是活动),区间派发行不通。 function value(id as Number, c as Ctx) as String { var v = dateTime(id, c); if (v == null) { v = activity(id, c); } @@ -579,6 +791,7 @@ module Fields { if (v == null) { v = system(id, c); } if (v == null) { v = environment(id, c); } if (v == null) { v = weather(id, c); } + if (v == null) { v = owm(id, c); } if (v == null) { v = custom(id, c); } return v == null ? "" : v; } diff --git a/source/Icons.mc b/source/Icons.mc index 7a20921..41fce08 100644 --- a/source/Icons.mc +++ b/source/Icons.mc @@ -3,24 +3,34 @@ import Toybox.Graphics; import Toybox.WatchUi; import Layout; -// The glyphs that sit above the two top data slots, plus the battery and the -// bluetooth rune. +// ============================================================================ +// 图标 +// ---------------------------------------------------------------------------- +// 顶部两个数据位上方的图标,加上电池和蓝牙符号。 // -// The comps contain three icons -- sunrise, weather, battery. The first two -// are traced outlines whose finest features are two or three design pixels -// across; rebuilding them from circles and lines looked right at 454 px and -// turned to mush at 280. They ship instead as bitmaps rasterised from the -// design paths once per screen width (tools/gen_icons.py) and are tinted with -// the theme's accent at draw time. +// 设计稿里只有三个图标:日出、天气、电池。 // -// Everything else -- steps, calories, stairs, heart -- has no counterpart in -// the comps, so those stay as primitives sized off the same design canvas. +// 前两个是位图描出来的矢量轮廓,最细的特征只有 2–3 个设计像素宽。一开始我用 +// 圆、线段、多边形去「重画」它们,在 454px 上看着还行,一到 280px(Enduro 3) +// 就糊成一团 —— 因为 s=0.56 时那些细节不足 1 个物理像素。 +// 中间还试过做一版「低细节」图元,结果更糟:日出变成三条横杠。 +// +// 现在的做法是:用 tools/gen_icons.py 把**设计稿的路径**按每种屏宽各光栅化一 +// 张位图,运行时用 drawBitmap2(:tintColor) 按主题色染色。这样每台设备拿到的 +// 都是为它的像素网格专门渲染的图。 +// +// 其余图标(步数、卡路里、楼梯、心形…)设计稿里根本没有,所以保持图元绘制, +// 尺寸同样基于 500 设计画布。 +// ============================================================================ module Icons { + // 三张位图在 onLayout 时一次性加载(loadResource 开销不小,不能放 onUpdate) var mSunrise = null; var mSunset = null; var mWeather = null; + // 加载图标位图。资源按屏宽分目录,monkey.jungle 里为每台设备指定了 + // resources-icons-<屏宽>,所以这里拿到的自动就是对的尺寸。 function load() as Void { mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise); mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset); @@ -29,8 +39,9 @@ module Icons { function ready() as Boolean { return mSunrise != null; } - // drawBitmap2 refuses a palettised source, which is why the generated - // drawables carry packingFormat="png" to keep their alpha channel. + // ⚠️ drawBitmap2 的 :tintColor 拒绝**调色板化**的位图,运行时会抛 + // "Source must not use a color palette"。所以生成的 drawables.xml 里每个 + // bitmap 都带 packingFormat="png",保住 alpha 通道不被压成调色板。 function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void { if (bmp == null) { return; } dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2, @@ -38,8 +49,9 @@ module Icons { bmp, {:tintColor => col}); } - // Outline, charge bar and terminal. The bar tracks the real level, the - // way the comp draws it at 40%. + // 电池:外框 + 内部电量条 + 右侧触点。 + // 电量条长度跟随真实电量(设计稿画的是 40% 的状态)。 + // 画法是「先整块填充,再用黑色掏空内部,最后按比例填回电量条」。 function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, k as Float, col as Number, level as Float) as Void { var x0 = Layout.px(x); @@ -62,7 +74,7 @@ module Icons { } } - // The standard bluetooth rune, drawn as one polyline. + // 标准蓝牙符号,用一条折线一笔画成(六段,中间的竖线会被走两次)。 function bluetooth(dc as Graphics.Dc, col as Number) as Void { dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.setPenWidth(Layout.pen(3.2)); @@ -79,8 +91,9 @@ module Icons { Layout.line(dc, r, qt, l, qb); } - // Which glyph, if any, belongs above a given field id. Fields with no - // natural icon -- dates, week numbers, custom text -- read fine bare. + // 按字段编号决定上方画哪个图标。 + // 日期、周数、自定义文字这类字段没有合适的图标,就不画 —— 光有数值也读得懂, + // 硬配一个反而干扰。 function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric, col as Number, level as Float) as Void { if (id == 712 || id == 700) { @@ -105,25 +118,25 @@ module Icons { var cyd = Layout.ICON_CY; dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.setPenWidth(Layout.pen(3.0)); - if (id == 2 || id == 603) { // calories: flame + if (id == 2 || id == 603) { // 卡路里:火焰 dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)], [Layout.px(xDesign + r * 0.72), Layout.px(cyd + r * 0.25)], [Layout.px(xDesign + r * 0.40), Layout.px(cyd + r * 0.85)], [Layout.px(xDesign - r * 0.40), Layout.px(cyd + r * 0.85)], [Layout.px(xDesign - r * 0.72), Layout.px(cyd + r * 0.25)]]); - } else if (id == 1 || id == 14 || id == 707) { // steps: two prints + } else if (id == 1 || id == 14 || id == 707) { // 步数:两个脚印 dc.fillRoundedRectangle(Layout.px(xDesign - r * 0.85), Layout.px(cyd - r * 0.75), Layout.px(r * 0.62), Layout.px(r * 1.15), Layout.px(r * 0.3)); dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35), Layout.px(r * 0.62), Layout.px(r * 1.15), Layout.px(r * 0.3)); - } else if (id == 3 || id == 9 || id == 615) { // distance: arrow + } else if (id == 3 || id == 9 || id == 615) { // 距离/海拔:箭头 dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)], [Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)], [Layout.px(xDesign), Layout.px(cyd + r * 0.35)], [Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]); - } else if (id == 618 || id == 276 || id == 623) { // heart + } else if (id == 618 || id == 276 || id == 623) { // 心率/静息/体能:心形 dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28), Layout.px(r * 0.44)); dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28), @@ -131,7 +144,7 @@ module Icons { dc.fillPolygon([[Layout.px(xDesign - r * 0.78), Layout.px(cyd - r * 0.12)], [Layout.px(xDesign + r * 0.78), Layout.px(cyd - r * 0.12)], [Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]); - } else if (id == 4 || id == 5 || id == 6) { // floors: stairs + } else if (id == 4 || id == 5 || id == 6) { // 楼层:台阶 for (var i = 0; i < 3; i++) { dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i), Layout.px(cyd + r * 0.6 - r * 0.55 * i), diff --git a/source/Layout.mc b/source/Layout.mc index e910132..929d4c8 100644 --- a/source/Layout.mc +++ b/source/Layout.mc @@ -2,52 +2,57 @@ import Toybox.Lang; import Toybox.Graphics; import Toybox.Math; -// Geometry and text plumbing for the face. +// ============================================================================ +// 布局与文字绘制 +// ---------------------------------------------------------------------------- +// 本文件里所有的数字都是**设计稿 500×500 画布上的坐标**,全部来自 +// design/watchface-*.svg 的实测: +// - 进度环的几何参数取自 design/rebuild.py(原作者从位图逆向出的常量) +// - 各元素位置取自 SVG 描边路径的墨迹包围盒,不是目测的 // -// Every literal here is a coordinate on the design's 500x500 canvas, measured -// from design/watchface-*.svg -- ring numbers come from design/rebuild.py, the -// element positions are the ink bounding boxes of the traced SVG paths. At -// draw time everything is multiplied by s = screenWidth / 500, so one set of -// numbers serves screens from 240 px (fenix 7S) to 454 px (venu 3). +// 绘制时统一乘以 s = 屏宽 / 500,所以同一套数字可以覆盖 +// 240px(fenix 7S)到 454px(venu 3)全部 17 款设备。 // -// Call init() from the view's onLayout before anything else touches this. +// ⚠️ 使用前必须先由 view 的 onLayout 调用一次 init()。 +// ============================================================================ module Layout { - // ---------------------------------------------------------------- ring - // Ticks are annular sectors of constant angular width, not radial lines, - // which is why they are drawn as four-point polygons rather than strokes. - const R_IN = 231.09; // inner edge of the tick band - const R_OUT = 249.10; // outer edge - const TOP_N = 21; // ticks per top arc - const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12 + // ------------------------------- 进度环 ------------------------------- + // 刻度是**等角宽的环形扇区**,不是从圆心发射的线段 —— 所以画的时候用四点 + // 多边形填充,而不是 drawLine 描边。这点很关键:用线段画会导致刻度在内圈 + // 挤、外圈疏,与设计稿对不上。 + const R_IN = 231.09; // 刻度带内边缘半径 + const R_OUT = 249.10; // 刻度带外边缘半径 + const TOP_N = 21; // 每条顶弧的刻度数 + const TOP_PH = 6.500; // 第 0 根刻度的角度(自 12 点起顺时针,度) const TOP_PITCH = 4.000; - const TOP_HW = 1.554; // half of the 3.108 deg tick width - const BOT_N = 21; // ticks per bottom arc - const BOT_PH = 6.005; // measured clockwise from 6 o'clock + const TOP_HW = 1.554; // 刻度角宽 3.108° 的一半 + const BOT_N = 21; // 每条底弧的刻度数 + const BOT_PH = 6.005; // 底弧角度自 6 点起量 const BOT_PITCH = 3.000; const BOT_HW = 1.043; - const DOT_RAD = 240.10; // anchor dots sit on the band's mid-radius + const DOT_RAD = 240.10; // 四个锚点圆点所在半径(正好是刻度带中线) const DOT_R = 9.00; const DOT_ANG = [0.0, 93.15, 180.0, 266.85]; - // ------------------------------------------------------------- elements - const BATT_X = 192.70; // battery body, left edge - const BATT_W = 26.20; // body only; the terminal sits to its right + // ------------------------------- 各元素 ------------------------------- + const BATT_X = 192.70; // 电池外框左边缘 + const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算 const BATT_H = 17.81; - const BATT_WALL = 2.20; // outline thickness - const BATT_TW = 2.53; // terminal + const BATT_WALL = 2.20; // 外框线宽 + const BATT_TW = 2.53; // 右侧触点宽 const BATT_TH = 9.20; - const BATT_VX = 232.00; // "40%" left edge + const BATT_VX = 232.00; // 百分比文字左边缘 const BATT_CY = 53.20; - const LEFT_X = 157.00; // top data slots, centred + const LEFT_X = 157.00; // 顶部两个数据位的中心 x(居中对齐) const RIGHT_X = 343.70; - const ICON_CY = 89.50; // matches the icon bitmaps' own centre + const ICON_CY = 89.50; // 与图标位图自身的中心对齐,别随意改 const VALUE_CY = 131.06; const TIME_CY = 221.10; - const HOURS_R = 211.40; // hour digits, right edge - const MINUTES_L = 276.02; // minute digits, left edge + const HOURS_R = 211.40; // 小时数字右边缘(右对齐基准) + const MINUTES_L = 276.02; // 分钟数字左边缘(左对齐基准) const COLON_X = 237.88; const COLON_W = 24.24; const COLON_H = 21.30; @@ -57,7 +62,7 @@ module Layout { const BAND_Y = 286.30; const BAND_H = 44.19; const BAND_CY = 308.10; - const BT_CX = 36.88; // bluetooth rune + const BT_CX = 36.88; // 蓝牙符号中心 const BT_CY = 306.99; const BT_HW = 8.89; const BT_HH = 12.92; @@ -67,30 +72,37 @@ module Layout { const BOT_VAL_CY = 366.76; const BOT_LAB_CY = 410.88; - const DIST_R = 234.30; // right edge of both left-hand bottom lines - const STEP_L = 267.40; // left edge of both right-hand bottom lines + const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准 + const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准 - // Cap heights of the design's three text sizes. - const CAP_BAND = 25.34; // date band, battery percentage - const CAP_COMP = 31.50; // data slots, bottom row - const CAP_TIME = 98.70; // time digits + // 设计稿里三档文字的**字面高度**(cap height,大写字母/数字的高度, + // 不含上下留白)。字号选择就是按这个来匹配的。 + const CAP_BAND = 25.34; // 日期带、电量百分比 + const CAP_COMP = 31.50; // 四个数据位、底部行 + const CAP_TIME = 98.70; // 时间大数字 - // How much room a value may take before it is stepped down a size. The - // top slots are centred, so this is the full width they may span; the - // bottom pair are edge-aligned and share the middle of the dial. + // 数值最多占多宽,超了就降一档字号。 + // 顶部两格是居中对齐,这个值是它能横跨的总宽度; + // 底部两格是边对齐,各占表盘中间往外的一半。 + // 有了这个,用户就算把 6 个汉字的长日期放进底部格也不会压到进度环上。 const TOP_MAX_W = 185.0; const BOT_MAX_W = 150.0; - // Connect IQ exposes ascent and descent but not cap height, and the ascent - // reserves room for diacritics that no digit or capital ever uses. These - // two ratios were measured by rendering into the simulator and reading the - // ink back off the screenshot (fenix847mm: FONT_XTINY ascent 29 -> caps - // 22 px; FONT_NUMBER_MILD ascent 82 -> digits 58 px). + // ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight,**没有字面高度接口**。 + // 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent + // 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有 + // 设计稿的 57%)。 + // + // 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。 + // fenix847mm:FONT_XTINY ascent 29 → 大写实际高 22px → 0.76 + // FONT_NUMBER_MILD ascent 82 → 数字实际高 58px → 0.71 + // 各机型内置字体不完全相同(Roboto / RobotoCondensed / Yantramanav / + // 各机型位图字体),比例会有几个百分点误差,可以接受。 const CAP_OF_TEXT = 0.76; const CAP_OF_NUM = 0.71; - // --------------------------------------------------------------- state - var scale = 1.0; // device px per design px + // ------------------------------- 运行时状态 ---------------------------- + var scale = 1.0; // 设备像素 / 设计像素 var width = 0; var height = 0; var cx = 0; @@ -99,8 +111,7 @@ module Layout { var fontComp = Graphics.FONT_MEDIUM; var fontTime = Graphics.FONT_NUMBER_HOT; - // Text sizes in ascending order, so a value that will not fit can walk - // down the list until it does. + // 文字字号按从小到大排列,这样放不下的值可以沿着这张表往下逐档缩。 var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL, Graphics.FONT_MEDIUM, Graphics.FONT_LARGE]; @@ -119,7 +130,7 @@ module Layout { function ready() as Boolean { return width > 0; } - // Design pixels to device pixels. + // 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。 function px(v as Numeric) as Number { return Math.round(v * scale).toNumber(); } @@ -128,8 +139,8 @@ module Layout { return Graphics.getFontAscent(font) * ratio; } - // Closest cap height wins -- not "largest that still fits", which throws - // away a whole size whenever the design falls between two fonts. + // 选**字面高度最接近**目标的那一档,而不是「不超过目标的最大档」。 + // 后者在设计尺寸恰好卡在两档字体之间时会白白小一号。 function pickFont(cands as Array, targetCap as Float, ratio as Float) as Graphics.FontDefinition { var best = cands[0]; @@ -142,8 +153,8 @@ module Layout { return best; } - // A long value -- twelve characters of custom text, say -- would run off - // the dial at the design's size. Step down until it fits the slot. + // 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会 + // 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。 function fitFont(dc as Graphics.Dc, str as String, font as Graphics.FontDefinition, maxDesignW as Float) as Graphics.FontDefinition { @@ -154,7 +165,7 @@ module Layout { if (textFonts[i] == f) { break; } i--; } - if (i < 0) { return f; } // not one of the text fonts + if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它 while (i >= 0) { if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) { return textFonts[i]; @@ -164,9 +175,9 @@ module Layout { return textFonts[0]; } - // TEXT_JUSTIFY_VCENTER centres the font box, but capitals occupy only the - // band from the baseline up by capHeight, so the ink sits high in that box. - // Push the draw down by the difference to land the ink on cy. + // TEXT_JUSTIFY_VCENTER 居中的是**字体盒子**,而大写字母/数字只占据 + // 「基线往上 capHeight」这一段,在盒子里是偏上的。 + // 所以要往下推一个差值,才能让实际墨迹的中心落在 cy 上。 function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition, cyDesign as Numeric, ratio as Float) as Number { var fh = dc.getFontHeight(font); @@ -182,17 +193,18 @@ module Layout { just | Graphics.TEXT_JUSTIFY_VCENTER); } - // Same, but shrinks the font if the string is too wide for its slot. + // 同上,但会在字符串超出槽位宽度时自动降字号。 function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, font as Graphics.FontDefinition, str as String, just as Number, col as Number, maxDesignW as Float) as Void { text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col); } - // Monkey C cannot fill glyphs with a gradient, so the string is drawn once - // per stop with dc.setClip holding each pass to its own horizontal band. - // The first and last band are extended off-glyph so no sliver of the - // digits is ever left undrawn. + // Monkey C 没法给文字填渐变。这里的做法是:**同一串数字画 N 遍**,每遍用 + // dc.setClip 把绘制限制在一条横向色带内,色带颜色取自主题的渐变停止点。 + // + // 首尾两条色带故意向外延伸到屏幕边缘,保证字形上下不会有没画到的缝隙 + // (字体实际墨迹高度和我们估算的 capHeight 总有几像素出入)。 function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, font as Graphics.FontDefinition, str as String, just as Number, ramp as Array) as Void { @@ -213,7 +225,7 @@ module Layout { dc.clearClip(); } - // Design angles run clockwise from 12 o'clock; screen y grows downward. + // 设计稿的角度自 12 点起顺时针为正;屏幕 y 轴向下为正,所以是 cy - cos。 function polarX(ang as Float, r as Float) as Number { return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber(); } @@ -227,7 +239,7 @@ module Layout { dc.drawLine(px(x1), px(y1), px(x2), px(y2)); } - // Pen widths round to zero on small screens; never let one vanish. + // 线宽在小屏上会被 round 成 0 直接消失,这里兜一个最小值 2。 function pen(v as Numeric) as Number { var w = px(v); return w < 2 ? 2 : w; diff --git a/source/Owm.mc b/source/Owm.mc new file mode 100644 index 0000000..8d3affd --- /dev/null +++ b/source/Owm.mc @@ -0,0 +1,68 @@ +import Toybox.Lang; +import Toybox.Application; +import Toybox.Math; +import Toybox.Time; +import Toybox.Time.Gregorian; +import Settings; + +// ============================================================================ +// OpenWeatherMap —— 读取端 +// ---------------------------------------------------------------------------- +// 本模块**不联网**,只负责把后台服务上次存下来的数据读出来。 +// 真正的网络请求在 Fenix8V3Background.mc 里,由系统按定时器唤醒执行。 +// +// 为什么要拆成两半:表盘进程绝大多数时间根本没在运行,onUpdate 又必须够快, +// 不可能在绘制路径里发 HTTP 请求。Connect IQ 给的方案就是后台服务 —— 定时唤醒、 +// 请求、把结果通过 Background.exit() 交回前台,前台再写进 Application.Storage。 +// +// API key 由用户自己在设置里填(免费档就够)。没填 key 时: +// - enabled() 返回 false,后台定时器根本不会注册,一次网络都不会发 +// - 25 个 OWM 字段全部显示 "--" +// +// 后台服务会把两个接口的响应**压平成一个扁平字典**再存,这样表盘侧永远不用在 +// 内存里扛一棵解析好的 JSON 树(手表内存很紧张)。 +// ============================================================================ +module Owm { + + const STORE = "owm"; // 在 Application.Storage 里的键名 + const MAX_AGE = 7200; // 超过两小时的数据视为过期(秒) + + // 用户填的 API key;没填返回空串。 + function key() as String { + return Settings.text("OwmKey"); + } + + // 只有填了 key 才算启用。这个判断同时决定了后台定时器要不要注册, + // 见 Fenix8V3App.scheduleWeather()。 + function enabled() as Boolean { + return key().length() > 0; + } + + // 取出后台存的整个扁平字典;从没成功拉取过则为 null。 + function data() as Dictionary? { + var d = Application.Storage.getValue(STORE); + return (d == null) ? null : (d as Dictionary); + } + + // 取单个字段。键名是后台服务定义的短名("temp"/"feels"/"hi1"…), + // 与 Fenix8V3Background.mc 里的 put() 调用一一对应。 + function get(field as String) { + var d = data(); + if (d == null) { return null; } + return d.get(field); + } + + // 数据是否还新鲜。目前 Fields 里没有强制用它 —— 手表离线一段时间后 + // 显示稍旧的天气,比直接显示 "--" 更有用;留着给需要的地方调用。 + function fresh() as Boolean { + var t = get("dt"); + if (t == null) { return false; } + return (Time.now().value() - (t as Number)) < MAX_AGE; + } + + // 该字段编号是否属于 OWM 分类。500–537 这一段全是 OWM 的编号, + // 与 Garmin 原生天气(300–315)完全分开。 + function owns(id as Number) as Boolean { + return (id >= 500 && id <= 537) || id == 502 || id == 503; + } +} diff --git a/source/Settings.mc b/source/Settings.mc index c7553cb..f9d6c10 100644 --- a/source/Settings.mc +++ b/source/Settings.mc @@ -1,58 +1,92 @@ import Toybox.Lang; import Toybox.Application; -// Every stored preference in one place, with the defaults that reproduce the -// design comps. Keys match resources/settings/properties.xml. +// ============================================================================ +// 设置读取 +// ---------------------------------------------------------------------------- +// 所有存储的偏好设置集中在这里,默认值就是设计稿的那套配置。 +// 键名与 resources/settings/properties.xml 一一对应。 // -// Properties.getValue returns null for a key the watch has not stored yet -- -// a fresh install, or a setting added by an update -- so every read falls back -// to the default rather than trusting the stored value. +// 为什么要包一层而不是各处直接调 Properties.getValue: +// Connect IQ 的 Properties.getValue 遇到「手表还没存过的键」时**不是返回 null, +// 而是抛 Out of Bounds 异常**。每次版本更新新增设置项,在手表把新的 +// properties.xml 合并进去之前都会撞上这个坑,轻则某个字段读不到,重则整个表盘 +// 崩掉。所以全部读取都走本模块,异常一律降级成默认值。 +// ============================================================================ module Settings { - // Field ids follow https://watchface.io/docs/datafields. - const DEF_LEFT_TOP = 712; // sunrise - const DEF_RIGHT_TOP = 639; // forecast high / low - const DEF_BOTTOM_LEFT = 3; // distance - const DEF_BOTTOM_RIGHT = 1; // steps + // ---- 四个数据位的默认字段 ---- + // 字段编号沿用 https://watchface.io/docs/datafields,与 Fields.mc 中一致。 + // 这四个默认值合起来就是设计稿上的样子。 + const DEF_LEFT_TOP = 712; // 左上:日出 + const DEF_RIGHT_TOP = 639; // 右上:预报最高/最低气温 + const DEF_BOTTOM_LEFT = 3; // 左下:距离 + const DEF_BOTTOM_RIGHT = 1; // 右下:步数 + // ------------------------------------------------------------------------ + // 底层读取:吞掉「键不存在」异常,返回 null 交给上层兜底。 + // 注意 catch 在这里是有效的(Properties 抛的是普通异常);而 Background + // 模块的 Out of Bounds 属于 VM 级错误,catch 不住,那边只能用别的办法绕开, + // 详见 Fenix8V3App.scheduleWeather()。 + // ------------------------------------------------------------------------ + function raw(key as String) { + try { + return Application.Properties.getValue(key); + } catch (e) { + return null; + } + } + + // 读数字型设置,取不到就用 def。 function number(key as String, def as Number) as Number { - var v = Application.Properties.getValue(key); + var v = raw(key); if (v == null) { return def; } return v as Number; } + // 读布尔型设置,取不到就用 def。 function bool(key as String, def as Boolean) as Boolean { - var v = Application.Properties.getValue(key); + var v = raw(key); if (v == null) { return def; } return v as Boolean; } + // 读字符串型设置,取不到返回空串(不是 null,省得每个调用点再判一次)。 function text(key as String) as String { - var v = Application.Properties.getValue(key); + var v = raw(key); if (v == null) { return ""; } return v.toString(); } - // Theme index, clamped into range in case a stored value outlives a - // release that removed a theme. + // ------------------------------------------------------------------------ + // 主题序号(0 基)。做范围钳制是为了防止这种情况:用户存的是「主题 7」, + // 之后某个版本删掉了一套主题,存量值就会越界 —— 直接崩数组下标。 + // ------------------------------------------------------------------------ function themeIndex(count as Number) as Number { var i = number("Theme", 1) - 1; if (i < 0 || i >= count) { return 0; } return i; } + // ---- 四个数据位选中的字段编号 ---- function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); } function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); } function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); } function bottomRight() as Number { return number("BottomRight", DEF_BOTTOM_RIGHT); } - function ringTR() as Number { return number("RingTR", 0); } - function ringTL() as Number { return number("RingTL", 1); } - function ringBR() as Number { return number("RingBR", 4); } - function ringBL() as Number { return number("RingBL", 3); } + // ---- 进度环四段各自绑定的指标 ---- + // 注意这里的编号是进度环自己的一张短表(0 步数 / 1 卡路里 / 2 距离 / + // 3 楼层 / 4 动动条 / 5 活动分钟 / 6 身体电量),与上面数据位的字段编号 + // 是两套体系,不要混用。取值逻辑在 Fenix8V3View.ringProgress()。 + function ringTR() as Number { return number("RingTR", 0); } // 右上:步数 + function ringTL() as Number { return number("RingTL", 1); } // 左上:卡路里 + function ringBR() as Number { return number("RingBR", 4); } // 右下:动动条 + function ringBL() as Number { return number("RingBL", 3); } // 左下:楼层 + // 温度单位:0 跟随手表 / 1 强制摄氏 / 2 强制华氏。 function tempUnit() as Number { return number("TempUnit", 0); } + // ---- 三个显隐开关 ---- function showTop() as Boolean { return bool("ShowTop", true); } function showBand() as Boolean { return bool("ShowBand", true); } function showBottom() as Boolean { return bool("ShowBottom", true); } diff --git a/source/Solar.mc b/source/Solar.mc new file mode 100644 index 0000000..8f9965b --- /dev/null +++ b/source/Solar.mc @@ -0,0 +1,120 @@ +import Toybox.Lang; +import Toybox.Math; +import Toybox.Time; +import Toybox.Time.Gregorian; +import Toybox.Position; + +// ============================================================================ +// 太阳高度角事件 +// ---------------------------------------------------------------------------- +// Connect IQ 只给了 Weather.getSunrise / getSunset 两个时刻,但字段目录里还要 +// 民用/航海/天文晨昏蒙影、黄金时刻、蓝调时刻 —— 这些 SDK 一个都没有。 +// +// 好在它们本质是同一个问题换个参数:**太阳中心到达高度角 h 的时刻是几点**。 +// 所以只写一个 event() 就够了,各事件只是 h 不同: +// 日出日落 -0.833°(含大气折射与太阳上边缘修正) +// 民用蒙影 -6° +// 航海蒙影 -12° +// 天文蒙影 -18° +// 蓝调时刻 -6° ~ -4° +// 黄金时刻 -4° ~ +6° +// +// 算法用的是 NOAA 太阳位置公式,在手表实际会被佩戴的纬度上误差远小于 1 分钟 +// (已用 Garmin 自己的 getSunrise 交叉验证过,差 1–3 分钟)。 +// 极圈内某天太阳压根到不了要求的高度时返回 null,上层显示 "--"。 +// ============================================================================ +module Solar { + + // 各事件对应的太阳高度角(度)。 + const ALT_SUNRISE = -0.833d; // upper limb, refraction included + const ALT_CIVIL = -6.0d; + const ALT_NAUTICAL = -12.0d; + const ALT_ASTRO = -18.0d; + const ALT_BLUE = -4.0d; // blue hour runs -6 to -4 + const ALT_GOLDEN = 6.0d; // golden hour runs -4 to +6 + + const RAD = Math.PI / 180.0d; + + // 从 2000-01-01 到当天的**整数天数**,再按经度往东/西挪一点, + // 让后面算出的中天时刻落在当地正午而不是 UT 正午。 + // + // ⚠️ 这里必须取整数天。第一版忘了取整,儒略日末尾的 .5 被带了进去, + // 结果中天算到了午夜,日出日落直接对调、整体偏了 1.5 天。 + // 是靠与 Garmin 的 getSunrise 对比才发现的。 + function daysFromEpoch(g as Gregorian.Info, lonDeg as Double) as Double { + var y = g.year; + var m = g.month; + if (m <= 2) { y = y - 1; m = m + 12; } + var a = y / 100; + var b = 2 - a + a / 4; + var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber() + + g.day + b - 1524.5; + var whole = Math.round(jd.toDouble() - 2451545.0d + 0.5d); + return whole + 0.0008d - lonDeg / 360.0d; + } + + // 太阳位于高度角 h 时的时角(度)。 + // acos 的参数超出 [-1,1] 说明这一天太阳始终在该高度之上或之下 + // (极昼极夜),此时返回 null。 + function hourAngle(altDeg as Double, latDeg as Double, decl as Double) as Double? { + var latR = latDeg * RAD; + var c = (Math.sin(altDeg * RAD) - Math.sin(latR) * Math.sin(decl)) + / (Math.cos(latR) * Math.cos(decl)); + if (c > 1.0 || c < -1.0) { return null; } + return Math.acos(c) / RAD; + } + + // 太阳到达 altDeg 的时刻。morning=true 取东边那次穿越(上午), + // false 取西边那次(下午)。 + function event(altDeg as Double, loc as Position.Location?, + morning as Boolean) as Time.Moment? { + if (loc == null) { return null; } + var deg = loc.toDegrees(); + var lat = deg[0].toDouble(); + var lon = deg[1].toDouble(); + + var now = Time.now(); + var g = Gregorian.utcInfo(now, Time.FORMAT_SHORT); + var n = daysFromEpoch(g, lon); + + // 平近点角 M、中心差 C、黄经 lambda —— NOAA 公式的三步。 + var M = (357.5291d + 0.98560028d * n) * RAD; + var C = 1.9148d * Math.sin(M) + 0.0200d * Math.sin(2 * M) + + 0.0003d * Math.sin(3 * M); + var lambda = (M / RAD + C + 180.0d + 102.9372d) * RAD; + // 中天时刻(当地真正午),仍以「距历元的天数」表示。 + var transit = 2451545.0d + n + 0.0053d * Math.sin(M) + - 0.0069d * Math.sin(2 * lambda); + var decl = Math.asin(Math.sin(lambda) * Math.sin(23.4397d * RAD)); + + var w = hourAngle(altDeg, lat, decl); + if (w == null) { return null; } + var jd = morning ? (transit - w / 360.0d) : (transit + w / 360.0d); + + // 儒略日换算回 Unix 时间戳。Time.Moment 收的是 UTC 秒数, + // 之后 Gregorian.info() 会自动转成本地时间显示。 + var unix = (jd - 2440587.5d) * 86400.0d; + return new Time.Moment(unix.toNumber()); + } + + // 按字段编号返回对应事件的时刻;不属于本模块的编号返回 null。 + // 蓝调/黄金时刻取的是「该时段开始的那一刻」。 + function byId(id as Number, loc as Position.Location?) as Time.Moment? { + if (id == 710) { return event(ALT_CIVIL, loc, true); } + if (id == 709) { return event(ALT_NAUTICAL, loc, true); } + if (id == 708) { return event(ALT_ASTRO, loc, true); } + if (id == 711) { return event(ALT_CIVIL, loc, true); } // blue hour opens + if (id == 714) { return event(ALT_BLUE, loc, true); } // golden hour opens + if (id == 715) { return event(ALT_GOLDEN, loc, false); } // golden hour opens + if (id == 718) { return event(ALT_BLUE, loc, false); } // blue hour opens + if (id == 719) { return event(ALT_CIVIL, loc, false); } + if (id == 720) { return event(ALT_NAUTICAL, loc, false); } + if (id == 721) { return event(ALT_ASTRO, loc, false); } + return null; + } + + function owns(id as Number) as Boolean { + return id == 708 || id == 709 || id == 710 || id == 711 || id == 714 + || id == 715 || id == 718 || id == 719 || id == 720 || id == 721; + } +} diff --git a/source/Themes.mc b/source/Themes.mc index 7787e31..aa25dba 100644 --- a/source/Themes.mc +++ b/source/Themes.mc @@ -1,8 +1,17 @@ -// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg. -// Do not edit by hand -- change the design kit and re-run the generator. +// ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。 +// 要改配色就改设计稿里的 SVG,然后重新跑一遍生成器。 // -// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock -// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom +// 七套主题的全部颜色数据。各数组的下标就是主题序号(0=Ember … 6=Kelp)。 +// +// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。 +// 下标 0 是最靠近 12 点 / 6 点的那根。 +// 这是从设计稿里**逐根取样**的实测值,不是插值近似。 +// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。 +// TICKOFF 未点亮刻度的颜色 +// ANCHORDOT 四个锚点圆点 +// BANDFILL/BANDTEXT 日期带的底色 / 文字色 +// ACCENT 图标与底部标签 +// TEXTPRIMARY 各项数值 import Toybox.Lang; module Themes { diff --git a/tools/gen_fields.py b/tools/gen_fields.py index 81f82e8..8d1d0d9 100644 --- a/tools/gen_fields.py +++ b/tools/gen_fields.py @@ -1,20 +1,24 @@ #!/usr/bin/env python3 -"""One table -> the field id list, both languages, and the settings XML. +"""一张表 → 字段 id 表、两种语言的文案、以及设置项 XML。 -Field ids follow the numbering at https://watchface.io/docs/datafields so the -two catalogues line up, but only the fields Connect IQ can actually serve from -the watch are here. Anything needing a web service (OpenWeatherMap, -StormGlass), a companion phone integration (the week-to-date and 7/28-day -per-sport aggregates), or another installed app (CGM, hydration, quotes) is -deliberately absent -- see README section 8 for the full reasoning. +字段编号沿用 https://watchface.io/docs/datafields,方便两边对照;但这里只收录 +Connect IQ 在手表本机能算出来的字段。 + +需要联网服务的(OpenWeatherMap 的部分、StormGlass)、需要手机侧与 Garmin +Connect 集成的(按运动分类的周/月累计)、需要用户装了别的 App 的(CGM、 +补水、行情),都不在这里 —— 具体原因见 README 第 7 节。 python3 tools/gen_fields.py -Writes: - source/FieldTable.mc ids + short label lookup - resources/settings/settings.xml the four field pickers - resources/strings/fields.xml English names and labels - resources-chn/strings/fields.xml Chinese names and labels +产出: + source/FieldTable.mc 字段 id 表 + 下标反查 + resources/settings/settings.xml 四个数据位的选择器 + resources/strings/fields.xml 英文的字段名与表盘标签 + resources-chn/strings/fields.xml 中文的字段名与表盘标签 + +加字段的流程:在下面的 FIELDS 表里加一行 → 跑一遍本脚本 → +到 source/Fields.mc 对应分类的函数里补一个分支。设置项、两种语言的文案、 +表盘上的标签都会自动跟上,不会漏。 """ import os @@ -40,6 +44,8 @@ FIELDS = [ (868, "MonthNum", "Month (6)", "月份数字", "MON", "月"), (858, "Meridiem", "AM/PM", "上午/下午", "", ""), (859, "WeekIso", "Week Number (ISO)", "周数 (ISO)", "WEEK", "周"), + (850, "Seconds", "Seconds", "秒", "SEC", "秒"), + (869, "Seconds0", "Seconds (09)", "秒 (前导零)", "SEC", "秒"), (873, "WeekCal", "Week Number", "周数", "WEEK", "周"), # ---- activity ------------------------------------------------------- @@ -58,6 +64,15 @@ FIELDS = [ (6, "MetersUp", "Distance Climbed", "爬升高度", "ASC", "爬升"), (9, "PushDist", "Push Distance", "推行距离", "PUSH", "推行"), (10, "Pushes", "Pushes", "推行次数", "PUSH", "推行"), + (219, "StepsWeek", "Steps (Week to Date)", "本周步数", "WEEK", "周步数"), + (220, "Steps7d", "Steps (Last 7 Days)", "近 7 天步数", "7D", "7天"), + (211, "CalWeek", "Calories (Week to Date)", "本周卡路里", "WEEK", "周千卡"), + (212, "Cal7d", "Calories (Last 7 Days)", "近 7 天卡路里", "7D", "7天"), + (213, "DistWeek", "Distance (Week to Date)", "本周距离", "WEEK", "周距离"), + (214, "Dist7d", "Distance (Last 7 Days)", "近 7 天距离", "7D", "7天"), + (203, "FloorsWeek", "Floors (Week to Date)", "本周楼层", "WEEK", "周楼层"), + (204, "Floors7d", "Floors (Last 7 Days)", "近 7 天楼层", "7D", "7天"), + (200, "ActiveMin7d", "Active Minutes (7 Days)", "近 7 天活动分钟", "7D", "7天"), # ---- body ----------------------------------------------------------- (618, "HeartRate", "Heart Rate", "心率", "HR", "心率"), @@ -94,6 +109,16 @@ FIELDS = [ (731, "UntilSun", "Time Until Sun Event", "距太阳事件", "SUN", "太阳"), (706, "MoonAge", "Moon Age", "月龄", "MOON", "月龄"), (732, "MoonLit", "Moon Illumination", "月球照明", "MOON", "月相"), + (710, "CivilAm", "Civil Twilight Start", "民用晨光始", "CIVIL", "民用晨"), + (709, "NauticalAm", "Nautical Twilight Start", "航海晨光始", "NAUT", "航海晨"), + (708, "AstroAm", "Astronomical Twilight Start", "天文晨光始", "ASTR", "天文晨"), + (711, "BlueAm", "Blue Hour (AM)", "蓝调时刻 (晨)", "BLUE", "蓝调晨"), + (714, "GoldenAm", "Golden Hour (AM)", "黄金时刻 (晨)", "GOLD", "黄金晨"), + (715, "GoldenPm", "Golden Hour (PM)", "黄金时刻 (昏)", "GOLD", "黄金昏"), + (718, "BluePm", "Blue Hour (PM)", "蓝调时刻 (昏)", "BLUE", "蓝调昏"), + (719, "CivilPm", "Civil Twilight End", "民用昏影终", "CIVIL", "民用昏"), + (720, "NauticalPm", "Nautical Twilight End", "航海昏影终", "NAUT", "航海昏"), + (721, "AstroPm", "Astronomical Twilight End", "天文昏影终", "ASTR", "天文昏"), # ---- weather -------------------------------------------------------- (306, "Temp", "Temperature", "气温", "TEMP", "气温"), @@ -107,6 +132,38 @@ FIELDS = [ (307, "WindBearing", "Wind Bearing", "风向角", "WIND", "风向"), (310, "WindDir", "Wind Direction", "风向", "WIND", "风向"), (309, "WeatherTime", "Weather Update Time", "天气更新时间", "WX", "更新"), + (312, "PressureSea", "Pressure (Sea Level)", "海平面气压", "hPa", "气压"), + (313, "Uvi", "UV Index", "紫外线指数", "UVI", "紫外线"), + (314, "Visibility", "Visibility", "能见度", "VIS", "能见度"), + (315, "DewPoint", "Dew Point", "露点", "DEW", "露点"), + + # ---- OpenWeatherMap ------------------------------------------------- + # Fetched by the background service; see source/Owm.mc. + (500, "OwmTemp", "OWM Temperature", "OWM 气温", "TEMP", "气温"), + (515, "OwmMinMax", "OWM Max/Min Temp", "OWM 最高/最低", "HI/LO", "高低温"), + (503, "OwmFeels", "OWM Feels Like", "OWM 体感温度", "FEEL", "体感"), + (504, "OwmMax", "OWM Temp Max", "OWM 最高温", "HIGH", "最高"), + (505, "OwmMin", "OWM Temp Min", "OWM 最低温", "LOW", "最低"), + (507, "OwmDescShort", "OWM Description", "OWM 天气", "WX", "天气"), + (502, "OwmDescLong", "OWM Description (Long)", "OWM 天气详述", "WX", "天气"), + (501, "OwmHumidity", "OWM Humidity", "OWM 湿度", "HUM", "湿度"), + (506, "OwmPressure", "OWM Pressure", "OWM 气压", "hPa", "气压"), + (509, "OwmVisibility", "OWM Visibility", "OWM 能见度", "VIS", "能见度"), + (510, "OwmWind", "OWM Wind Speed", "OWM 风速", "WIND", "风速"), + (511, "OwmWindDeg", "OWM Wind Degrees", "OWM 风向角", "WIND", "风向"), + (518, "OwmWindDir", "OWM Wind Direction", "OWM 风向", "WIND", "风向"), + (512, "OwmGust", "OWM Wind Gust", "OWM 阵风", "GUST", "阵风"), + (513, "OwmRain", "OWM Rain 1h", "OWM 1h 降雨", "RAIN", "降雨"), + (532, "OwmSnow", "OWM Snow 1h", "OWM 1h 降雪", "SNOW", "降雪"), + (536, "OwmClouds", "OWM Cloud Cover", "OWM 云量", "CLD", "云量"), + (537, "OwmPrecip", "OWM Precipitation %", "OWM 降水概率", "RAIN", "降水"), + (530, "OwmDew", "OWM Dew Point", "OWM 露点", "DEW", "露点"), + (534, "OwmTomorrow", "OWM Temp Tomorrow", "OWM 明日气温", "TMRW", "明日"), + (522, "OwmMinMaxTmrw", "OWM Min/Max Tomorrow", "OWM 明日高低温", "TMRW", "明日"), + (535, "OwmDayAfter", "OWM Temp Day After", "OWM 后日气温", "D+2", "后日"), + (523, "OwmMinMaxD2", "OWM Min/Max Day After", "OWM 后日高低温", "D+2", "后日"), + (516, "OwmCity", "OWM City Name", "OWM 城市", "CITY", "城市"), + (517, "OwmUpdated", "OWM Update Time", "OWM 更新时间", "OWM", "更新"), # ---- custom --------------------------------------------------------- (703, "Custom1", "Custom Text 1", "自定义文字 1", "", ""), @@ -141,6 +198,10 @@ OTHER_SETTINGS = """ + + + + @@ -184,11 +245,15 @@ def main(): assert len(ids) == len(set(ids)), "duplicate field id" # ---- FieldTable.mc ---- - L = ["// AUTO-GENERATED by tools/gen_fields.py -- do not edit by hand.", - "// Ids follow https://watchface.io/docs/datafields; only the fields a", - "// Connect IQ watch face can source on-device are listed.", + L = ["// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。", + "// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,", + "// 再到 source/Fields.mc 对应分类的函数里补一个分支。", + "//", + "// 字段编号沿用 https://watchface.io/docs/datafields,只收录 Connect IQ", + "// 表盘在本机能算出来的那些。", "import Toybox.Lang;", "", "module FieldTable {", "", - " // Field ids, in the same order as the Labels resource string.", + " // 全部字段 id,顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——", + " // Fields.label() 就是靠这个下标去取对应标签的。", " const IDS = ["] row = " " for i, fid in enumerate(ids): @@ -198,6 +263,7 @@ def main(): row = " " row += piece L += [row, " ];", "", + " // 由字段 id 反查它在 IDS 里的下标;找不到返回 0(对应「关闭」)。", " function indexOf(id as Number) as Number {", " for (var i = 0; i < IDS.size(); i++) {", " if (IDS[i] == id) { return i; }", diff --git a/tools/gen_icons.py b/tools/gen_icons.py index 69e5ce5..97b8fd0 100644 --- a/tools/gen_icons.py +++ b/tools/gen_icons.py @@ -1,21 +1,24 @@ #!/usr/bin/env python3 -"""Rasterise the design's icon paths into tintable bitmap resources. +"""把设计稿的图标路径光栅化成可染色的位图资源。 -The sunrise and weather icons in design/*.svg are traced outlines whose finest -features are two or three design pixels across. Redrawing them from circles -and lines works at 454 px and falls apart at 280 px, where those features land -under one device pixel. Rendering the real paths once per screen size and -blitting them with drawBitmap2(:tintColor) keeps them exact everywhere. +design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 2–3 个设计 +像素宽。用圆和线段去重画它们在 454px 上还行,到 280px 就彻底糊掉 —— 那个尺寸 +下这些细节不足一个物理像素。 + +所以改成:把真实路径按每种屏宽各渲染一次,运行时用 +drawBitmap2(:tintColor) 染上主题色。这样每台设备拿到的都是为它的像素网格 +专门渲染的图。 python3 tools/gen_icons.py -Writes resources-icons-/drawables/{sunrise,sunset,weather}.png plus the -matching drawables.xml. White pixels, alpha coverage; the theme's accent -colour is applied at draw time via :tintColor. +产出 resources-icons-<屏宽>/drawables/{sunrise,sunset,weather}.png 以及配套的 +drawables.xml;另外产出 resources-launcher-<尺寸>/ 下各机型要求尺寸的启动图标。 -MIP devices have no alpha blending, so for those widths the coverage mask is -thresholded to hard edges rather than left antialiased -- a soft edge there -would be quantised to something muddier than a clean one. +⚠️ 两个要点: + 1. drawables.xml 里必须写 packingFormat="png"。否则资源编译器会把图片压成 + 调色板格式,而 :tintColor 拒绝调色板化的源,运行时直接抛异常。 + 2. MIP 屏没有 alpha 混合,所以那几个尺寸的覆盖率蒙版会被阈值化成硬边 —— + 软边在 64 色调色板下反而更脏。 """ import os import re diff --git a/tools/gen_preview_svg.py b/tools/gen_preview_svg.py index ed0572c..6218988 100644 --- a/tools/gen_preview_svg.py +++ b/tools/gen_preview_svg.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Render preview-themes.svg and preview.html by replaying the face's draw calls. +"""重放表盘自己的绘制逻辑,产出 preview-themes.svg 和 preview.html。 -Colours are parsed straight out of source/Themes.mc and the geometry constants -are the same literals as source/Fenix8V3View.mc, so the preview cannot drift -from the firmware. Text is drawn with a web font rather than Garmin's Roboto -Condensed / Bionic, so judge geometry and colour here, not glyph shapes. +颜色直接解析自 source/Themes.mc,几何常量与 source/Fenix8V3View.mc / +source/Layout.mc 保持同一套字面值,所以预览不会和固件漂移。 + +⚠️ 文字用的是网页字体,真机是 Garmin 内置的 Roboto / Bionic,字形会有差异。 +**几何和配色可信,字形不可信** —— 判断字形要看模拟器截图。 python3 tools/gen_preview_svg.py """ diff --git a/tools/gen_themes.py b/tools/gen_themes.py index cfa6898..5facebc 100644 --- a/tools/gen_themes.py +++ b/tools/gen_themes.py @@ -1,28 +1,27 @@ #!/usr/bin/env python3 -"""Generate source/Themes.mc from the design kit in design/. +"""从 design/ 的设计稿生成 source/Themes.mc。 -Ground truth is the seven watchface-NN-*.svg files (produced by design/rebuild.py -from the original raster comps). Every colour written to Themes.mc is measured -from those files -- nothing here is hand-picked. +事实来源是七个 watchface-NN-*.svg(原作者用 design/rebuild.py 从位图逆向出来的)。 +写进 Themes.mc 的每一个颜色都是从这些文件里**量出来的**,没有一个是手挑的。 python3 tools/gen_themes.py -Extracted per theme: +每套主题提取出: - ringTop[21] lit colour of each top tick, index 0 = 6.5 deg .. 20 = 86.5 deg - ringBottom[21] lit colour of each bottom tick, index 0 = 6.0 deg .. 20 = 66.0 deg - tickOff colour of an unlit tick - anchorDot the four dots between the arcs - bandFill/Text date band background / foreground - colon the two colon blocks - hours[11] vertical gradient ramp of the hour digits, top -> bottom - minutes[11] ditto for the minute digits - accent icons and DST/STEP labels - textPrimary complication and bottom-row values + ringTop[21] 顶弧每根刻度点亮时的颜色,下标 0 = 6.5°,20 = 86.5° + ringBottom[21] 底弧每根刻度点亮时的颜色,下标 0 = 6.0°,20 = 66.0° + tickOff 未点亮刻度的颜色 + anchorDot 四段弧之间的锚点圆点 + bandFill/Text 日期带的底色 / 文字色 + colon 两块冒号 + hours[11] 小时数字的竖向渐变,从上到下 + minutes[11] 分钟数字的竖向渐变 + accent 图标与 DST/STEP 标签 + textPrimary 各数据位与底部行的数值 -Only 14 of the 21 bottom ticks are lit in any comp, so indices 14..20 of -ringBottom are extrapolated (least-squares fit over the last 8 measured ticks). -Those ticks only appear once a metric passes ~67%. +⚠️ 已知近似:设计稿里底弧最多只点亮 14/21 根,所以 ringBottom 的第 15–21 项 +无法实测,由最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会 +显示这几根。 """ import os import re @@ -160,11 +159,20 @@ def fmt_list(colours, indent): def main(): themes = [read_theme(t) for t in ORDER] L = [] - L.append("// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg.") - L.append("// Do not edit by hand -- change the design kit and re-run the generator.") + L.append("// ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。") + L.append("// 要改配色就改设计稿里的 SVG,然后重新跑一遍生成器。") L.append("//") - L.append("// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock") - L.append("// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom") + L.append("// 七套主题的全部颜色数据。各数组的下标就是主题序号(0=Ember … 6=Kelp)。") + L.append("//") + L.append("// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。") + L.append("// 下标 0 是最靠近 12 点 / 6 点的那根。") + L.append("// 这是从设计稿里**逐根取样**的实测值,不是插值近似。") + L.append("// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。") + L.append("// TICKOFF 未点亮刻度的颜色") + L.append("// ANCHORDOT 四个锚点圆点") + L.append("// BANDFILL/BANDTEXT 日期带的底色 / 文字色") + L.append("// ACCENT 图标与底部标签") + L.append("// TEXTPRIMARY 各项数值") L.append("import Toybox.Lang;") L.append("") L.append("module Themes {")