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