Update watch face: modular refactor + OWM/Solar/background + codegen tools

- Split render into FieldTable/Fields/Icons/Layout/Settings modules
- Add Fenix8V3Background, Owm (weather), Solar modules
- Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg)
- Tune README/manifest/strings/settings
This commit is contained in:
2026-09-10 07:10:55 +08:00
parent 52346bdd09
commit 4d96ab0912
23 changed files with 1549 additions and 344 deletions

View File

@@ -31,7 +31,10 @@ fenix8v3-watchface/
│ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式 │ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式
│ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制 │ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制
│ ├── Icons.mc # 图标:位图三件套 + 其余图元 │ ├── Icons.mc # 图标:位图三件套 + 其余图元
│ ├── Fields.mc # 75 项数据字段的取值与格式化 │ ├── Fields.mc # 125 项数据字段的取值与格式化
│ ├── Solar.mc # 日出/晨昏蒙影/黄金蓝调时刻的太阳高度角计算
│ ├── Owm.mc # OpenWeatherMap 缓存读取
│ ├── Fenix8V3Background.mc # 后台服务:拉取 OWM
│ ├── Settings.mc # 所有设置项的读取与默认值 │ ├── Settings.mc # 所有设置项的读取与默认值
│ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表 │ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表
│ └── Themes.mc # ⚠️ 自动生成:七套主题配色 │ └── 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、恢复时间、最大摄氧量跑/骑) | | 身体 | 心率、静息心率、压力指数、血氧、呼吸频率、身体电量、活动条(正/反向、体重、BMI、恢复时间、最大摄氧量跑/骑) |
| 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 | | 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 |
| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明 | | 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明、民用/航海/天文晨昏蒙影(各 2 项)、黄金时刻(晨/昏)、蓝调时刻(晨/昏) |
| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间 | | 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间、海平面气压、紫外线、能见度、露点 |
| OpenWeatherMap | 气温、体感、最高/最低、天气描述(简/详、湿度、气压、能见度、风速、风向角、风向、阵风、1h 降雨、1h 降雪、云量、降水概率、露点、明日与后日气温及高低温、城市名、更新时间(共 25 项) |
| 自定义 | 自定义文字 1 / 2 / 3 | | 自定义 | 自定义文字 1 / 2 / 3 |
加字段:在 `tools/gen_fields.py``FIELDS` 表里加一行,跑一遍生成器,再到 `source/Fields.mc``value()` 里补一个分支。设置项、两种语言的文案、表盘标签会自动跟上。 加字段:在 `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.io5 项) | 需要 `Communications.makeWebRequest` + 用户自备 API key + 后台服务定时拉取与缓存。是另一个子系统,且要求用户自己去注册 key | | 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 只有 `ActivityMonitor.getHistory()` 的按天总计(步数/卡路里/距离/楼层/活动分钟),**没有**按运动分类的历史。所以通用的本周/近 7 天累计已经做了,按跑步/骑行/游泳分开的做不了——那些在 GreenBlack 那边是手机侧与 Garmin Connect 集成同步来的 |
| 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 不暴露按运动分类的历史累计。这些数据在 GreenBlack 那边是通过手机侧与 Garmin Connect 的集成同步来的,本机 API 只有 `ActivityMonitor.getHistory()` 的最近几天步数/卡路里/距离 | | StormGlass.io 潮汐5 项) | 需要额外的付费 API key且与本表盘的定位无关 |
| 第三方 App 复杂功能CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App并通过 Complications API 订阅 | | 第三方 App 复杂功能CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App并通过 Complications API 订阅 |
| 日历事件、计时器、倒计时 | 日历不对 CIQ 开放;表盘拿不到触摸输入,没法做启停 | | 日历事件、计时器、倒计时 | 日历不对 CIQ 开放;表盘拿不到触摸输入,没法做启停 |
| 秒数 | 需要 `onPartialUpdate` 每秒重绘。技术上可行,但会明显吃电,暂未做 | | 睡眠得分、训练状态、完赛/配速预测 | `ActivityMonitor.Info``UserProfile` 上没有这些字段SDK 9.1.0 实测) |
| 晨昏蒙影、黄金/蓝调时刻(约 14 项) | 可以自己算太阳高度角推出来,只是还没写;月龄和月球照明已经是这样算的 | | 高尔夫成绩 | 无对应 API |
| 睡眠得分、训练状态 | `ActivityMonitor.Info` 上没有这两个字段SDK 9.1.0 实测) |
| 标记类字段Marker约 35 项) | 那是给刻度型表盘做环形标记用的,本表盘的进度环用的是自己的指标体系 | | 标记类字段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()`。 > `Sensor` 权限**不能**用于 `type="watchface"`(除非同时申请 `Background`),编译器会直接拒绝。所以海拔与气压走 `Activity.Info` 而不是 `Sensor.getInfo()`。

View File

@@ -38,6 +38,8 @@
requested, so altitude and pressure come from Activity.Info instead. --> requested, so altitude and pressure come from Activity.Info instead. -->
<iq:permissions> <iq:permissions>
<iq:uses-permission id="UserProfile"/> <iq:uses-permission id="UserProfile"/>
<iq:uses-permission id="Communications"/>
<iq:uses-permission id="Background"/>
<iq:uses-permission id="SensorHistory"/> <iq:uses-permission id="SensorHistory"/>
</iq:permissions> </iq:permissions>

View File

@@ -15,6 +15,8 @@
<string id="FldMonthNum">月份数字</string> <string id="FldMonthNum">月份数字</string>
<string id="FldMeridiem">上午/下午</string> <string id="FldMeridiem">上午/下午</string>
<string id="FldWeekIso">周数 (ISO)</string> <string id="FldWeekIso">周数 (ISO)</string>
<string id="FldSeconds"></string>
<string id="FldSeconds0">秒 (前导零)</string>
<string id="FldWeekCal">周数</string> <string id="FldWeekCal">周数</string>
<string id="FldSteps">步数</string> <string id="FldSteps">步数</string>
<string id="FldStepsRound">步数 (取整)</string> <string id="FldStepsRound">步数 (取整)</string>
@@ -31,6 +33,15 @@
<string id="FldMetersUp">爬升高度</string> <string id="FldMetersUp">爬升高度</string>
<string id="FldPushDist">推行距离</string> <string id="FldPushDist">推行距离</string>
<string id="FldPushes">推行次数</string> <string id="FldPushes">推行次数</string>
<string id="FldStepsWeek">本周步数</string>
<string id="FldSteps7d">近 7 天步数</string>
<string id="FldCalWeek">本周卡路里</string>
<string id="FldCal7d">近 7 天卡路里</string>
<string id="FldDistWeek">本周距离</string>
<string id="FldDist7d">近 7 天距离</string>
<string id="FldFloorsWeek">本周楼层</string>
<string id="FldFloors7d">近 7 天楼层</string>
<string id="FldActiveMin7d">近 7 天活动分钟</string>
<string id="FldHeartRate">心率</string> <string id="FldHeartRate">心率</string>
<string id="FldRestingHr">静息心率</string> <string id="FldRestingHr">静息心率</string>
<string id="FldStress">压力指数</string> <string id="FldStress">压力指数</string>
@@ -61,6 +72,16 @@
<string id="FldUntilSun">距太阳事件</string> <string id="FldUntilSun">距太阳事件</string>
<string id="FldMoonAge">月龄</string> <string id="FldMoonAge">月龄</string>
<string id="FldMoonLit">月球照明</string> <string id="FldMoonLit">月球照明</string>
<string id="FldCivilAm">民用晨光始</string>
<string id="FldNauticalAm">航海晨光始</string>
<string id="FldAstroAm">天文晨光始</string>
<string id="FldBlueAm">蓝调时刻 (晨)</string>
<string id="FldGoldenAm">黄金时刻 (晨)</string>
<string id="FldGoldenPm">黄金时刻 (昏)</string>
<string id="FldBluePm">蓝调时刻 (昏)</string>
<string id="FldCivilPm">民用昏影终</string>
<string id="FldNauticalPm">航海昏影终</string>
<string id="FldAstroPm">天文昏影终</string>
<string id="FldTemp">气温</string> <string id="FldTemp">气温</string>
<string id="FldTempHiLo">最高/最低气温</string> <string id="FldTempHiLo">最高/最低气温</string>
<string id="FldFeelsLike">体感温度</string> <string id="FldFeelsLike">体感温度</string>
@@ -72,12 +93,41 @@
<string id="FldWindBearing">风向角</string> <string id="FldWindBearing">风向角</string>
<string id="FldWindDir">风向</string> <string id="FldWindDir">风向</string>
<string id="FldWeatherTime">天气更新时间</string> <string id="FldWeatherTime">天气更新时间</string>
<string id="FldPressureSea">海平面气压</string>
<string id="FldUvi">紫外线指数</string>
<string id="FldVisibility">能见度</string>
<string id="FldDewPoint">露点</string>
<string id="FldOwmTemp">OWM 气温</string>
<string id="FldOwmMinMax">OWM 最高/最低</string>
<string id="FldOwmFeels">OWM 体感温度</string>
<string id="FldOwmMax">OWM 最高温</string>
<string id="FldOwmMin">OWM 最低温</string>
<string id="FldOwmDescShort">OWM 天气</string>
<string id="FldOwmDescLong">OWM 天气详述</string>
<string id="FldOwmHumidity">OWM 湿度</string>
<string id="FldOwmPressure">OWM 气压</string>
<string id="FldOwmVisibility">OWM 能见度</string>
<string id="FldOwmWind">OWM 风速</string>
<string id="FldOwmWindDeg">OWM 风向角</string>
<string id="FldOwmWindDir">OWM 风向</string>
<string id="FldOwmGust">OWM 阵风</string>
<string id="FldOwmRain">OWM 1h 降雨</string>
<string id="FldOwmSnow">OWM 1h 降雪</string>
<string id="FldOwmClouds">OWM 云量</string>
<string id="FldOwmPrecip">OWM 降水概率</string>
<string id="FldOwmDew">OWM 露点</string>
<string id="FldOwmTomorrow">OWM 明日气温</string>
<string id="FldOwmMinMaxTmrw">OWM 明日高低温</string>
<string id="FldOwmDayAfter">OWM 后日气温</string>
<string id="FldOwmMinMaxD2">OWM 后日高低温</string>
<string id="FldOwmCity">OWM 城市</string>
<string id="FldOwmUpdated">OWM 更新时间</string>
<string id="FldCustom1">自定义文字 1</string> <string id="FldCustom1">自定义文字 1</string>
<string id="FldCustom2">自定义文字 2</string> <string id="FldCustom2">自定义文字 2</string>
<string id="FldCustom3">自定义文字 3</string> <string id="FldCustom3">自定义文字 3</string>
<!-- On-face captions, in FieldTable.IDS order. --> <!-- On-face captions, in FieldTable.IDS order. -->
<string id="FieldLabels">,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,,,</string> <string id="FieldLabels">,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,秒,秒,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,周步数,7天,周千卡,7天,周距离,7天,周楼层,7天,7天,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,民用晨,航海晨,天文晨,蓝调晨,黄金晨,黄金昏,蓝调昏,民用昏,航海昏,天文昏,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,气压,紫外线,能见度,露点,气温,高低温,体感,最高,最低,天气,天气,湿度,气压,能见度,风速,风向,风向,阵风,降雨,降雪,云量,降水,露点,明日,明日,后日,后日,城市,更新,,,</string>
<string id="MetricSteps">步数</string> <string id="MetricSteps">步数</string>
<string id="MetricCalories">卡路里</string> <string id="MetricCalories">卡路里</string>

View File

@@ -13,6 +13,7 @@
<string id="SettingTempUnit">温度单位</string> <string id="SettingTempUnit">温度单位</string>
<string id="SettingAltOffset1">第二时区 1相对本地分钟数</string> <string id="SettingAltOffset1">第二时区 1相对本地分钟数</string>
<string id="SettingAltOffset2">第二时区 2相对本地分钟数</string> <string id="SettingAltOffset2">第二时区 2相对本地分钟数</string>
<string id="SettingOwmKey">OpenWeatherMap API 密钥</string>
<string id="SettingCustom1">自定义文字 1</string> <string id="SettingCustom1">自定义文字 1</string>
<string id="SettingCustom2">自定义文字 2</string> <string id="SettingCustom2">自定义文字 2</string>
<string id="SettingCustom3">自定义文字 3</string> <string id="SettingCustom3">自定义文字 3</string>

View File

@@ -13,6 +13,7 @@
<property id="TempUnit" type="number">0</property> <property id="TempUnit" type="number">0</property>
<property id="AltOffset1" type="number">0</property> <property id="AltOffset1" type="number">0</property>
<property id="AltOffset2" type="number">0</property> <property id="AltOffset2" type="number">0</property>
<property id="OwmKey" type="string"></property>
<property id="Custom1" type="string"></property> <property id="Custom1" type="string"></property>
<property id="Custom2" type="string"></property> <property id="Custom2" type="string"></property>
<property id="Custom3" type="string"></property> <property id="Custom3" type="string"></property>

View File

@@ -29,6 +29,8 @@
<listEntry value="868">@Strings.FldMonthNum</listEntry> <listEntry value="868">@Strings.FldMonthNum</listEntry>
<listEntry value="858">@Strings.FldMeridiem</listEntry> <listEntry value="858">@Strings.FldMeridiem</listEntry>
<listEntry value="859">@Strings.FldWeekIso</listEntry> <listEntry value="859">@Strings.FldWeekIso</listEntry>
<listEntry value="850">@Strings.FldSeconds</listEntry>
<listEntry value="869">@Strings.FldSeconds0</listEntry>
<listEntry value="873">@Strings.FldWeekCal</listEntry> <listEntry value="873">@Strings.FldWeekCal</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry> <listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry> <listEntry value="14">@Strings.FldStepsRound</listEntry>
@@ -45,6 +47,15 @@
<listEntry value="6">@Strings.FldMetersUp</listEntry> <listEntry value="6">@Strings.FldMetersUp</listEntry>
<listEntry value="9">@Strings.FldPushDist</listEntry> <listEntry value="9">@Strings.FldPushDist</listEntry>
<listEntry value="10">@Strings.FldPushes</listEntry> <listEntry value="10">@Strings.FldPushes</listEntry>
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
<listEntry value="220">@Strings.FldSteps7d</listEntry>
<listEntry value="211">@Strings.FldCalWeek</listEntry>
<listEntry value="212">@Strings.FldCal7d</listEntry>
<listEntry value="213">@Strings.FldDistWeek</listEntry>
<listEntry value="214">@Strings.FldDist7d</listEntry>
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
<listEntry value="204">@Strings.FldFloors7d</listEntry>
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry> <listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry> <listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry> <listEntry value="12">@Strings.FldStress</listEntry>
@@ -75,6 +86,16 @@
<listEntry value="731">@Strings.FldUntilSun</listEntry> <listEntry value="731">@Strings.FldUntilSun</listEntry>
<listEntry value="706">@Strings.FldMoonAge</listEntry> <listEntry value="706">@Strings.FldMoonAge</listEntry>
<listEntry value="732">@Strings.FldMoonLit</listEntry> <listEntry value="732">@Strings.FldMoonLit</listEntry>
<listEntry value="710">@Strings.FldCivilAm</listEntry>
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
<listEntry value="708">@Strings.FldAstroAm</listEntry>
<listEntry value="711">@Strings.FldBlueAm</listEntry>
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
<listEntry value="718">@Strings.FldBluePm</listEntry>
<listEntry value="719">@Strings.FldCivilPm</listEntry>
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
<listEntry value="721">@Strings.FldAstroPm</listEntry>
<listEntry value="306">@Strings.FldTemp</listEntry> <listEntry value="306">@Strings.FldTemp</listEntry>
<listEntry value="639">@Strings.FldTempHiLo</listEntry> <listEntry value="639">@Strings.FldTempHiLo</listEntry>
<listEntry value="300">@Strings.FldFeelsLike</listEntry> <listEntry value="300">@Strings.FldFeelsLike</listEntry>
@@ -86,6 +107,35 @@
<listEntry value="307">@Strings.FldWindBearing</listEntry> <listEntry value="307">@Strings.FldWindBearing</listEntry>
<listEntry value="310">@Strings.FldWindDir</listEntry> <listEntry value="310">@Strings.FldWindDir</listEntry>
<listEntry value="309">@Strings.FldWeatherTime</listEntry> <listEntry value="309">@Strings.FldWeatherTime</listEntry>
<listEntry value="312">@Strings.FldPressureSea</listEntry>
<listEntry value="313">@Strings.FldUvi</listEntry>
<listEntry value="314">@Strings.FldVisibility</listEntry>
<listEntry value="315">@Strings.FldDewPoint</listEntry>
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry> <listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry> <listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry> <listEntry value="705">@Strings.FldCustom3</listEntry>
@@ -109,6 +159,8 @@
<listEntry value="868">@Strings.FldMonthNum</listEntry> <listEntry value="868">@Strings.FldMonthNum</listEntry>
<listEntry value="858">@Strings.FldMeridiem</listEntry> <listEntry value="858">@Strings.FldMeridiem</listEntry>
<listEntry value="859">@Strings.FldWeekIso</listEntry> <listEntry value="859">@Strings.FldWeekIso</listEntry>
<listEntry value="850">@Strings.FldSeconds</listEntry>
<listEntry value="869">@Strings.FldSeconds0</listEntry>
<listEntry value="873">@Strings.FldWeekCal</listEntry> <listEntry value="873">@Strings.FldWeekCal</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry> <listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry> <listEntry value="14">@Strings.FldStepsRound</listEntry>
@@ -125,6 +177,15 @@
<listEntry value="6">@Strings.FldMetersUp</listEntry> <listEntry value="6">@Strings.FldMetersUp</listEntry>
<listEntry value="9">@Strings.FldPushDist</listEntry> <listEntry value="9">@Strings.FldPushDist</listEntry>
<listEntry value="10">@Strings.FldPushes</listEntry> <listEntry value="10">@Strings.FldPushes</listEntry>
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
<listEntry value="220">@Strings.FldSteps7d</listEntry>
<listEntry value="211">@Strings.FldCalWeek</listEntry>
<listEntry value="212">@Strings.FldCal7d</listEntry>
<listEntry value="213">@Strings.FldDistWeek</listEntry>
<listEntry value="214">@Strings.FldDist7d</listEntry>
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
<listEntry value="204">@Strings.FldFloors7d</listEntry>
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry> <listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry> <listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry> <listEntry value="12">@Strings.FldStress</listEntry>
@@ -155,6 +216,16 @@
<listEntry value="731">@Strings.FldUntilSun</listEntry> <listEntry value="731">@Strings.FldUntilSun</listEntry>
<listEntry value="706">@Strings.FldMoonAge</listEntry> <listEntry value="706">@Strings.FldMoonAge</listEntry>
<listEntry value="732">@Strings.FldMoonLit</listEntry> <listEntry value="732">@Strings.FldMoonLit</listEntry>
<listEntry value="710">@Strings.FldCivilAm</listEntry>
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
<listEntry value="708">@Strings.FldAstroAm</listEntry>
<listEntry value="711">@Strings.FldBlueAm</listEntry>
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
<listEntry value="718">@Strings.FldBluePm</listEntry>
<listEntry value="719">@Strings.FldCivilPm</listEntry>
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
<listEntry value="721">@Strings.FldAstroPm</listEntry>
<listEntry value="306">@Strings.FldTemp</listEntry> <listEntry value="306">@Strings.FldTemp</listEntry>
<listEntry value="639">@Strings.FldTempHiLo</listEntry> <listEntry value="639">@Strings.FldTempHiLo</listEntry>
<listEntry value="300">@Strings.FldFeelsLike</listEntry> <listEntry value="300">@Strings.FldFeelsLike</listEntry>
@@ -166,6 +237,35 @@
<listEntry value="307">@Strings.FldWindBearing</listEntry> <listEntry value="307">@Strings.FldWindBearing</listEntry>
<listEntry value="310">@Strings.FldWindDir</listEntry> <listEntry value="310">@Strings.FldWindDir</listEntry>
<listEntry value="309">@Strings.FldWeatherTime</listEntry> <listEntry value="309">@Strings.FldWeatherTime</listEntry>
<listEntry value="312">@Strings.FldPressureSea</listEntry>
<listEntry value="313">@Strings.FldUvi</listEntry>
<listEntry value="314">@Strings.FldVisibility</listEntry>
<listEntry value="315">@Strings.FldDewPoint</listEntry>
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry> <listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry> <listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry> <listEntry value="705">@Strings.FldCustom3</listEntry>
@@ -189,6 +289,8 @@
<listEntry value="868">@Strings.FldMonthNum</listEntry> <listEntry value="868">@Strings.FldMonthNum</listEntry>
<listEntry value="858">@Strings.FldMeridiem</listEntry> <listEntry value="858">@Strings.FldMeridiem</listEntry>
<listEntry value="859">@Strings.FldWeekIso</listEntry> <listEntry value="859">@Strings.FldWeekIso</listEntry>
<listEntry value="850">@Strings.FldSeconds</listEntry>
<listEntry value="869">@Strings.FldSeconds0</listEntry>
<listEntry value="873">@Strings.FldWeekCal</listEntry> <listEntry value="873">@Strings.FldWeekCal</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry> <listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry> <listEntry value="14">@Strings.FldStepsRound</listEntry>
@@ -205,6 +307,15 @@
<listEntry value="6">@Strings.FldMetersUp</listEntry> <listEntry value="6">@Strings.FldMetersUp</listEntry>
<listEntry value="9">@Strings.FldPushDist</listEntry> <listEntry value="9">@Strings.FldPushDist</listEntry>
<listEntry value="10">@Strings.FldPushes</listEntry> <listEntry value="10">@Strings.FldPushes</listEntry>
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
<listEntry value="220">@Strings.FldSteps7d</listEntry>
<listEntry value="211">@Strings.FldCalWeek</listEntry>
<listEntry value="212">@Strings.FldCal7d</listEntry>
<listEntry value="213">@Strings.FldDistWeek</listEntry>
<listEntry value="214">@Strings.FldDist7d</listEntry>
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
<listEntry value="204">@Strings.FldFloors7d</listEntry>
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry> <listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry> <listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry> <listEntry value="12">@Strings.FldStress</listEntry>
@@ -235,6 +346,16 @@
<listEntry value="731">@Strings.FldUntilSun</listEntry> <listEntry value="731">@Strings.FldUntilSun</listEntry>
<listEntry value="706">@Strings.FldMoonAge</listEntry> <listEntry value="706">@Strings.FldMoonAge</listEntry>
<listEntry value="732">@Strings.FldMoonLit</listEntry> <listEntry value="732">@Strings.FldMoonLit</listEntry>
<listEntry value="710">@Strings.FldCivilAm</listEntry>
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
<listEntry value="708">@Strings.FldAstroAm</listEntry>
<listEntry value="711">@Strings.FldBlueAm</listEntry>
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
<listEntry value="718">@Strings.FldBluePm</listEntry>
<listEntry value="719">@Strings.FldCivilPm</listEntry>
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
<listEntry value="721">@Strings.FldAstroPm</listEntry>
<listEntry value="306">@Strings.FldTemp</listEntry> <listEntry value="306">@Strings.FldTemp</listEntry>
<listEntry value="639">@Strings.FldTempHiLo</listEntry> <listEntry value="639">@Strings.FldTempHiLo</listEntry>
<listEntry value="300">@Strings.FldFeelsLike</listEntry> <listEntry value="300">@Strings.FldFeelsLike</listEntry>
@@ -246,6 +367,35 @@
<listEntry value="307">@Strings.FldWindBearing</listEntry> <listEntry value="307">@Strings.FldWindBearing</listEntry>
<listEntry value="310">@Strings.FldWindDir</listEntry> <listEntry value="310">@Strings.FldWindDir</listEntry>
<listEntry value="309">@Strings.FldWeatherTime</listEntry> <listEntry value="309">@Strings.FldWeatherTime</listEntry>
<listEntry value="312">@Strings.FldPressureSea</listEntry>
<listEntry value="313">@Strings.FldUvi</listEntry>
<listEntry value="314">@Strings.FldVisibility</listEntry>
<listEntry value="315">@Strings.FldDewPoint</listEntry>
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry> <listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry> <listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry> <listEntry value="705">@Strings.FldCustom3</listEntry>
@@ -269,6 +419,8 @@
<listEntry value="868">@Strings.FldMonthNum</listEntry> <listEntry value="868">@Strings.FldMonthNum</listEntry>
<listEntry value="858">@Strings.FldMeridiem</listEntry> <listEntry value="858">@Strings.FldMeridiem</listEntry>
<listEntry value="859">@Strings.FldWeekIso</listEntry> <listEntry value="859">@Strings.FldWeekIso</listEntry>
<listEntry value="850">@Strings.FldSeconds</listEntry>
<listEntry value="869">@Strings.FldSeconds0</listEntry>
<listEntry value="873">@Strings.FldWeekCal</listEntry> <listEntry value="873">@Strings.FldWeekCal</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry> <listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry> <listEntry value="14">@Strings.FldStepsRound</listEntry>
@@ -285,6 +437,15 @@
<listEntry value="6">@Strings.FldMetersUp</listEntry> <listEntry value="6">@Strings.FldMetersUp</listEntry>
<listEntry value="9">@Strings.FldPushDist</listEntry> <listEntry value="9">@Strings.FldPushDist</listEntry>
<listEntry value="10">@Strings.FldPushes</listEntry> <listEntry value="10">@Strings.FldPushes</listEntry>
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
<listEntry value="220">@Strings.FldSteps7d</listEntry>
<listEntry value="211">@Strings.FldCalWeek</listEntry>
<listEntry value="212">@Strings.FldCal7d</listEntry>
<listEntry value="213">@Strings.FldDistWeek</listEntry>
<listEntry value="214">@Strings.FldDist7d</listEntry>
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
<listEntry value="204">@Strings.FldFloors7d</listEntry>
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry> <listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry> <listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry> <listEntry value="12">@Strings.FldStress</listEntry>
@@ -315,6 +476,16 @@
<listEntry value="731">@Strings.FldUntilSun</listEntry> <listEntry value="731">@Strings.FldUntilSun</listEntry>
<listEntry value="706">@Strings.FldMoonAge</listEntry> <listEntry value="706">@Strings.FldMoonAge</listEntry>
<listEntry value="732">@Strings.FldMoonLit</listEntry> <listEntry value="732">@Strings.FldMoonLit</listEntry>
<listEntry value="710">@Strings.FldCivilAm</listEntry>
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
<listEntry value="708">@Strings.FldAstroAm</listEntry>
<listEntry value="711">@Strings.FldBlueAm</listEntry>
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
<listEntry value="718">@Strings.FldBluePm</listEntry>
<listEntry value="719">@Strings.FldCivilPm</listEntry>
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
<listEntry value="721">@Strings.FldAstroPm</listEntry>
<listEntry value="306">@Strings.FldTemp</listEntry> <listEntry value="306">@Strings.FldTemp</listEntry>
<listEntry value="639">@Strings.FldTempHiLo</listEntry> <listEntry value="639">@Strings.FldTempHiLo</listEntry>
<listEntry value="300">@Strings.FldFeelsLike</listEntry> <listEntry value="300">@Strings.FldFeelsLike</listEntry>
@@ -326,6 +497,35 @@
<listEntry value="307">@Strings.FldWindBearing</listEntry> <listEntry value="307">@Strings.FldWindBearing</listEntry>
<listEntry value="310">@Strings.FldWindDir</listEntry> <listEntry value="310">@Strings.FldWindDir</listEntry>
<listEntry value="309">@Strings.FldWeatherTime</listEntry> <listEntry value="309">@Strings.FldWeatherTime</listEntry>
<listEntry value="312">@Strings.FldPressureSea</listEntry>
<listEntry value="313">@Strings.FldUvi</listEntry>
<listEntry value="314">@Strings.FldVisibility</listEntry>
<listEntry value="315">@Strings.FldDewPoint</listEntry>
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry> <listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry> <listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry> <listEntry value="705">@Strings.FldCustom3</listEntry>
@@ -397,6 +597,10 @@
<settingConfig type="numeric" min="-720" max="840"/> <settingConfig type="numeric" min="-720" max="840"/>
</setting> </setting>
<setting propertyKey="@Properties.OwmKey" title="@Strings.SettingOwmKey">
<settingConfig type="alphaNumeric" maxLength="40"/>
</setting>
<setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1"> <setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1">
<settingConfig type="alphaNumeric" maxLength="12"/> <settingConfig type="alphaNumeric" maxLength="12"/>
</setting> </setting>

View File

@@ -15,6 +15,8 @@
<string id="FldMonthNum">Month (6)</string> <string id="FldMonthNum">Month (6)</string>
<string id="FldMeridiem">AM/PM</string> <string id="FldMeridiem">AM/PM</string>
<string id="FldWeekIso">Week Number (ISO)</string> <string id="FldWeekIso">Week Number (ISO)</string>
<string id="FldSeconds">Seconds</string>
<string id="FldSeconds0">Seconds (09)</string>
<string id="FldWeekCal">Week Number</string> <string id="FldWeekCal">Week Number</string>
<string id="FldSteps">Steps</string> <string id="FldSteps">Steps</string>
<string id="FldStepsRound">Steps Rounded</string> <string id="FldStepsRound">Steps Rounded</string>
@@ -31,6 +33,15 @@
<string id="FldMetersUp">Distance Climbed</string> <string id="FldMetersUp">Distance Climbed</string>
<string id="FldPushDist">Push Distance</string> <string id="FldPushDist">Push Distance</string>
<string id="FldPushes">Pushes</string> <string id="FldPushes">Pushes</string>
<string id="FldStepsWeek">Steps (Week to Date)</string>
<string id="FldSteps7d">Steps (Last 7 Days)</string>
<string id="FldCalWeek">Calories (Week to Date)</string>
<string id="FldCal7d">Calories (Last 7 Days)</string>
<string id="FldDistWeek">Distance (Week to Date)</string>
<string id="FldDist7d">Distance (Last 7 Days)</string>
<string id="FldFloorsWeek">Floors (Week to Date)</string>
<string id="FldFloors7d">Floors (Last 7 Days)</string>
<string id="FldActiveMin7d">Active Minutes (7 Days)</string>
<string id="FldHeartRate">Heart Rate</string> <string id="FldHeartRate">Heart Rate</string>
<string id="FldRestingHr">Resting Heart Rate</string> <string id="FldRestingHr">Resting Heart Rate</string>
<string id="FldStress">Stress Score</string> <string id="FldStress">Stress Score</string>
@@ -61,6 +72,16 @@
<string id="FldUntilSun">Time Until Sun Event</string> <string id="FldUntilSun">Time Until Sun Event</string>
<string id="FldMoonAge">Moon Age</string> <string id="FldMoonAge">Moon Age</string>
<string id="FldMoonLit">Moon Illumination</string> <string id="FldMoonLit">Moon Illumination</string>
<string id="FldCivilAm">Civil Twilight Start</string>
<string id="FldNauticalAm">Nautical Twilight Start</string>
<string id="FldAstroAm">Astronomical Twilight Start</string>
<string id="FldBlueAm">Blue Hour (AM)</string>
<string id="FldGoldenAm">Golden Hour (AM)</string>
<string id="FldGoldenPm">Golden Hour (PM)</string>
<string id="FldBluePm">Blue Hour (PM)</string>
<string id="FldCivilPm">Civil Twilight End</string>
<string id="FldNauticalPm">Nautical Twilight End</string>
<string id="FldAstroPm">Astronomical Twilight End</string>
<string id="FldTemp">Temperature</string> <string id="FldTemp">Temperature</string>
<string id="FldTempHiLo">Temperature High/Low</string> <string id="FldTempHiLo">Temperature High/Low</string>
<string id="FldFeelsLike">Feels Like</string> <string id="FldFeelsLike">Feels Like</string>
@@ -72,12 +93,41 @@
<string id="FldWindBearing">Wind Bearing</string> <string id="FldWindBearing">Wind Bearing</string>
<string id="FldWindDir">Wind Direction</string> <string id="FldWindDir">Wind Direction</string>
<string id="FldWeatherTime">Weather Update Time</string> <string id="FldWeatherTime">Weather Update Time</string>
<string id="FldPressureSea">Pressure (Sea Level)</string>
<string id="FldUvi">UV Index</string>
<string id="FldVisibility">Visibility</string>
<string id="FldDewPoint">Dew Point</string>
<string id="FldOwmTemp">OWM Temperature</string>
<string id="FldOwmMinMax">OWM Max/Min Temp</string>
<string id="FldOwmFeels">OWM Feels Like</string>
<string id="FldOwmMax">OWM Temp Max</string>
<string id="FldOwmMin">OWM Temp Min</string>
<string id="FldOwmDescShort">OWM Description</string>
<string id="FldOwmDescLong">OWM Description (Long)</string>
<string id="FldOwmHumidity">OWM Humidity</string>
<string id="FldOwmPressure">OWM Pressure</string>
<string id="FldOwmVisibility">OWM Visibility</string>
<string id="FldOwmWind">OWM Wind Speed</string>
<string id="FldOwmWindDeg">OWM Wind Degrees</string>
<string id="FldOwmWindDir">OWM Wind Direction</string>
<string id="FldOwmGust">OWM Wind Gust</string>
<string id="FldOwmRain">OWM Rain 1h</string>
<string id="FldOwmSnow">OWM Snow 1h</string>
<string id="FldOwmClouds">OWM Cloud Cover</string>
<string id="FldOwmPrecip">OWM Precipitation %</string>
<string id="FldOwmDew">OWM Dew Point</string>
<string id="FldOwmTomorrow">OWM Temp Tomorrow</string>
<string id="FldOwmMinMaxTmrw">OWM Min/Max Tomorrow</string>
<string id="FldOwmDayAfter">OWM Temp Day After</string>
<string id="FldOwmMinMaxD2">OWM Min/Max Day After</string>
<string id="FldOwmCity">OWM City Name</string>
<string id="FldOwmUpdated">OWM Update Time</string>
<string id="FldCustom1">Custom Text 1</string> <string id="FldCustom1">Custom Text 1</string>
<string id="FldCustom2">Custom Text 2</string> <string id="FldCustom2">Custom Text 2</string>
<string id="FldCustom3">Custom Text 3</string> <string id="FldCustom3">Custom Text 3</string>
<!-- On-face captions, in FieldTable.IDS order. --> <!-- On-face captions, in FieldTable.IDS order. -->
<string id="FieldLabels">,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,,,</string> <string id="FieldLabels">,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,,,</string>
<string id="MetricSteps">Steps</string> <string id="MetricSteps">Steps</string>
<string id="MetricCalories">Calories</string> <string id="MetricCalories">Calories</string>

View File

@@ -13,6 +13,7 @@
<string id="SettingTempUnit">Temperature Unit</string> <string id="SettingTempUnit">Temperature Unit</string>
<string id="SettingAltOffset1">Alternate Time 1 (minutes from local)</string> <string id="SettingAltOffset1">Alternate Time 1 (minutes from local)</string>
<string id="SettingAltOffset2">Alternate Time 2 (minutes from local)</string> <string id="SettingAltOffset2">Alternate Time 2 (minutes from local)</string>
<string id="SettingOwmKey">OpenWeatherMap API Key</string>
<string id="SettingCustom1">Custom Text 1</string> <string id="SettingCustom1">Custom Text 1</string>
<string id="SettingCustom2">Custom Text 2</string> <string id="SettingCustom2">Custom Text 2</string>
<string id="SettingCustom3">Custom Text 3</string> <string id="SettingCustom3">Custom Text 3</string>

View File

@@ -1,12 +1,25 @@
import Toybox.Application; import Toybox.Application;
import Toybox.Background;
import Toybox.Lang; import Toybox.Lang;
import Toybox.System;
import Toybox.Time;
import Toybox.WatchUi; 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 { class Fenix8V3App extends Application.AppBase {
// OWM 免费档的调用额度很宽裕,但表盘每分钟才重绘一次、数据本身也是小时级的,
// 拉太勤只是白白唤醒射频费电。半小时是个合适的折中。
const OWM_PERIOD_MIN = 30;
function initialize() { function initialize() {
AppBase.initialize(); AppBase.initialize();
} }
@@ -17,15 +30,55 @@ class Fenix8V3App extends Application.AppBase {
function onStop(state as Dictionary?) as Void { function onStop(state as Dictionary?) as Void {
} }
// ⚠️ 定时器的注册必须放在 getInitialView 里,不能放 onStart。
// 因为 onStart **在后台服务进程里也会被调用一次**,而在后台进程里调
// registerForTemporalEvent 会抛 Out of Bounds。
// getInitialView 只在前台进程运行,是安全的位置。
function getInitialView() as [Views] or [Views, InputDelegates] { function getInitialView() as [Views] or [Views, InputDelegates] {
scheduleWeather();
return [new Fenix8V3View()]; return [new Fenix8V3View()];
} }
// Fires when the phone pushes new settings down. The view reads every function getServiceDelegate() as [System.ServiceDelegate] {
// preference fresh on each draw, so a repaint is all that is needed. 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 { function onSettingsChanged() as Void {
scheduleWeather();
WatchUi.requestUpdate(); 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 { function getApp() as Fenix8V3App {

View File

@@ -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);
}
}
}

View File

@@ -13,16 +13,24 @@ import Layout;
import Icons; import Icons;
import Settings; 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 // 位置尺寸 → Layout.mc
// left here is the drawing order. // 数据取值 → Fields.mc
// 配色 → Themes.mc自动生成
// 设置读取 → Settings.mc
// 图标 → Icons.mc
// 留在本文件里的只有「按什么顺序画」。
// ============================================================================
class Fenix8V3View extends WatchUi.WatchFace { 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 mSleeping = false;
private var mBurnIn = false; private var mBurnIn = false;
@@ -30,18 +38,20 @@ class Fenix8V3View extends WatchUi.WatchFace {
WatchFace.initialize(); WatchFace.initialize();
} }
// 进入低功耗息屏。AMOLED 机型从这一刻起改画简化版面。
function onEnterSleep() as Void { function onEnterSleep() as Void {
mSleeping = true; mSleeping = true;
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
// 抬腕唤醒,恢复完整版面。
function onExitSleep() as Void { function onExitSleep() as Void {
mSleeping = false; mSleeping = false;
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
// Called once when the face is loaded, and again by onUpdate if anything // 表盘加载时调一次;如果 onUpdate 发现缓存失效(分辨率变了,或资源还没
// it caches has gone stale (a resolution change, or resources not yet in). // 加载好)会再调一次。所有「一次性、开销大」的准备工作都放这里。
function onLayout(dc as Dc) as Void { function onLayout(dc as Dc) as Void {
Layout.init(dc); Layout.init(dc);
Fields.loadStrings(); Fields.loadStrings();
@@ -51,27 +61,28 @@ class Fenix8V3View extends WatchUi.WatchFace {
&& ds.requiresBurnInProtection; && ds.requiresBurnInProtection;
} }
// ------------------------------------------------------------- ring // ------------------------------- 进度环 -------------------------------
// Progress for one of the four arcs, 0..1. These ids are the ring's own // 计算四段弧各自的进度0..1。
// short list, not the data field catalogue. // ⚠️ 这里的 idx 是**进度环自己的一张短表**(见 Settings.ringTR 的注释),
// 和数据位那套 125 项的字段编号完全是两回事,别混。
function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float { function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float {
if (info == null) { return 0.0f; } if (info == null) { return 0.0f; }
var p = 0.0f; var p = 0.0f;
if (idx == 0) { // steps if (idx == 0) { // 步数:对每日目标
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) { if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
p = info.steps.toFloat() / info.stepGoal.toFloat(); 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; } 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; } if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
} else if (idx == 3) { // floors } else if (idx == 3) { // 楼层:对每日目标,取不到目标就按 10 层
if (info.floorsClimbed != null) { if (info.floorsClimbed != null) {
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0) var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
? info.floorsClimbedGoal.toFloat() : 10.0f; ? info.floorsClimbedGoal.toFloat() : 10.0f;
p = info.floorsClimbed.toFloat() / goal; 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 if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null
&& (info has :activeMinutesWeekGoal) && (info has :activeMinutesWeekGoal)
&& info.activeMinutesWeekGoal != null && info.activeMinutesWeekGoal != null
@@ -79,10 +90,13 @@ class Fenix8V3View extends WatchUi.WatchFace {
p = info.activeMinutesWeek.total.toFloat() p = info.activeMinutesWeek.total.toFloat()
/ info.activeMinutesWeekGoal.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})); var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1}));
if (bb != null) { p = bb.toFloat() / 100.0; } 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) { if (info.moveBarLevel != null) {
var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX
- ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat(); - ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat();
@@ -96,8 +110,9 @@ class Fenix8V3View extends WatchUi.WatchFace {
return p; 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 { function tick(dc as Dc, ang as Float, hw as Float) as Void {
var a0 = ang - hw; var a0 = ang - hw;
var a1 = ang + hw; var a1 = ang + hw;
@@ -109,8 +124,10 @@ class Fenix8V3View extends WatchUi.WatchFace {
[Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]); [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<Number>, off as Number, lit as Number, function arc(dc as Dc, ramp as Array<Number>, off as Number, lit as Number,
phase as Float, pitch as Float, hw as Float, n 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 { 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, function drawRing(dc as Dc, ti as Number, pTR as Float, pTL as Float,
pBR as Float, pBL as Float) as Void { pBR as Float, pBL as Float) as Void {
// 四段弧共用两份配色表:两条顶弧用 RINGTOP两条底弧用 RINGBOTTOM。
// 左右弧靠 mirror 参数镜像,颜色按刻度下标取,所以左右对称位置同色。
var top = Themes.RINGTOP[ti] as Array<Number>; var top = Themes.RINGTOP[ti] as Array<Number>;
var bot = Themes.RINGBOTTOM[ti] as Array<Number>; var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var off = Themes.TICKOFF[ti] as Number; 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, 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); Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, true, false);
// 四个锚点圆点,压在四段弧的接缝处。
dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT); dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT);
var r = Layout.px(Layout.DOT_R); var r = Layout.px(Layout.DOT_R);
var rad = Layout.DOT_RAD * Layout.scale; 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 { function drawBattery(dc as Dc, ti as Number, level as Float) as Void {
Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0, Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0,
Themes.ACCENT[ti] as Number, level); Themes.ACCENT[ti] as Number, level);
@@ -155,6 +176,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number); Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
} }
// 顶部单个数据位:上方图标 + 下方数值。
// 数值走 fittedText太长会自动降字号不会冲出表盘。
function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean, function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean,
ctx as Fields.Ctx, level as Float) as Void { ctx as Fields.Ctx, level as Float) as Void {
var x = isLeft ? Layout.LEFT_X : Layout.RIGHT_X; 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); Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W);
} }
// 日期带:整条横幅底色 + 蓝牙符号 + 星期 + 月日 + 上午/下午。
// 24 小时制下不显示上午/下午(跟随手表设置)。
function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void { function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var fill = Themes.BANDFILL[ti] as Number; var fill = Themes.BANDFILL[ti] as Number;
dc.setColor(fill, fill); dc.setColor(fill, fill);
dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width, dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width,
Layout.px(Layout.BAND_H)); Layout.px(Layout.BAND_H));
var col = Themes.BANDTEXT[ti] as Number; 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) Icons.bluetooth(dc, (ds != null && !ds.phoneConnected)
? blend(col, fill, 0.6) : col); ? blend(col, fill, 0.6) : col);
// FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns // ⚠️ 必须用 FORMAT_SHORT,不能用 FORMAT_MEDIUM
// day_of_week and month as numbers rather than localised strings. // 只有短格式的 day_of_week / month 返回的是**数字**;中格式返回的是
// 本地化字符串,拿去做数组下标会直接抛类型异常 —— 初版就是这么崩的,
// 而且是每次更新都崩。
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand, Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand,
Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col); 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 { function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var h = g.hour; var h = g.hour;
@@ -206,6 +235,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
} }
// 底部两格:各自「数值 + 小标签」。
// 标签跟随所选字段自动变化,不需要单独配置。
function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void { function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void {
var value = Themes.TEXTPRIMARY[ti] as Number; var value = Themes.TEXTPRIMARY[ti] as Number;
var label = Themes.ACCENT[ti] as Number; var label = Themes.ACCENT[ti] as Number;
@@ -225,7 +256,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
label, Layout.BOT_MAX_W); label, Layout.BOT_MAX_W);
} }
// ------------------------------------------------------------ colour // ------------------------------- 颜色工具 ------------------------------
// 两色按比例混合f=0 取 c1f=1 取 c2。
function blend(c1 as Number, c2 as Number, f as Float) as Number { function blend(c1 as Number, c2 as Number, f as Float) as Number {
var r1 = (c1 >> 16) & 0xFF; var r1 = (c1 >> 16) & 0xFF;
var g1 = (c1 >> 8) & 0xFF; var g1 = (c1 >> 8) & 0xFF;
@@ -236,6 +268,7 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
// 整体压暗到 f 倍(常亮模式用)。
function dim(col as Number, f as Float) as Number { function dim(col as Number, f as Float) as Number {
var r = (((col >> 16) & 0xFF) * f).toNumber(); var r = (((col >> 16) & 0xFF) * f).toNumber();
var g = (((col >> 8) & 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; 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?, 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 { pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); 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 oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
var top = Themes.RINGTOP[ti] as Array<Number>; var top = Themes.RINGTOP[ti] as Array<Number>;
@@ -307,7 +344,79 @@ class Fenix8V3View extends WatchUi.WatchFace {
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band); 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 { function onUpdate(dc as Dc) as Void {
if (!Layout.ready() || Layout.width != dc.getWidth() if (!Layout.ready() || Layout.width != dc.getWidth()
|| !Fields.ready() || !Icons.ready()) { || !Fields.ready() || !Icons.ready()) {
@@ -315,6 +424,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
} }
if (dc has :setAntiAlias) { dc.setAntiAlias(true); } if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
// 每次绘制只构造一个 Ctx四个数据位共用 —— 天气、用户档案这些
// 开销大的查询因此每帧最多做一次,且只在真有格子要用时才做。
var ti = Settings.themeIndex(Themes.NAMES.size()); var ti = Settings.themeIndex(Themes.NAMES.size());
var ds = System.getDeviceSettings(); var ds = System.getDeviceSettings();
var ctx = new Fields.Ctx(ds, Settings.tempUnit()); var ctx = new Fields.Ctx(ds, Settings.tempUnit());
@@ -333,6 +444,7 @@ class Fenix8V3View extends WatchUi.WatchFace {
var pBR = ringProgress(Settings.ringBR(), info); var pBR = ringProgress(Settings.ringBR(), info);
var pBL = ringProgress(Settings.ringBL(), info); var pBL = ringProgress(Settings.ringBL(), info);
// 常亮模式走另一条绘制路径,画完直接返回。
if (mSleeping && mBurnIn) { if (mSleeping && mBurnIn) {
drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL); drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL);
return; return;

View File

@@ -1,18 +1,25 @@
// AUTO-GENERATED by tools/gen_fields.py -- do not edit by hand. // ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。
// Ids follow https://watchface.io/docs/datafields; only the fields a // 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,
// Connect IQ watch face can source on-device are listed. // 再到 source/Fields.mc 对应分类的函数里补一个分支。
//
// 字段编号沿用 https://watchface.io/docs/datafields只收录 Connect IQ
// 表盘在本机能算出来的那些。
import Toybox.Lang; import Toybox.Lang;
module FieldTable { module FieldTable {
// Field ids, in the same order as the Labels resource string. // 全部字段 id顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——
// Fields.label() 就是靠这个下标去取对应标签的。
const IDS = [ const IDS = [
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,873,1,14,707,2,603,150, 0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,1,14,707,2,
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, 603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,618,276,12,635,
253,255,254,256,252,615,616,733,712,717,700,731,706,732,306,639,300,301,302,305,304, 11,623,8,15,281,742,13,278,277,250,251,257,253,255,254,256,252,615,616,733,712,717,
308,307,310,309,703,704,705 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 { function indexOf(id as Number) as Number {
for (var i = 0; i < IDS.size(); i++) { for (var i = 0; i < IDS.size(); i++) {
if (IDS[i] == id) { return i; } if (IDS[i] == id) { return i; }

View File

@@ -13,16 +13,27 @@ import Toybox.Position;
import Toybox.WatchUi; import Toybox.WatchUi;
import FieldTable; import FieldTable;
import Settings; 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 // 字段编号沿用 https://watchface.io/docs/datafields两边可以直接对照。
// week-to-date and 7/28-day per-sport aggregates in that catalogue come from a // 驱动设置项和标签文案的那张表在 tools/gen_fields.py —— 加字段要改那里,
// 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. // 只收录**手表本机能算出来**的字段。没做的几类及原因见 README 第 7 节,简单说:
// - 按运动分类的周/月累计CIQ 只有按天的总计,没有分运动的历史
// - StormGlass 潮汐、第三方 App 复杂功能:需要额外 key 或用户装了对应 App
// - 日历、计时器CIQ 不开放日历,表盘也拿不到触摸输入
//
// 组织方式value() 按分类依次尝试 7 个函数,每个函数不认识编号就返回 null。
// 之所以不按编号区间派发,是因为编号是**交错**的 —— 1/2/3 是活动8/11/12 是
// 身体14 又回到活动。
// ============================================================================
module Fields { module Fields {
const DASH = "--"; const DASH = "--";
@@ -43,7 +54,7 @@ module Fields {
function ready() as Boolean { return mDow != null; } function ready() as Boolean { return mDow != null; }
// Monkey C has no String.split. // Monkey C 没有 String.split,只能自己按逗号切。
function splitCsv(str as String) as Array<String> { function splitCsv(str as String) as Array<String> {
var out = [] as Array<String>; var out = [] as Array<String>;
var rest = str; var rest = str;
@@ -69,8 +80,8 @@ module Fields {
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]); return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
} }
// "10" in English, "10日" in Chinese -- the format string carries the // 英文出 "10",中文出 "10日"。
// suffix, so feed it an empty month. // 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
function dayOnly(g as Gregorian.Info) as String { function dayOnly(g as Gregorian.Info) as String {
return Lang.format(mDateFmt, ["", g.day.format("%d")]); return Lang.format(mDateFmt, ["", g.day.format("%d")]);
} }
@@ -79,16 +90,18 @@ module Fields {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0]; 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 { function label(id as Number) as String {
if (mLabels == null) { return ""; } if (mLabels == null) { return ""; }
var i = FieldTable.indexOf(id); var i = FieldTable.indexOf(id);
return i < mLabels.size() ? mLabels[i] : ""; 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 { class Ctx {
var greg as Gregorian.Info; var greg as Gregorian.Info;
var is24 as Boolean = true; var is24 as Boolean = true;
@@ -152,18 +165,40 @@ module Fields {
return mProf; return mProf;
} }
// 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空:
// - 天气在同步之前没有观测点坐标
// - currentLocation 要有东西刚定过位才有值
// 所以每次拿到位置就缓存进 Storage两个来源都空时用缓存兜底
// 免得这些字段一会儿有一会儿变 "--"。
function where() as Position.Location? { function where() as Position.Location? {
var w = wx(); var w = wx();
if (w != null && w.observationLocationPosition != null) { if (w != null && (w has :observationLocationPosition)
return w.observationLocationPosition; && w.observationLocationPosition != null) {
return remember(w.observationLocationPosition);
} }
var a = act(); 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; 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 { function num(v as Numeric?) as String {
return v == null ? DASH : v.format("%d"); return v == null ? DASH : v.format("%d");
} }
@@ -197,7 +232,7 @@ module Fields {
return t.format("%d") + "°"; return t.format("%d") + "°";
} }
// ActivityMonitor distances are centimetres. // ActivityMonitor 的距离单位是**厘米**,先换算成公里再按单位制转换。
function dist(cm as Numeric?, c as Ctx) as String { function dist(cm as Numeric?, c as Ctx) as String {
if (cm == null) { return DASH; } if (cm == null) { return DASH; }
var km = cm.toFloat() / 100000.0; var km = cm.toFloat() / 100000.0;
@@ -217,7 +252,7 @@ module Fields {
return (n / 1000.0).format("%.1f") + "k"; return (n / 1000.0).format("%.1f") + "k";
} }
// Newest reading out of a SensorHistory iterator, or null. // 从 SensorHistory 迭代器里取最新一条读数;没有则 null
function latest(iter) as Numeric? { function latest(iter) as Numeric? {
if (iter == null) { return null; } if (iter == null) { return null; }
var s = iter.next(); var s = iter.next();
@@ -225,6 +260,53 @@ module Fields {
return s.data; return s.data;
} }
// 历史累计。
// ActivityMonitor.getHistory() 返回过去若干天(最新在前),**不含今天**。
// 把它加起来是本机唯一能做出「本周累计 / 近 7 天累计」的路子;
// 按运动项目细分的那些仍然做不到,那需要手机侧与 Garmin Connect 的集成。
//
// which0 步数 / 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 { function dayOfYear(g as Gregorian.Info) as Number {
var cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; var cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
var d = cum[g.month - 1] + g.day; var d = cum[g.month - 1] + g.day;
@@ -234,7 +316,7 @@ module Fields {
return d; 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 { function isoWeek(g as Gregorian.Info) as Number {
var dow = g.day_of_week - 1; // 0 = Sunday var dow = g.day_of_week - 1; // 0 = Sunday
var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday
@@ -244,7 +326,7 @@ module Fields {
return w; 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 { function moonAge(g as Gregorian.Info) as Float {
var y = g.year; var y = g.year;
var m = g.month; var m = g.month;
@@ -253,7 +335,7 @@ module Fields {
var b = 2 - a + a / 4; var b = 2 - a + a / 4;
var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber() var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber()
+ g.day + b - 1524.5 + g.hour / 24.0; + g.day + b - 1524.5 + g.hour / 24.0;
// Monkey C has no float modulo. // ⚠️ Monkey C 的 % 不支持浮点,只能手写取余。
var syn = 29.530588853; var syn = 29.530588853;
var x = jd - 2451550.1; var x = jd - 2451550.1;
var age = x - (x / syn).toNumber() * syn; var age = x - (x / syn).toNumber() * syn;
@@ -271,113 +353,136 @@ module Fields {
return pts[i]; return pts[i];
} }
// --------------------------------------------------------------- value // ------------------------------- 分类取值 ------------------------------
// Clock, calendar and week-number fields. // ---- 日期时间类 ----
// Returns null when the id is not one of ours, so value() // 不属于本类的编号返回 null交给 value() 试下一个分类。
// can try the next category.
function dateTime(id as Number, c as Ctx) as String? { function dateTime(id as Number, c as Ctx) as String? {
if (id == 860) { return clockInfo(c.greg, c); } if (id == 860) { return clockInfo(c.greg, c); } // 时间
if (id == 880) { if (id == 880) { // UTC 时间
var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT); var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT);
return clockInfo(utc, c); return clockInfo(utc, c);
} }
// 第二时区 1
if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); } if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); }
// 第二时区 2
if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); } if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); }
if (id == 855) { return c.greg.day.format("%d"); } if (id == 855) { return c.greg.day.format("%d"); } // 日
if (id == 868) { return c.greg.month.format("%d"); } if (id == 868) { return c.greg.month.format("%d"); } // 月份数字
if (id == 859) { return isoWeek(c.greg).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 == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); }
if (id == 870) { return dow(c.greg); } // 秒数要动起来依赖设备支持 onPartialUpdate
if (id == 856) { return dow(c.greg); } // view 只在有格子选了秒时才去请求那个回调。
if (id == 853) { return month(c.greg); } if (id == 850) { return c.greg.sec.format("%d"); } // 秒
if (id == 852) { return monthDay(c.greg); } if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零)
if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); } if (id == 870) { return dow(c.greg); } // 星期简写
if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } if (id == 856) { return dow(c.greg); } // 星期全称
if (id == 858) { return meridiem(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; return null;
} }
// Steps, calories, distance, floors and active minutes. // ---- 活动类 ----
// Returns null when the id is not one of ours, so value() // 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 7 天累计。
// can try the next category.
function activity(id as Number, c as Ctx) as String? { function activity(id as Number, c as Ctx) as String? {
var i = c.info(); var i = c.info();
if (id == 1) { return i == null ? DASH : num(i.steps); } if (id == 1) { return i == null ? DASH : num(i.steps); } // 步数
if (id == 14) { return i == null ? DASH : rounded(i.steps); } if (id == 14) { return i == null ? DASH : rounded(i.steps); } // 步数 (取整)
if (id == 707) { if (id == 707) { // 剩余步数
if (i == null || i.steps == null || i.stepGoal == null) { return DASH; } if (i == null || i.steps == null || i.stepGoal == null) { return DASH; }
var left = i.stepGoal - i.steps; var left = i.stepGoal - i.steps;
return left > 0 ? left.format("%d") : "0"; return left > 0 ? left.format("%d") : "0";
} }
if (id == 2) { return i == null ? DASH : num(i.calories); } if (id == 2) { return i == null ? DASH : num(i.calories); } // 卡路里
if (id == 603) { if (id == 603) { // 活动卡路里
if (i == null || i.calories == null) { return DASH; } if (i == null || i.calories == null) { return DASH; }
var p = c.profile(); var p = c.profile();
if (p == null || p.weight == null || p.height == null) { return num(i.calories); } if (p == null || p.weight == null || p.height == null) { return num(i.calories); }
return num(i.calories); // device reports total only 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) { if (i == null || !(i has :activeMinutesDay) || i.activeMinutesDay == null) {
return DASH; return DASH;
} }
var am = i.activeMinutesDay; var am = i.activeMinutesDay;
if (id == 152) { return num(am.moderate); } if (id == 152) { return num(am.moderate); } // 中强度分钟
if (id == 154) { return num(am.vigorous); } if (id == 154) { return num(am.vigorous); } // 高强度分钟
return num(am.total); return num(am.total);
} }
if (id == 151) { if (id == 151) { // 本周活动分钟
if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) { if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) {
return DASH; return DASH;
} }
return num(i.activeMinutesWeek.total); return num(i.activeMinutesWeek.total);
} }
// 距离
if (id == 3) { return i == null ? DASH : dist(i.distance, c); } if (id == 3) { return i == null ? DASH : dist(i.distance, c); }
// 上楼层数
if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); } if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); }
// Several ActivityMonitor.Info members compile everywhere but only // ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有
// exist on some devices and firmware -- the wheelchair counters in // 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛
// particular threw "Symbol Not Found" on a fenix 8. Check before use. // "Symbol Not Found",用户一选这个字段表盘就崩。
if (id == 5) { // 所以凡是非通用成员,用之前一律先 has 检查。
if (id == 5) { // 下楼层数
return (i == null || !(i has :floorsDescended)) ? DASH return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended); : num(i.floorsDescended);
} }
if (id == 6) { if (id == 6) { // 爬升高度
return (i == null || !(i has :metersClimbed)) ? DASH return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c); : metres(i.metersClimbed, c);
} }
if (id == 9) { if (id == 9) { // 推行距离
return (i == null || !(i has :pushDistance)) ? DASH return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c); : dist(i.pushDistance, c);
} }
if (id == 10) { if (id == 10) { // 推行次数
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes); 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; } if (i == null || i.moveBarLevel == null) { return DASH; }
var lvl = i.moveBarLevel; var lvl = i.moveBarLevel;
// 活动条 (反向)
if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; } if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; }
return lvl.format("%d"); return lvl.format("%d");
} }
if (id == 13) { if (id == 13) { // 恢复时间
return (i == null || !(i has :timeToRecovery)) ? DASH return (i == null || !(i has :timeToRecovery)) ? DASH
: num(i.timeToRecovery); : num(i.timeToRecovery);
} }
if (id == 11) { if (id == 11) { // 呼吸频率
return (i == null || !(i has :respirationRate)) ? DASH return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate); : num(i.respirationRate);
} }
if (id == 12) { if (id == 12) { // 压力指数
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore); return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
} }
return null; return null;
} }
// Heart, stress, oxygen, body battery and profile-derived values. // ---- 身体类 ----
// Returns null when the id is not one of ours, so value() // 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。
// can try the next category.
function body(id as Number, c as Ctx) as String? { function body(id as Number, c as Ctx) as String? {
if (id == 618) { if (id == 618) { // 心率
var a = c.act(); var a = c.act();
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); } if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
var h = ActivityMonitor.getHeartRateHistory(1, true); var h = ActivityMonitor.getHeartRateHistory(1, true);
@@ -390,34 +495,34 @@ module Fields {
} }
return DASH; return DASH;
} }
if (id == 276) { if (id == 276) { // 静息心率
var p = c.profile(); var p = c.profile();
return (p == null || !(p has :restingHeartRate)) ? DASH return (p == null || !(p has :restingHeartRate)) ? DASH
: num(p.restingHeartRate); : num(p.restingHeartRate);
} }
if (id == 635) { if (id == 635) { // 血氧
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; } if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1}))); return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1})));
} }
if (id == 623) { if (id == 623) { // 身体电量
if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; } if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; }
return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1}))); return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1})));
} }
if (id == 281 || id == 742) { if (id == 281 || id == 742) { // 体重 / BMI
var p = c.profile(); var p = c.profile();
if (p == null || !(p has :weight) || p.weight == null) { return DASH; } if (p == null || !(p has :weight) || p.weight == null) { return DASH; }
var kg = p.weight.toFloat() / 1000.0; var kg = p.weight.toFloat() / 1000.0;
if (id == 281) { if (id == 281) { // 体重
return (c.metric ? kg : kg * 2.20462).format("%.1f"); return (c.metric ? kg : kg * 2.20462).format("%.1f");
} }
if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; } if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; }
var m = p.height.toFloat() / 100.0; var m = p.height.toFloat() / 100.0;
return (kg / (m * m)).format("%.1f"); return (kg / (m * m)).format("%.1f");
} }
if (id == 277 || id == 278) { if (id == 277 || id == 278) { // 最大摄氧量 (骑) / 最大摄氧量 (跑)
var p = c.profile(); var p = c.profile();
if (p == null) { return DASH; } if (p == null) { return DASH; }
if (id == 278) { if (id == 278) { // 最大摄氧量 (跑)
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH; return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
} }
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH; return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
@@ -426,84 +531,87 @@ module Fields {
return null; 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? { function system(id as Number, c as Ctx) as String? {
var st = c.stats(); var st = c.stats();
if (id == 250) { if (id == 250) { // 电量
return st == null || st.battery == null ? DASH return st == null || st.battery == null ? DASH
: st.battery.format("%d") + "%"; : st.battery.format("%d") + "%";
} }
if (id == 251) { if (id == 251) { // 剩余天数
if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) { if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) {
return DASH; return DASH;
} }
return st.batteryInDays.format("%d") + "d"; 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 pct = (st == null || st.battery == null) ? DASH : st.battery.format("%d");
var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null) var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null)
? DASH : st.batteryInDays.format("%d"); ? DASH : st.batteryInDays.format("%d");
return pct + "/" + d; return pct + "/" + d;
} }
if (id == 252) { if (id == 252) { // 太阳能强度
if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) { if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) {
return DASH; return DASH;
} }
return st.solarIntensity.format("%d") + "%"; return st.solarIntensity.format("%d") + "%";
} }
var ds = c.ds(); var ds = c.ds();
// 闹钟
if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); } if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); }
// 通知
if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); } if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); }
if (id == 254) { if (id == 254) { // 勿扰模式
return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF"); return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF");
} }
if (id == 256) { if (id == 256) { // 蓝牙
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF"); return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
} }
return null; 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? { function environment(id as Number, c as Ctx) as String? {
if (id == 615) { if (id == 615) { // 海拔
var a = c.act(); var a = c.act();
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c); 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(); var a = c.act();
if (a == null) { return DASH; } if (a == null) { return DASH; }
var pa = null; var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) { if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压
pa = a.meanSeaLevelPressure; pa = a.meanSeaLevelPressure;
} else if (id == 733 && (a has :ambientPressure)) { } else if (id == 733 && (a has :ambientPressure)) { // 环境气压
pa = a.ambientPressure; pa = a.ambientPressure;
} }
if (pa == null) { return DASH; } if (pa == null) { return DASH; }
return (pa.toFloat() / 100.0).format("%d"); 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(); var loc = c.where();
if (loc == null) { return DASH; } if (loc == null) { return DASH; }
var now = Time.now(); var now = Time.now();
var rise = Weather.getSunrise(loc, now); var rise = Weather.getSunrise(loc, now);
var set = Weather.getSunset(loc, now); var set = Weather.getSunset(loc, now);
if (id == 712) { return clock(rise, c); } if (id == 712) { return clock(rise, c); } // 日出
if (id == 717) { return clock(set, c); } if (id == 717) { return clock(set, c); } // 日落
if (rise == null || set == null) { return DASH; } if (rise == null || set == null) { return DASH; }
var next = (now.compare(rise) < 0) ? rise var next = (now.compare(rise) < 0) ? rise
: ((now.compare(set) < 0) ? set : 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); var secs = next.compare(now);
if (secs < 0) { secs = secs + 86400; } if (secs < 0) { secs = secs + 86400; }
return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d"); return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d");
} }
if (id == 706) { return moonAge(c.greg).format("%.1f"); } if (Solar.owns(id)) {
if (id == 732) { 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 age = moonAge(c.greg);
var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0; var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0;
return (lit * 100.0).format("%d") + "%"; return (lit * 100.0).format("%d") + "%";
@@ -512,66 +620,170 @@ module Fields {
return null; return null;
} }
// Garmin's cached conditions and today's forecast. // ---- Garmin 原生天气 ----
// Returns null when the id is not one of ours, so value() // 手表同步下来的当前天气与今日预报。与 OWM 那套完全独立。
// can try the next category.
function weather(id as Number, c as Ctx) as String? { function weather(id as Number, c as Ctx) as String? {
var w = c.wx(); var w = c.wx();
// 气温
if (id == 306) { return w == null ? DASH : temp(w.temperature, c); } if (id == 306) { return w == null ? DASH : temp(w.temperature, c); }
if (id == 300) { if (id == 300) { // 体感温度
return (w == null || !(w has :feelsLikeTemperature)) ? DASH return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c); : temp(w.feelsLikeTemperature, c);
} }
if (id == 305) { if (id == 305) { // 湿度
return (w == null || !(w has :relativeHumidity)) ? DASH return (w == null || !(w has :relativeHumidity)) ? DASH
: num(w.relativeHumidity) + "%"; : num(w.relativeHumidity) + "%";
} }
if (id == 304) { if (id == 304) { // 降水概率
if (w == null || !(w has :precipitationChance)) { return DASH; } if (w == null || !(w has :precipitationChance)) { return DASH; }
return num(w.precipitationChance) + "%"; return num(w.precipitationChance) + "%";
} }
if (id == 308) { if (id == 308) { // 风速
if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; } if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; }
var kmh = w.windSpeed.toFloat() * 3.6; var kmh = w.windSpeed.toFloat() * 3.6;
return (c.metric ? kmh : kmh * 0.621371).format("%d"); 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); 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); 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) { if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH; return DASH;
} }
return clock(w.observationTime, c); 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(); var f = c.forecast();
if (f == null) { return DASH; } if (f == null) { return DASH; }
var hi = (f has :highTemperature) ? f.highTemperature : null; var hi = (f has :highTemperature) ? f.highTemperature : null;
var lo = (f has :lowTemperature) ? f.lowTemperature : null; var lo = (f has :lowTemperature) ? f.lowTemperature : null;
if (id == 301) { return temp(hi, c); } if (id == 301) { return temp(hi, c); } // 最高气温
if (id == 302) { return temp(lo, c); } if (id == 302) { return temp(lo, c); } // 最低气温
return temp(hi, c) + "/" + temp(lo, c); return temp(hi, c) + "/" + temp(lo, c);
} }
return null; return null;
} }
// Free text the wearer typed into the settings. // ---- OpenWeatherMap ----
// Returns null when the id is not one of ours, so value() // 读后台服务上次存下来的数据(本函数不联网,见 Owm.mc / Fenix8V3Background.mc
// can try the next category. // 后台一律用公制请求,单位换算和其他字段一样在这里做。
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? { function custom(id as Number, c as Ctx) as String? {
if (id == 703) { return Settings.text("Custom1"); } if (id == 703) { return Settings.text("Custom1"); } // 自定义文字 1
if (id == 704) { return Settings.text("Custom2"); } if (id == 704) { return Settings.text("Custom2"); } // 自定义文字 2
if (id == 705) { return Settings.text("Custom3"); } if (id == 705) { return Settings.text("Custom3"); } // 自定义文字 3
return null; 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 { function value(id as Number, c as Ctx) as String {
var v = dateTime(id, c); var v = dateTime(id, c);
if (v == null) { v = activity(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 = system(id, c); }
if (v == null) { v = environment(id, c); } if (v == null) { v = environment(id, c); }
if (v == null) { v = weather(id, c); } if (v == null) { v = weather(id, c); }
if (v == null) { v = owm(id, c); }
if (v == null) { v = custom(id, c); } if (v == null) { v = custom(id, c); }
return v == null ? "" : v; return v == null ? "" : v;
} }

View File

@@ -3,24 +3,34 @@ import Toybox.Graphics;
import Toybox.WatchUi; import Toybox.WatchUi;
import Layout; 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 // 前两个是位图描出来的矢量轮廓,最细的特征只有 23 个设计像素宽。一开始我用
// the comps, so those stay as primitives sized off the same design canvas. // 圆、线段、多边形去「重画」它们,在 454px 上看着还行,一到 280pxEnduro 3
// 就糊成一团 —— 因为 s=0.56 时那些细节不足 1 个物理像素。
// 中间还试过做一版「低细节」图元,结果更糟:日出变成三条横杠。
//
// 现在的做法是:用 tools/gen_icons.py 把**设计稿的路径**按每种屏宽各光栅化一
// 张位图,运行时用 drawBitmap2(:tintColor) 按主题色染色。这样每台设备拿到的
// 都是为它的像素网格专门渲染的图。
//
// 其余图标(步数、卡路里、楼梯、心形…)设计稿里根本没有,所以保持图元绘制,
// 尺寸同样基于 500 设计画布。
// ============================================================================
module Icons { module Icons {
// 三张位图在 onLayout 时一次性加载loadResource 开销不小,不能放 onUpdate
var mSunrise = null; var mSunrise = null;
var mSunset = null; var mSunset = null;
var mWeather = null; var mWeather = null;
// 加载图标位图。资源按屏宽分目录monkey.jungle 里为每台设备指定了
// resources-icons-<屏宽>,所以这里拿到的自动就是对的尺寸。
function load() as Void { function load() as Void {
mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise); mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise);
mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset); mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset);
@@ -29,8 +39,9 @@ module Icons {
function ready() as Boolean { return mSunrise != null; } function ready() as Boolean { return mSunrise != null; }
// drawBitmap2 refuses a palettised source, which is why the generated // ⚠️ drawBitmap2 的 :tintColor 拒绝**调色板化**的位图,运行时会抛
// drawables carry packingFormat="png" to keep their alpha channel. // "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 { function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void {
if (bmp == null) { return; } if (bmp == null) { return; }
dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2, dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2,
@@ -38,8 +49,9 @@ module Icons {
bmp, {:tintColor => col}); 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, function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
k as Float, col as Number, level as Float) as Void { k as Float, col as Number, level as Float) as Void {
var x0 = Layout.px(x); 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 { function bluetooth(dc as Graphics.Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.2)); dc.setPenWidth(Layout.pen(3.2));
@@ -79,8 +91,9 @@ module Icons {
Layout.line(dc, r, qt, l, qb); 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, function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric,
col as Number, level as Float) as Void { col as Number, level as Float) as Void {
if (id == 712 || id == 700) { if (id == 712 || id == 700) {
@@ -105,25 +118,25 @@ module Icons {
var cyd = Layout.ICON_CY; var cyd = Layout.ICON_CY;
dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.0)); 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)], 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.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.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)]]); [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), 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.62), Layout.px(r * 1.15),
Layout.px(r * 0.3)); Layout.px(r * 0.3));
dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35), 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.62), Layout.px(r * 1.15),
Layout.px(r * 0.3)); 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)], dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)], [Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.35)], [Layout.px(xDesign), Layout.px(cyd + r * 0.35)],
[Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]); [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), dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28),
Layout.px(r * 0.44)); Layout.px(r * 0.44));
dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28), 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)], 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 + r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]); [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++) { for (var i = 0; i < 3; i++) {
dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i), dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i),
Layout.px(cyd + r * 0.6 - r * 0.55 * i), Layout.px(cyd + r * 0.6 - r * 0.55 * i),

