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 {")