View File

@@ -2,52 +2,57 @@ import Toybox.Lang;
import Toybox.Graphics; import Toybox.Graphics;
import Toybox.Math; 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 // 绘制时统一乘以 s = 屏宽 / 500所以同一套数字可以覆盖
// from design/watchface-*.svg -- ring numbers come from design/rebuild.py, the // 240pxfenix 7S到 454pxvenu 3全部 17 款设备。
// 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).
// //
// Call init() from the view's onLayout before anything else touches this. // ⚠️ 使用前必须先由 view onLayout 调用一次 init()。
// ============================================================================
module Layout { 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. // 多边形填充,而不是 drawLine 描边。这点很关键:用线段画会导致刻度在内圈
const R_IN = 231.09; // inner edge of the tick band // 挤、外圈疏,与设计稿对不上。
const R_OUT = 249.10; // outer edge const R_IN = 231.09; // 刻度带内边缘半径
const TOP_N = 21; // ticks per top arc const R_OUT = 249.10; // 刻度带外边缘半径
const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12 const TOP_N = 21; // 每条顶弧的刻度数
const TOP_PH = 6.500; // 第 0 根刻度的角度(自 12 点起顺时针,度)
const TOP_PITCH = 4.000; const TOP_PITCH = 4.000;
const TOP_HW = 1.554; // half of the 3.108 deg tick width const TOP_HW = 1.554; // 刻度角宽 3.108° 的一半
const BOT_N = 21; // ticks per bottom arc const BOT_N = 21; // 每条底弧的刻度数
const BOT_PH = 6.005; // measured clockwise from 6 o'clock const BOT_PH = 6.005; // 底弧角度自 6 点起量
const BOT_PITCH = 3.000; const BOT_PITCH = 3.000;
const BOT_HW = 1.043; 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_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85]; const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------------------------------------- elements // ------------------------------- 各元素 -------------------------------
const BATT_X = 192.70; // battery body, left edge const BATT_X = 192.70; // 电池外框左边缘
const BATT_W = 26.20; // body only; the terminal sits to its right const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算
const BATT_H = 17.81; const BATT_H = 17.81;
const BATT_WALL = 2.20; // outline thickness const BATT_WALL = 2.20; // 外框线宽
const BATT_TW = 2.53; // terminal const BATT_TW = 2.53; // 右侧触点宽
const BATT_TH = 9.20; const BATT_TH = 9.20;
const BATT_VX = 232.00; // "40%" left edge const BATT_VX = 232.00; // 百分比文字左边缘
const BATT_CY = 53.20; 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 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 VALUE_CY = 131.06;
const TIME_CY = 221.10; const TIME_CY = 221.10;
const HOURS_R = 211.40; // hour digits, right edge const HOURS_R = 211.40; // 小时数字右边缘(右对齐基准)
const MINUTES_L = 276.02; // minute digits, left edge const MINUTES_L = 276.02; // 分钟数字左边缘(左对齐基准)
const COLON_X = 237.88; const COLON_X = 237.88;
const COLON_W = 24.24; const COLON_W = 24.24;
const COLON_H = 21.30; const COLON_H = 21.30;
@@ -57,7 +62,7 @@ module Layout {
const BAND_Y = 286.30; const BAND_Y = 286.30;
const BAND_H = 44.19; const BAND_H = 44.19;
const BAND_CY = 308.10; const BAND_CY = 308.10;
const BT_CX = 36.88; // bluetooth rune const BT_CX = 36.88; // 蓝牙符号中心
const BT_CY = 306.99; const BT_CY = 306.99;
const BT_HW = 8.89; const BT_HW = 8.89;
const BT_HH = 12.92; const BT_HH = 12.92;
@@ -67,30 +72,37 @@ module Layout {
const BOT_VAL_CY = 366.76; const BOT_VAL_CY = 366.76;
const BOT_LAB_CY = 410.88; const BOT_LAB_CY = 410.88;
const DIST_R = 234.30; // right edge of both left-hand bottom lines const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准
const STEP_L = 267.40; // left edge of both right-hand bottom lines const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准
// Cap heights of the design's three text sizes. // 设计稿里三档文字的**字面高度**cap height大写字母/数字的高度,
const CAP_BAND = 25.34; // date band, battery percentage // 不含上下留白)。字号选择就是按这个来匹配的。
const CAP_COMP = 31.50; // data slots, bottom row const CAP_BAND = 25.34; // 日期带、电量百分比
const CAP_TIME = 98.70; // time digits 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 TOP_MAX_W = 185.0;
const BOT_MAX_W = 150.0; const BOT_MAX_W = 150.0;
// Connect IQ exposes ascent and descent but not cap height, and the ascent // ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight**没有字面高度接口**。
// reserves room for diacritics that no digit or capital ever uses. These // 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent
// two ratios were measured by rendering into the simulator and reading the // 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有
// ink back off the screenshot (fenix847mm: FONT_XTINY ascent 29 -> caps // 设计稿的 57%)。
// 22 px; FONT_NUMBER_MILD ascent 82 -> digits 58 px). //
// 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。
// fenix847mmFONT_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_TEXT = 0.76;
const CAP_OF_NUM = 0.71; const CAP_OF_NUM = 0.71;
// --------------------------------------------------------------- state // ------------------------------- 运行时状态 ----------------------------
var scale = 1.0; // device px per design px var scale = 1.0; // 设备像素 / 设计像素
var width = 0; var width = 0;
var height = 0; var height = 0;
var cx = 0; var cx = 0;
@@ -99,8 +111,7 @@ module Layout {
var fontComp = Graphics.FONT_MEDIUM; var fontComp = Graphics.FONT_MEDIUM;
var fontTime = Graphics.FONT_NUMBER_HOT; 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, var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE]; Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
@@ -119,7 +130,7 @@ module Layout {
function ready() as Boolean { return width > 0; } function ready() as Boolean { return width > 0; }
// Design pixels to device pixels. // 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。
function px(v as Numeric) as Number { function px(v as Numeric) as Number {
return Math.round(v * scale).toNumber(); return Math.round(v * scale).toNumber();
} }
@@ -128,8 +139,8 @@ module Layout {
return Graphics.getFontAscent(font) * ratio; 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<Graphics.FontDefinition>, targetCap as Float, function pickFont(cands as Array<Graphics.FontDefinition>, targetCap as Float,
ratio as Float) as Graphics.FontDefinition { ratio as Float) as Graphics.FontDefinition {
var best = cands[0]; var best = cands[0];
@@ -142,8 +153,8 @@ module Layout {
return best; return best;
} }
// A long value -- twelve characters of custom text, say -- would run off // 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会
// the dial at the design's size. Step down until it fits the slot. // 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。
function fitFont(dc as Graphics.Dc, str as String, function fitFont(dc as Graphics.Dc, str as String,
font as Graphics.FontDefinition, maxDesignW as Float) font as Graphics.FontDefinition, maxDesignW as Float)
as Graphics.FontDefinition { as Graphics.FontDefinition {
@@ -154,7 +165,7 @@ module Layout {
if (textFonts[i] == f) { break; } if (textFonts[i] == f) { break; }
i--; i--;
} }
if (i < 0) { return f; } // not one of the text fonts if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它
while (i >= 0) { while (i >= 0) {
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) { if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
return textFonts[i]; return textFonts[i];
@@ -164,9 +175,9 @@ module Layout {
return textFonts[0]; return textFonts[0];
} }
// TEXT_JUSTIFY_VCENTER centres the font box, but capitals occupy only the // TEXT_JUSTIFY_VCENTER 居中的是**字体盒子**,而大写字母/数字只占据
// band from the baseline up by capHeight, so the ink sits high in that box. // 「基线往上 capHeight」这一段在盒子里是偏上的。
// Push the draw down by the difference to land the ink on cy. // 所以要往下推一个差值,才能让实际墨迹的中心落在 cy 上。
function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition, function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition,
cyDesign as Numeric, ratio as Float) as Number { cyDesign as Numeric, ratio as Float) as Number {
var fh = dc.getFontHeight(font); var fh = dc.getFontHeight(font);
@@ -182,17 +193,18 @@ module Layout {
just | Graphics.TEXT_JUSTIFY_VCENTER); 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, function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String, font as Graphics.FontDefinition, str as String,
just as Number, col as Number, maxDesignW as Float) as Void { just as Number, col as Number, maxDesignW as Float) as Void {
text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col); 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 // Monkey C 没法给文字填渐变。这里的做法是:**同一串数字画 N 遍**,每遍用
// per stop with dc.setClip holding each pass to its own horizontal band. // dc.setClip 把绘制限制在一条横向色带内,色带颜色取自主题的渐变停止点。
// The first and last band are extended off-glyph so no sliver of the //
// digits is ever left undrawn. // 首尾两条色带故意向外延伸到屏幕边缘,保证字形上下不会有没画到的缝隙
// (字体实际墨迹高度和我们估算的 capHeight 总有几像素出入)。
function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String, font as Graphics.FontDefinition, str as String,
just as Number, ramp as Array<Number>) as Void { just as Number, ramp as Array<Number>) as Void {
@@ -213,7 +225,7 @@ module Layout {
dc.clearClip(); 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 { function polarX(ang as Float, r as Float) as Number {
return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber(); 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)); 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 { function pen(v as Numeric) as Number {
var w = px(v); var w = px(v);
return w < 2 ? 2 : w; return w < 2 ? 2 : w;

68
source/Owm.mc Normal file
View File

@@ -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 分类。500537 这一段全是 OWM 的编号,
// 与 Garmin 原生天气300315完全分开。
function owns(id as Number) as Boolean {
return (id >= 500 && id <= 537) || id == 502 || id == 503;
}
}

View File

@@ -1,58 +1,92 @@
import Toybox.Lang; import Toybox.Lang;
import Toybox.Application; 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 -- // 为什么要包一层而不是各处直接调 Properties.getValue
// a fresh install, or a setting added by an update -- so every read falls back // Connect IQ 的 Properties.getValue 遇到「手表还没存过的键」时**不是返回 null
// to the default rather than trusting the stored value. // 而是抛 Out of Bounds 异常**。每次版本更新新增设置项,在手表把新的
// properties.xml 合并进去之前都会撞上这个坑,轻则某个字段读不到,重则整个表盘
// 崩掉。所以全部读取都走本模块,异常一律降级成默认值。
// ============================================================================
module Settings { module Settings {
// Field ids follow https://watchface.io/docs/datafields. // ---- 四个数据位的默认字段 ----
const DEF_LEFT_TOP = 712; // sunrise // 字段编号沿用 https://watchface.io/docs/datafields与 Fields.mc 中一致。
const DEF_RIGHT_TOP = 639; // forecast high / low // 这四个默认值合起来就是设计稿上的样子。
const DEF_BOTTOM_LEFT = 3; // distance const DEF_LEFT_TOP = 712; // 左上:日出
const DEF_BOTTOM_RIGHT = 1; // steps 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 { 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; } if (v == null) { return def; }
return v as Number; return v as Number;
} }
// 读布尔型设置,取不到就用 def。
function bool(key as String, def as Boolean) as Boolean { 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; } if (v == null) { return def; }
return v as Boolean; return v as Boolean;
} }
// 读字符串型设置,取不到返回空串(不是 null省得每个调用点再判一次
function text(key as String) as String { function text(key as String) as String {
var v = Application.Properties.getValue(key); var v = raw(key);
if (v == null) { return ""; } if (v == null) { return ""; }
return v.toString(); 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 { function themeIndex(count as Number) as Number {
var i = number("Theme", 1) - 1; var i = number("Theme", 1) - 1;
if (i < 0 || i >= count) { return 0; } if (i < 0 || i >= count) { return 0; }
return i; return i;
} }
// ---- 四个数据位选中的字段编号 ----
function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); } function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); }
function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); } function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); }
function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); } function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); }
function bottomRight() as Number { return number("BottomRight", DEF_BOTTOM_RIGHT); } 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); } // 注意这里的编号是进度环自己的一张短表0 步数 / 1 卡路里 / 2 距离 /
function ringBR() as Number { return number("RingBR", 4); } // 3 楼层 / 4 动动条 / 5 活动分钟 / 6 身体电量),与上面数据位的字段编号
function ringBL() as Number { return number("RingBL", 3); } // 是两套体系,不要混用。取值逻辑在 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 tempUnit() as Number { return number("TempUnit", 0); }
// ---- 三个显隐开关 ----
function showTop() as Boolean { return bool("ShowTop", true); } function showTop() as Boolean { return bool("ShowTop", true); }
function showBand() as Boolean { return bool("ShowBand", true); } function showBand() as Boolean { return bool("ShowBand", true); }
function showBottom() as Boolean { return bool("ShowBottom", true); } function showBottom() as Boolean { return bool("ShowBottom", true); }

120
source/Solar.mc Normal file
View File

@@ -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 交叉验证过,差 13 分钟)。
// 极圈内某天太阳压根到不了要求的高度时返回 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;
}
}

View File

@@ -1,8 +1,17 @@
// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg. // ⚠️ 本文件由 tools/gen_themes.py design/watchface-*.svg 自动生成,请勿手改。
// Do not edit by hand -- change the design kit and re-run the generator. // 要改配色就改设计稿里的 SVG然后重新跑一遍生成器。
// //
// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock // 七套主题的全部颜色数据。各数组的下标就是主题序号0=Ember … 6=Kelp
// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom //
// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。
// 下标 0 是最靠近 12 点 / 6 点的那根。
// 这是从设计稿里**逐根取样**的实测值,不是插值近似。
// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。
// TICKOFF 未点亮刻度的颜色
// ANCHORDOT 四个锚点圆点
// BANDFILL/BANDTEXT 日期带的底色 / 文字色
// ACCENT 图标与底部标签
// TEXTPRIMARY 各项数值
import Toybox.Lang; import Toybox.Lang;
module Themes { module Themes {

View File

@@ -1,20 +1,24 @@
#!/usr/bin/env python3 #!/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 字段编号沿用 https://watchface.io/docs/datafields,方便两边对照;但这里只收录
two catalogues line up, but only the fields Connect IQ can actually serve from Connect IQ 在手表本机能算出来的字段。
the watch are here. Anything needing a web service (OpenWeatherMap,
StormGlass), a companion phone integration (the week-to-date and 7/28-day 需要联网服务的OpenWeatherMap 的部分、StormGlass、需要手机侧与 Garmin
per-sport aggregates), or another installed app (CGM, hydration, quotes) is Connect 集成的(按运动分类的周/月累计)、需要用户装了别的 App 的(CGM
deliberately absent -- see README section 8 for the full reasoning. 补水、行情),都不在这里 —— 具体原因见 README 第 7 节。
python3 tools/gen_fields.py python3 tools/gen_fields.py
Writes: 产出:
source/FieldTable.mc ids + short label lookup source/FieldTable.mc 字段 id 表 + 下标反查
resources/settings/settings.xml the four field pickers resources/settings/settings.xml 四个数据位的选择器
resources/strings/fields.xml English names and labels resources/strings/fields.xml 英文的字段名与表盘标签
resources-chn/strings/fields.xml Chinese names and labels resources-chn/strings/fields.xml 中文的字段名与表盘标签
加字段的流程:在下面的 FIELDS 表里加一行 → 跑一遍本脚本 →
到 source/Fields.mc 对应分类的函数里补一个分支。设置项、两种语言的文案、
表盘上的标签都会自动跟上,不会漏。
""" """
import os import os
@@ -40,6 +44,8 @@ FIELDS = [
(868, "MonthNum", "Month (6)", "月份数字", "MON", ""), (868, "MonthNum", "Month (6)", "月份数字", "MON", ""),
(858, "Meridiem", "AM/PM", "上午/下午", "", ""), (858, "Meridiem", "AM/PM", "上午/下午", "", ""),
(859, "WeekIso", "Week Number (ISO)", "周数 (ISO)", "WEEK", ""), (859, "WeekIso", "Week Number (ISO)", "周数 (ISO)", "WEEK", ""),
(850, "Seconds", "Seconds", "", "SEC", ""),
(869, "Seconds0", "Seconds (09)", "秒 (前导零)", "SEC", ""),
(873, "WeekCal", "Week Number", "周数", "WEEK", ""), (873, "WeekCal", "Week Number", "周数", "WEEK", ""),
# ---- activity ------------------------------------------------------- # ---- activity -------------------------------------------------------
@@ -58,6 +64,15 @@ FIELDS = [
(6, "MetersUp", "Distance Climbed", "爬升高度", "ASC", "爬升"), (6, "MetersUp", "Distance Climbed", "爬升高度", "ASC", "爬升"),
(9, "PushDist", "Push Distance", "推行距离", "PUSH", "推行"), (9, "PushDist", "Push Distance", "推行距离", "PUSH", "推行"),
(10, "Pushes", "Pushes", "推行次数", "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 ----------------------------------------------------------- # ---- body -----------------------------------------------------------
(618, "HeartRate", "Heart Rate", "心率", "HR", "心率"), (618, "HeartRate", "Heart Rate", "心率", "HR", "心率"),
@@ -94,6 +109,16 @@ FIELDS = [
(731, "UntilSun", "Time Until Sun Event", "距太阳事件", "SUN", "太阳"), (731, "UntilSun", "Time Until Sun Event", "距太阳事件", "SUN", "太阳"),
(706, "MoonAge", "Moon Age", "月龄", "MOON", "月龄"), (706, "MoonAge", "Moon Age", "月龄", "MOON", "月龄"),
(732, "MoonLit", "Moon Illumination", "月球照明", "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 -------------------------------------------------------- # ---- weather --------------------------------------------------------
(306, "Temp", "Temperature", "气温", "TEMP", "气温"), (306, "Temp", "Temperature", "气温", "TEMP", "气温"),
@@ -107,6 +132,38 @@ FIELDS = [
(307, "WindBearing", "Wind Bearing", "风向角", "WIND", "风向"), (307, "WindBearing", "Wind Bearing", "风向角", "WIND", "风向"),
(310, "WindDir", "Wind Direction", "风向", "WIND", "风向"), (310, "WindDir", "Wind Direction", "风向", "WIND", "风向"),
(309, "WeatherTime", "Weather Update Time", "天气更新时间", "WX", "更新"), (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 --------------------------------------------------------- # ---- custom ---------------------------------------------------------
(703, "Custom1", "Custom Text 1", "自定义文字 1", "", ""), (703, "Custom1", "Custom Text 1", "自定义文字 1", "", ""),
@@ -141,6 +198,10 @@ OTHER_SETTINGS = """
<settingConfig type="numeric" min="-720" max="840"/> <settingConfig type="numeric" min="-720" max="840"/>
</setting> </setting>
<setting propertyKey="@Properties.OwmKey" title="@Strings.SettingOwmKey">
<settingConfig type="alphaNumeric" maxLength="40"/>
</setting>
<setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1"> <setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1">
<settingConfig type="alphaNumeric" maxLength="12"/> <settingConfig type="alphaNumeric" maxLength="12"/>
</setting> </setting>
@@ -184,11 +245,15 @@ def main():
assert len(ids) == len(set(ids)), "duplicate field id" assert len(ids) == len(set(ids)), "duplicate field id"
# ---- FieldTable.mc ---- # ---- FieldTable.mc ----
L = ["// AUTO-GENERATED by tools/gen_fields.py -- do not edit by hand.", L = ["// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。",
"// Ids follow https://watchface.io/docs/datafields; only the fields a", "// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,",
"// Connect IQ watch face can source on-device are listed.", "// 再到 source/Fields.mc 对应分类的函数里补一个分支。",
"//",
"// 字段编号沿用 https://watchface.io/docs/datafields只收录 Connect IQ",
"// 表盘在本机能算出来的那些。",
"import Toybox.Lang;", "", "module FieldTable {", "", "import Toybox.Lang;", "", "module FieldTable {", "",
" // Field ids, in the same order as the Labels resource string.", " // 全部字段 id顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——",
" // Fields.label() 就是靠这个下标去取对应标签的。",
" const IDS = ["] " const IDS = ["]
row = " " row = " "
for i, fid in enumerate(ids): for i, fid in enumerate(ids):
@@ -198,6 +263,7 @@ def main():
row = " " row = " "
row += piece row += piece
L += [row, " ];", "", L += [row, " ];", "",
" // 由字段 id 反查它在 IDS 里的下标;找不到返回 0对应「关闭」",
" function indexOf(id as Number) as Number {", " function indexOf(id as Number) as Number {",
" for (var i = 0; i < IDS.size(); i++) {", " for (var i = 0; i < IDS.size(); i++) {",
" if (IDS[i] == id) { return i; }", " if (IDS[i] == id) { return i; }",

View File

@@ -1,21 +1,24 @@
#!/usr/bin/env python3 #!/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 design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 23 个设计
features are two or three design pixels across. Redrawing them from circles 像素宽。用圆和线段去重画它们在 454px 上还行,到 280px 就彻底糊掉 —— 那个尺寸
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. 所以改成:把真实路径按每种屏宽各渲染一次,运行时用
drawBitmap2(:tintColor) 染上主题色。这样每台设备拿到的都是为它的像素网格
专门渲染的图。
python3 tools/gen_icons.py python3 tools/gen_icons.py
Writes resources-icons-<W>/drawables/{sunrise,sunset,weather}.png plus the 产出 resources-icons-<屏宽>/drawables/{sunrise,sunset,weather}.png 以及配套的
matching drawables.xml. White pixels, alpha coverage; the theme's accent drawables.xml;另外产出 resources-launcher-<尺寸>/ 下各机型要求尺寸的启动图标。
colour is applied at draw time via :tintColor.
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 1. drawables.xml 里必须写 packingFormat="png"。否则资源编译器会把图片压成
would be quantised to something muddier than a clean one. 调色板格式,而 :tintColor 拒绝调色板化的源,运行时直接抛异常。
2. MIP 屏没有 alpha 混合,所以那几个尺寸的覆盖率蒙版会被阈值化成硬边 ——
软边在 64 色调色板下反而更脏。
""" """
import os import os
import re import re

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env python3 #!/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 颜色直接解析自 source/Themes.mc几何常量与 source/Fenix8V3View.mc /
are the same literals as source/Fenix8V3View.mc, so the preview cannot drift source/Layout.mc 保持同一套字面值,所以预览不会和固件漂移。
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. ⚠️ 文字用的是网页字体,真机是 Garmin 内置的 Roboto / Bionic字形会有差异。
**几何和配色可信,字形不可信** —— 判断字形要看模拟器截图。
python3 tools/gen_preview_svg.py python3 tools/gen_preview_svg.py
""" """

View File

@@ -1,28 +1,27 @@
#!/usr/bin/env python3 #!/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 事实来源是七个 watchface-NN-*.svg原作者用 design/rebuild.py 从位图逆向出来的)。
from the original raster comps). Every colour written to Themes.mc is measured 写进 Themes.mc 的每一个颜色都是从这些文件里**量出来的**,没有一个是手挑的。
from those files -- nothing here is hand-picked.
python3 tools/gen_themes.py 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 ringTop[21] 顶弧每根刻度点亮时的颜色,下标 0 = 6.5°,20 = 86.5°
ringBottom[21] lit colour of each bottom tick, index 0 = 6.0 deg .. 20 = 66.0 deg ringBottom[21] 底弧每根刻度点亮时的颜色,下标 0 = 6.0°,20 = 66.0°
tickOff colour of an unlit tick tickOff 未点亮刻度的颜色
anchorDot the four dots between the arcs anchorDot 四段弧之间的锚点圆点
bandFill/Text date band background / foreground bandFill/Text 日期带的底色 / 文字色
colon the two colon blocks colon 两块冒号
hours[11] vertical gradient ramp of the hour digits, top -> bottom hours[11] 小时数字的竖向渐变,从上到下
minutes[11] ditto for the minute digits minutes[11] 分钟数字的竖向渐变
accent icons and DST/STEP labels accent 图标与 DST/STEP 标签
textPrimary complication and bottom-row values textPrimary 各数据位与底部行的数值
Only 14 of the 21 bottom ticks are lit in any comp, so indices 14..20 of ⚠️ 已知近似:设计稿里底弧最多只点亮 14/21 根,所以 ringBottom 的第 1521 项
ringBottom are extrapolated (least-squares fit over the last 8 measured ticks). 无法实测,由最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会
Those ticks only appear once a metric passes ~67%. 显示这几根。
""" """
import os import os
import re import re
@@ -160,11 +159,20 @@ def fmt_list(colours, indent):
def main(): def main():
themes = [read_theme(t) for t in ORDER] themes = [read_theme(t) for t in ORDER]
L = [] L = []
L.append("// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg.") L.append("// ⚠️ 本文件由 tools/gen_themes.py design/watchface-*.svg 自动生成,请勿手改。")
L.append("// Do not edit by hand -- change the design kit and re-run the generator.") L.append("// 要改配色就改设计稿里的 SVG然后重新跑一遍生成器。")
L.append("//") L.append("//")
L.append("// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock") L.append("// 七套主题的全部颜色数据。各数组的下标就是主题序号0=Ember … 6=Kelp")
L.append("// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom") 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("import Toybox.Lang;")
L.append("") L.append("")
L.append("module Themes {") L.append("module Themes {")