diff --git a/README.md b/README.md index caa4323..7213e0e 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,30 @@ fenix8v3-watchface/ │ └── rebuild.py # 原作者从位图逆向出 SVG 的脚本(仅存档,不参与构建) ├── tools/ │ ├── gen_themes.py # design/*.svg -> source/Themes.mc +│ ├── gen_fields.py # 字段表 -> FieldTable.mc + settings.xml + 两种语言文案 +│ ├── gen_icons.py # design/*.svg -> 各尺寸图标位图 + 启动图标 │ └── gen_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html ├── source/ │ ├── Fenix8V3App.mc # 应用入口 -│ ├── Fenix8V3View.mc # 全部渲染逻辑 -│ └── Themes.mc # ⚠️ 自动生成,勿手改 -├── resources/ # 英文文案 + 设置项定义 +│ ├── Fenix8V3View.mc # 渲染逻辑(含常亮模式) +│ ├── Fields.mc # 75 项数据字段的取值与格式化 +│ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表 +│ └── Themes.mc # ⚠️ 自动生成:七套主题配色 +├── resources/ # 英文文案 + 设置项定义(部分自动生成) ├── resources-chn/ # 简体中文文案 +├── resources-icons-<宽度>/ # ⚠️ 自动生成:按屏幕尺寸光栅化的图标 +├── resources-launcher-<尺寸>/ # ⚠️ 自动生成:各尺寸启动图标 └── bin/ # 编译产物(已 gitignore) ``` -三份实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**:`Themes.mc`。预览脚本直接解析它,不会与固件漂移。 +重新生成全部派生文件: + +```bash +python3 tools/gen_themes.py && python3 tools/gen_fields.py \ + && python3 tools/gen_icons.py && python3 tools/gen_preview_svg.py +``` + +三份渲染实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**:`Themes.mc`。预览脚本直接解析它,不会与固件漂移。字段表、设置项、两种语言的文案同样只有 `tools/gen_fields.py` 里那一张表。 --- @@ -127,42 +140,94 @@ python3 tools/gen_themes.py && python3 tools/gen_preview_svg.py --- -## 7. 可配置设置 +## 7. 可配置设置与数据字段 -共 13 项,通过 Garmin Connect 手机 App 的「表盘设置」修改(定义在 `resources/settings/settings.xml`,默认值在 `properties.xml`)。 +19 项设置,在 Garmin Connect 手机 App 的「表盘设置」里改(定义在 `resources/settings/settings.xml`,默认值在 `properties.xml`,两者由 `tools/gen_fields.py` 生成)。 -| 设置 | 键 | 可选值 | 默认 | -|------|----|--------|------| -| 主题 | `Theme` | 1–7 | 1 | -| 顶右 / 顶左 / 底右 / 底左弧指标 | `RingTR/TL/BR/BL` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 / 1 / 4 / 3 | -| 左上数据 | `LeftTop` | 见下 | 6(日出) | -| 右上数据 | `RightTop` | 见下 | 8(最高/最低气温) | -| 左下数据 | `BottomLeft` | 见下 | 3(距离) | -| 右下数据 | `BottomRight` | 见下 | 2(步数) | -| 温度单位 | `TempUnit` | 0 跟随手表 / 1 °C / 2 °F | 0 | -| 显示顶行 / 日期带 / 底行 | `ShowTop` / `ShowBand` / `ShowBottom` | 布尔 | true | +| 设置 | 键 | 默认 | +|------|----|------| +| 主题 | `Theme` | 1 (Ember) | +| 四段进度环指标 | `RingTR/TL/BR/BL` | 步数 / 卡路里 / 动动条 / 楼层 | +| 四个数据位 | `LeftTop` `RightTop` `BottomLeft` `BottomRight` | 日出 / 高低温 / 距离 / 步数 | +| 温度单位 | `TempUnit` | 跟随手表 | +| 第二时区 1 / 2 偏移(分钟) | `AltOffset1` `AltOffset2` | 0 | +| 自定义文字 1 / 2 / 3 | `Custom1/2/3` | 空 | +| 显示顶行 / 日期带 / 底行 | `ShowTop` `ShowBand` `ShowBottom` | true | -**数据项**(四个数据位共用同一张表):`0` 当前气温 · `1` 卡路里 · `2` 步数 · `3` 距离 · `4` 楼层 · `5` 电量 · `6` 日出 · `7` 日落 · `8` 最高/最低气温 · `9` 心率。 +进度环指标:步数、卡路里、距离、楼层、动动条、活动分钟(对周目标)、身体电量。 -默认值即设计稿的配置:左上=日出时间、右上=`63°/52°`、左下=距离、右下=步数。 +### 数据字段 -底部两格的小标签跟随所选数据自动变化(`距离` / `步数` / `心率` …),不需要单独配置。 +四个数据位共用一张 **75 项** 的字段表,编号沿用 [watchface.io/docs/datafields](https://watchface.io/docs/datafields),两边可以直接对照。底部两格的小标签跟随所选字段自动变化,不需要单独配置。 + +| 分类 | 字段 | +|------|------| +| 日期时间 | 时间、UTC 时间、第二时区 1/2、星期月日、星期日、月日、日、星期(简写/全称)、月份(名称/数字)、上午下午、周数(ISO / 日历) | +| 活动 | 步数、步数取整、剩余步数、卡路里、活动卡路里、活动分钟(总/中强度/高强度/本周)、距离、上楼层数、下楼层数、爬升高度、推行距离、推行次数 | +| 身体 | 心率、静息心率、压力指数、血氧、呼吸频率、身体电量、活动条(正/反向)、体重、BMI、恢复时间、最大摄氧量(跑/骑) | +| 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 | +| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明 | +| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间 | +| 自定义 | 自定义文字 1 / 2 / 3 | + +加字段:在 `tools/gen_fields.py` 的 `FIELDS` 表里加一行,跑一遍生成器,再到 `source/Fields.mc` 的 `value()` 里补一个分支。设置项、两种语言的文案、表盘标签会自动跟上。 + +### ⚠️ 没有实现的字段,以及原因 + +watchface.io 的完整目录约 300 项,本表只收了 **手表本机能算出来的** 那些。其余几类不是没做,是一个自包含的 Connect IQ 表盘做不了: + +| 没做的 | 原因 | +|--------|------| +| OpenWeatherMap(约 30 项)、StormGlass.io(5 项) | 需要 `Communications.makeWebRequest` + 用户自备 API key + 后台服务定时拉取与缓存。是另一个子系统,且要求用户自己去注册 key | +| 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 不暴露按运动分类的历史累计。这些数据在 GreenBlack 那边是通过手机侧与 Garmin Connect 的集成同步来的,本机 API 只有 `ActivityMonitor.getHistory()` 的最近几天步数/卡路里/距离 | +| 第三方 App 复杂功能(CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App,并通过 Complications API 订阅 | +| 日历事件、计时器、倒计时 | 日历不对 CIQ 开放;表盘拿不到触摸输入,没法做启停 | +| 秒数 | 需要 `onPartialUpdate` 每秒重绘。技术上可行,但会明显吃电,暂未做 | +| 晨昏蒙影、黄金/蓝调时刻(约 14 项) | 可以自己算太阳高度角推出来,只是还没写;月龄和月球照明已经是这样算的 | +| 睡眠得分、训练状态 | `ActivityMonitor.Info` 上没有这两个字段(SDK 9.1.0 实测) | +| 标记类字段(Marker,约 35 项) | 那是给刻度型表盘做环形标记用的,本表盘的进度环用的是自己的指标体系 | --- -## 8. 数据来源 +## 8. 数据来源与权限 | 元素 | API | |------|-----| -| 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` | -| 日出 / 日落 | `Weather.getSunrise/getSunset(location, Time.now())`,位置取 `CurrentConditions.observationLocationPosition`,回退 `Activity.getActivityInfo().currentLocation` | -| 最高 / 最低气温 | `Weather.getDailyForecast()[0].highTemperature / lowTemperature` | -| 当前气温 | `Weather.getCurrentConditions().temperature` | -| 电量 | `System.getSystemStats().battery`(0–100 的百分比) | -| 时间 / 日期 | `Time.Gregorian`,`FORMAT_SHORT`(只有短格式的 `day_of_week` / `month` 是数字) | -| 蓝牙 | `DeviceSettings.phoneConnected`,未连接时图标压暗 | +| 步数 / 卡路里 / 距离 / 楼层 / 动动条 / 活动分钟 / 呼吸 / 压力 / 恢复时间 | `ActivityMonitor.getInfo()` | +| 心率 | `Activity.getActivityInfo().currentHeartRate`,回退 `ActivityMonitor.getHeartRateHistory()` | +| 血氧 / 身体电量 | `SensorHistory`(需 `SensorHistory` 权限) | +| 静息心率 / 体重 / 身高 / 最大摄氧量 | `UserProfile.getProfile()`(需 `UserProfile` 权限) | +| 海拔 / 环境气压 / 海平面气压 | `Activity.getActivityInfo()` | +| 日出 / 日落 | `Weather.getSunrise/getSunset(location, time)`,位置取 `CurrentConditions.observationLocationPosition`,回退 `Activity.currentLocation` | +| 气温 / 体感 / 湿度 / 降水 / 风 | `Weather.getCurrentConditions()` | +| 最高 / 最低气温 | `Weather.getDailyForecast()[0]` | +| 电量 / 剩余天数 / 太阳能 | `System.getSystemStats()`(`battery` 是 0–100 的百分比,不是 0–1) | +| 闹钟 / 通知 / 勿扰 / 蓝牙 / 单位制 | `System.getDeviceSettings()` | +| 月龄 / 月球照明 | 自算(儒略日 + 朔望月 29.530588853 天) | +| 周数 | 自算(ISO 8601 与日历周两种) | -单位与制式跟随手表设置:`is24Hour`(24 小时制下不显示 AM/PM)、`distanceUnits`(km / mi)、`temperatureUnits`(°C / °F)。天气与日出需手表已配对并同步过数据,否则显示 `--`。 +### 权限 + +`manifest.xml` 声明两项:`UserProfile`(静息心率、体重、VO2max)与 `SensorHistory`(血氧、身体电量)。用户安装时会看到授权提示。 + +> `Sensor` 权限**不能**用于 `type="watchface"`(除非同时申请 `Background`),编译器会直接拒绝。所以海拔与气压走 `Activity.Info` 而不是 `Sensor.getInfo()`。 + +单位与制式跟随手表:`is24Hour`、`distanceUnits`、`temperatureUnits`(温度可用 `TempUnit` 覆盖)。天气与日出需手表已配对并同步过数据,否则显示 `--`。 + +--- + +## 8.2 常亮显示(AOD) + +AMOLED 机型在常亮模式下必须压低点亮像素,否则既费电又有烧屏风险,Garmin 上架审核也会卡。`onEnterSleep` / `onExitSleep` 切换到 `drawAmbient()`: + +- 不画日期带底色(整条亮橙色横幅是最费电的元素) +- 进度环只画**点亮的**刻度,且整体压暗到 45% +- 时间与日期压暗到 55%,其余数据位全部隐藏 +- 整个画面按分钟在 ±4px 内游走,避免固定像素长期点亮 + +只在 `DeviceSettings.requiresBurnInProtection` 为真时启用;MIP 机型(Fenix 7 / Enduro 3 等)不受影响,照常全量绘制。 + +模拟器可用 `Settings → Display Mode` 切换验证。 --- diff --git a/manifest.xml b/manifest.xml index e24ad13..c7cbb4c 100644 --- a/manifest.xml +++ b/manifest.xml @@ -34,5 +34,12 @@ eng + + + + + + diff --git a/monkey.jungle b/monkey.jungle index 19faf56..500909f 100644 --- a/monkey.jungle +++ b/monkey.jungle @@ -1,7 +1,32 @@ project.manifest = manifest.xml base.sourcePath = source -# resources-chn holds the Simplified Chinese variant of every string; -# the -chn suffix is what tells monkeyc which language it belongs to. + +# resources-chn holds the Simplified Chinese variant of every string; the +# -chn suffix is what tells monkeyc which language it belongs to. base.resourcePath = resources;resources-chn + +# Two more resource trees per device, both generated by tools/gen_icons.py: +# resources-icons- sunrise/sunset/weather rasterised from the +# design paths for that exact pixel grid +# resources-launcher- launcher icon at the size the device asks for + +enduro3.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40 +epix2.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60 +epix2pro47mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60 +epix2pro51mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-60 +fenix7.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40 +fenix7s.resourcePath = $(base.resourcePath);resources-icons-240;resources-launcher-40 +fenix7x.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40 +fenix843mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60 +fenix847mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65 +fenix8pro47mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65 +fenix8solar47mm.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40 +fenix8solar51mm.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40 +fr255.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40 +fr265.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60 +fr965.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65 +venu3.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-70 +vivoactive5.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-56 + base.excludeAnnotations = release diff --git a/resources-chn/strings/fields.xml b/resources-chn/strings/fields.xml new file mode 100644 index 0000000..bba8ac3 --- /dev/null +++ b/resources-chn/strings/fields.xml @@ -0,0 +1,89 @@ + + + 关闭 + 时间 + UTC 时间 + 第二时区 1 + 第二时区 2 + 星期 月 日 + 星期 日 + 月 日 + + 星期简写 + 星期全称 + 月份 + 月份数字 + 上午/下午 + 周数 (ISO) + 周数 + 步数 + 步数 (取整) + 剩余步数 + 卡路里 + 活动卡路里 + 活动分钟 + 中强度分钟 + 高强度分钟 + 本周活动分钟 + 距离 + 上楼层数 + 下楼层数 + 爬升高度 + 推行距离 + 推行次数 + 心率 + 静息心率 + 压力指数 + 血氧 + 呼吸频率 + 身体电量 + 活动条 + 活动条 (反向) + 体重 + BMI + 恢复时间 + 最大摄氧量 (跑) + 最大摄氧量 (骑) + 电量 + 剩余天数 + 电量 / 天数 + 闹钟 + 通知 + 勿扰模式 + 蓝牙 + 太阳能强度 + 海拔 + 海平面气压 + 环境气压 + 日出 + 日落 + 下一太阳事件 + 距太阳事件 + 月龄 + 月球照明 + 气温 + 最高/最低气温 + 体感温度 + 最高气温 + 最低气温 + 湿度 + 降水概率 + 风速 + 风向角 + 风向 + 天气更新时间 + 自定义文字 1 + 自定义文字 2 + 自定义文字 3 + + + ,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,,, + + 步数 + 卡路里 + 距离 + 楼层 + 动动条 + 活动分钟 + 身体电量 + diff --git a/resources-chn/strings/strings.xml b/resources-chn/strings/strings.xml index 299ef15..c94db93 100644 --- a/resources-chn/strings/strings.xml +++ b/resources-chn/strings/strings.xml @@ -10,10 +10,20 @@ 右上数据 左下数据 右下数据 + 温度单位 + 第二时区 1(相对本地分钟数) + 第二时区 2(相对本地分钟数) + 自定义文字 1 + 自定义文字 2 + 自定义文字 3 显示顶部一行 显示日期带 显示底部一行 + 跟随手表 + 摄氏度 (°C) + 华氏度 (°F) + 1 · 余烬 2 · 极光 3 · 黄铜 @@ -22,32 +32,9 @@ 6 · 酸绿 7 · 海藻 - 步数 - 卡路里 - 距离 - 楼层 - 动动条 - - 气温 - 最高 / 最低气温 - 日出 - 日落 - 卡路里 - 步数 - 距离 - 楼层 - 电量 - 心率 - 周日,周一,周二,周三,周四,周五,周六 1月,2月,3月,4月,5月,6月,7月,8月,9月,10月,11月,12月 $1$$2$日 上午,下午 - 气温,卡路里,步数,距离,楼层,电量,日出,日落,高低温,心率 - - 温度单位 - 跟随手表 - 摄氏度 (°C) - 华氏度 (°F) diff --git a/resources-icons-240/drawables/drawables.xml b/resources-icons-240/drawables/drawables.xml new file mode 100644 index 0000000..b1703a8 --- /dev/null +++ b/resources-icons-240/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/resources-icons-240/drawables/sunrise.png b/resources-icons-240/drawables/sunrise.png new file mode 100644 index 0000000..3184f14 Binary files /dev/null and b/resources-icons-240/drawables/sunrise.png differ diff --git a/resources-icons-240/drawables/sunset.png b/resources-icons-240/drawables/sunset.png new file mode 100644 index 0000000..e60eb2b Binary files /dev/null and b/resources-icons-240/drawables/sunset.png differ diff --git a/resources-icons-240/drawables/weather.png b/resources-icons-240/drawables/weather.png new file mode 100644 index 0000000..dfb6b84 Binary files /dev/null and b/resources-icons-240/drawables/weather.png differ diff --git a/resources-icons-260/drawables/drawables.xml b/resources-icons-260/drawables/drawables.xml new file mode 100644 index 0000000..b1703a8 --- /dev/null +++ b/resources-icons-260/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/resources-icons-260/drawables/sunrise.png b/resources-icons-260/drawables/sunrise.png new file mode 100644 index 0000000..e901d32 Binary files /dev/null and b/resources-icons-260/drawables/sunrise.png differ diff --git a/resources-icons-260/drawables/sunset.png b/resources-icons-260/drawables/sunset.png new file mode 100644 index 0000000..6af60c4 Binary files /dev/null and b/resources-icons-260/drawables/sunset.png differ diff --git a/resources-icons-260/drawables/weather.png b/resources-icons-260/drawables/weather.png new file mode 100644 index 0000000..6c5e42a Binary files /dev/null and b/resources-icons-260/drawables/weather.png differ diff --git a/resources-icons-280/drawables/drawables.xml b/resources-icons-280/drawables/drawables.xml new file mode 100644 index 0000000..b1703a8 --- /dev/null +++ b/resources-icons-280/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/resources-icons-280/drawables/sunrise.png b/resources-icons-280/drawables/sunrise.png new file mode 100644 index 0000000..9d5fec6 Binary files /dev/null and b/resources-icons-280/drawables/sunrise.png differ diff --git a/resources-icons-280/drawables/sunset.png b/resources-icons-280/drawables/sunset.png new file mode 100644 index 0000000..ddad99d Binary files /dev/null and b/resources-icons-280/drawables/sunset.png differ diff --git a/resources-icons-280/drawables/weather.png b/resources-icons-280/drawables/weather.png new file mode 100644 index 0000000..fa6e229 Binary files /dev/null and b/resources-icons-280/drawables/weather.png differ diff --git a/resources-icons-390/drawables/drawables.xml b/resources-icons-390/drawables/drawables.xml new file mode 100644 index 0000000..b1703a8 --- /dev/null +++ b/resources-icons-390/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/resources-icons-390/drawables/sunrise.png b/resources-icons-390/drawables/sunrise.png new file mode 100644 index 0000000..67f053e Binary files /dev/null and b/resources-icons-390/drawables/sunrise.png differ diff --git a/resources-icons-390/drawables/sunset.png b/resources-icons-390/drawables/sunset.png new file mode 100644 index 0000000..c755128 Binary files /dev/null and b/resources-icons-390/drawables/sunset.png differ diff --git a/resources-icons-390/drawables/weather.png b/resources-icons-390/drawables/weather.png new file mode 100644 index 0000000..45bddb9 Binary files /dev/null and b/resources-icons-390/drawables/weather.png differ diff --git a/resources-icons-416/drawables/drawables.xml b/resources-icons-416/drawables/drawables.xml new file mode 100644 index 0000000..b1703a8 --- /dev/null +++ b/resources-icons-416/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/resources-icons-416/drawables/sunrise.png b/resources-icons-416/drawables/sunrise.png new file mode 100644 index 0000000..10d3e22 Binary files /dev/null and b/resources-icons-416/drawables/sunrise.png differ diff --git a/resources-icons-416/drawables/sunset.png b/resources-icons-416/drawables/sunset.png new file mode 100644 index 0000000..9a6648d Binary files /dev/null and b/resources-icons-416/drawables/sunset.png differ diff --git a/resources-icons-416/drawables/weather.png b/resources-icons-416/drawables/weather.png new file mode 100644 index 0000000..386fb2f Binary files /dev/null and b/resources-icons-416/drawables/weather.png differ diff --git a/resources-icons-454/drawables/drawables.xml b/resources-icons-454/drawables/drawables.xml new file mode 100644 index 0000000..b1703a8 --- /dev/null +++ b/resources-icons-454/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/resources-icons-454/drawables/sunrise.png b/resources-icons-454/drawables/sunrise.png new file mode 100644 index 0000000..ba3a9a8 Binary files /dev/null and b/resources-icons-454/drawables/sunrise.png differ diff --git a/resources-icons-454/drawables/sunset.png b/resources-icons-454/drawables/sunset.png new file mode 100644 index 0000000..c772780 Binary files /dev/null and b/resources-icons-454/drawables/sunset.png differ diff --git a/resources-icons-454/drawables/weather.png b/resources-icons-454/drawables/weather.png new file mode 100644 index 0000000..1fb453e Binary files /dev/null and b/resources-icons-454/drawables/weather.png differ diff --git a/resources/drawables/drawables.xml b/resources-launcher-40/drawables/drawables.xml similarity index 100% rename from resources/drawables/drawables.xml rename to resources-launcher-40/drawables/drawables.xml diff --git a/resources-launcher-40/drawables/launcher_icon.png b/resources-launcher-40/drawables/launcher_icon.png new file mode 100644 index 0000000..9b00253 Binary files /dev/null and b/resources-launcher-40/drawables/launcher_icon.png differ diff --git a/resources-launcher-56/drawables/drawables.xml b/resources-launcher-56/drawables/drawables.xml new file mode 100644 index 0000000..579343d --- /dev/null +++ b/resources-launcher-56/drawables/drawables.xml @@ -0,0 +1,3 @@ + + + diff --git a/resources-launcher-56/drawables/launcher_icon.png b/resources-launcher-56/drawables/launcher_icon.png new file mode 100644 index 0000000..71f9b18 Binary files /dev/null and b/resources-launcher-56/drawables/launcher_icon.png differ diff --git a/resources-launcher-60/drawables/drawables.xml b/resources-launcher-60/drawables/drawables.xml new file mode 100644 index 0000000..579343d --- /dev/null +++ b/resources-launcher-60/drawables/drawables.xml @@ -0,0 +1,3 @@ + + + diff --git a/resources-launcher-60/drawables/launcher_icon.png b/resources-launcher-60/drawables/launcher_icon.png new file mode 100644 index 0000000..b3f8583 Binary files /dev/null and b/resources-launcher-60/drawables/launcher_icon.png differ diff --git a/resources-launcher-65/drawables/drawables.xml b/resources-launcher-65/drawables/drawables.xml new file mode 100644 index 0000000..579343d --- /dev/null +++ b/resources-launcher-65/drawables/drawables.xml @@ -0,0 +1,3 @@ + + + diff --git a/resources-launcher-65/drawables/launcher_icon.png b/resources-launcher-65/drawables/launcher_icon.png new file mode 100644 index 0000000..b8bbe02 Binary files /dev/null and b/resources-launcher-65/drawables/launcher_icon.png differ diff --git a/resources-launcher-70/drawables/drawables.xml b/resources-launcher-70/drawables/drawables.xml new file mode 100644 index 0000000..579343d --- /dev/null +++ b/resources-launcher-70/drawables/drawables.xml @@ -0,0 +1,3 @@ + + + diff --git a/resources-launcher-70/drawables/launcher_icon.png b/resources-launcher-70/drawables/launcher_icon.png new file mode 100644 index 0000000..15ca2ff Binary files /dev/null and b/resources-launcher-70/drawables/launcher_icon.png differ diff --git a/resources/drawables/launcher_icon.png b/resources/drawables/launcher_icon.png deleted file mode 100644 index c257fff..0000000 Binary files a/resources/drawables/launcher_icon.png and /dev/null differ diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index a85eaab..20ed93d 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -4,11 +4,18 @@ 1 4 3 - 6 - 8 + + 712 + 639 3 - 2 + 1 0 + 0 + 0 + + + true true true diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml index 6982840..464ed79 100644 --- a/resources/settings/settings.xml +++ b/resources/settings/settings.xml @@ -1,4 +1,5 @@ + @Strings.ThemeEmber @@ -11,6 +12,326 @@ + + + @Strings.FldOff + @Strings.FldTime + @Strings.FldUtcTime + @Strings.FldAltTime1 + @Strings.FldAltTime2 + @Strings.FldDayMonthDay + @Strings.FldDayDate + @Strings.FldMonthDay + @Strings.FldDayOfMonth + @Strings.FldDayShort + @Strings.FldDayFull + @Strings.FldMonthName + @Strings.FldMonthNum + @Strings.FldMeridiem + @Strings.FldWeekIso + @Strings.FldWeekCal + @Strings.FldSteps + @Strings.FldStepsRound + @Strings.FldStepsToGo + @Strings.FldCalories + @Strings.FldActiveCal + @Strings.FldActiveMin + @Strings.FldActiveMod + @Strings.FldActiveVig + @Strings.FldActiveWeek + @Strings.FldDistance + @Strings.FldFloorsUp + @Strings.FldFloorsDown + @Strings.FldMetersUp + @Strings.FldPushDist + @Strings.FldPushes + @Strings.FldHeartRate + @Strings.FldRestingHr + @Strings.FldStress + @Strings.FldPulseOx + @Strings.FldRespiration + @Strings.FldBodyBattery + @Strings.FldMoveBar + @Strings.FldMoveBarRev + @Strings.FldWeight + @Strings.FldBmi + @Strings.FldRecovery + @Strings.FldVo2Run + @Strings.FldVo2Bike + @Strings.FldBattery + @Strings.FldBatteryDays + @Strings.FldBatteryBoth + @Strings.FldAlarms + @Strings.FldNotifications + @Strings.FldDoNotDisturb + @Strings.FldBluetooth + @Strings.FldSolar + @Strings.FldAltitude + @Strings.FldSeaPressure + @Strings.FldAmbPressure + @Strings.FldSunrise + @Strings.FldSunset + @Strings.FldNextSun + @Strings.FldUntilSun + @Strings.FldMoonAge + @Strings.FldMoonLit + @Strings.FldTemp + @Strings.FldTempHiLo + @Strings.FldFeelsLike + @Strings.FldTempHigh + @Strings.FldTempLow + @Strings.FldHumidity + @Strings.FldPrecip + @Strings.FldWindSpeed + @Strings.FldWindBearing + @Strings.FldWindDir + @Strings.FldWeatherTime + @Strings.FldCustom1 + @Strings.FldCustom2 + @Strings.FldCustom3 + + + + + + @Strings.FldOff + @Strings.FldTime + @Strings.FldUtcTime + @Strings.FldAltTime1 + @Strings.FldAltTime2 + @Strings.FldDayMonthDay + @Strings.FldDayDate + @Strings.FldMonthDay + @Strings.FldDayOfMonth + @Strings.FldDayShort + @Strings.FldDayFull + @Strings.FldMonthName + @Strings.FldMonthNum + @Strings.FldMeridiem + @Strings.FldWeekIso + @Strings.FldWeekCal + @Strings.FldSteps + @Strings.FldStepsRound + @Strings.FldStepsToGo + @Strings.FldCalories + @Strings.FldActiveCal + @Strings.FldActiveMin + @Strings.FldActiveMod + @Strings.FldActiveVig + @Strings.FldActiveWeek + @Strings.FldDistance + @Strings.FldFloorsUp + @Strings.FldFloorsDown + @Strings.FldMetersUp + @Strings.FldPushDist + @Strings.FldPushes + @Strings.FldHeartRate + @Strings.FldRestingHr + @Strings.FldStress + @Strings.FldPulseOx + @Strings.FldRespiration + @Strings.FldBodyBattery + @Strings.FldMoveBar + @Strings.FldMoveBarRev + @Strings.FldWeight + @Strings.FldBmi + @Strings.FldRecovery + @Strings.FldVo2Run + @Strings.FldVo2Bike + @Strings.FldBattery + @Strings.FldBatteryDays + @Strings.FldBatteryBoth + @Strings.FldAlarms + @Strings.FldNotifications + @Strings.FldDoNotDisturb + @Strings.FldBluetooth + @Strings.FldSolar + @Strings.FldAltitude + @Strings.FldSeaPressure + @Strings.FldAmbPressure + @Strings.FldSunrise + @Strings.FldSunset + @Strings.FldNextSun + @Strings.FldUntilSun + @Strings.FldMoonAge + @Strings.FldMoonLit + @Strings.FldTemp + @Strings.FldTempHiLo + @Strings.FldFeelsLike + @Strings.FldTempHigh + @Strings.FldTempLow + @Strings.FldHumidity + @Strings.FldPrecip + @Strings.FldWindSpeed + @Strings.FldWindBearing + @Strings.FldWindDir + @Strings.FldWeatherTime + @Strings.FldCustom1 + @Strings.FldCustom2 + @Strings.FldCustom3 + + + + + + @Strings.FldOff + @Strings.FldTime + @Strings.FldUtcTime + @Strings.FldAltTime1 + @Strings.FldAltTime2 + @Strings.FldDayMonthDay + @Strings.FldDayDate + @Strings.FldMonthDay + @Strings.FldDayOfMonth + @Strings.FldDayShort + @Strings.FldDayFull + @Strings.FldMonthName + @Strings.FldMonthNum + @Strings.FldMeridiem + @Strings.FldWeekIso + @Strings.FldWeekCal + @Strings.FldSteps + @Strings.FldStepsRound + @Strings.FldStepsToGo + @Strings.FldCalories + @Strings.FldActiveCal + @Strings.FldActiveMin + @Strings.FldActiveMod + @Strings.FldActiveVig + @Strings.FldActiveWeek + @Strings.FldDistance + @Strings.FldFloorsUp + @Strings.FldFloorsDown + @Strings.FldMetersUp + @Strings.FldPushDist + @Strings.FldPushes + @Strings.FldHeartRate + @Strings.FldRestingHr + @Strings.FldStress + @Strings.FldPulseOx + @Strings.FldRespiration + @Strings.FldBodyBattery + @Strings.FldMoveBar + @Strings.FldMoveBarRev + @Strings.FldWeight + @Strings.FldBmi + @Strings.FldRecovery + @Strings.FldVo2Run + @Strings.FldVo2Bike + @Strings.FldBattery + @Strings.FldBatteryDays + @Strings.FldBatteryBoth + @Strings.FldAlarms + @Strings.FldNotifications + @Strings.FldDoNotDisturb + @Strings.FldBluetooth + @Strings.FldSolar + @Strings.FldAltitude + @Strings.FldSeaPressure + @Strings.FldAmbPressure + @Strings.FldSunrise + @Strings.FldSunset + @Strings.FldNextSun + @Strings.FldUntilSun + @Strings.FldMoonAge + @Strings.FldMoonLit + @Strings.FldTemp + @Strings.FldTempHiLo + @Strings.FldFeelsLike + @Strings.FldTempHigh + @Strings.FldTempLow + @Strings.FldHumidity + @Strings.FldPrecip + @Strings.FldWindSpeed + @Strings.FldWindBearing + @Strings.FldWindDir + @Strings.FldWeatherTime + @Strings.FldCustom1 + @Strings.FldCustom2 + @Strings.FldCustom3 + + + + + + @Strings.FldOff + @Strings.FldTime + @Strings.FldUtcTime + @Strings.FldAltTime1 + @Strings.FldAltTime2 + @Strings.FldDayMonthDay + @Strings.FldDayDate + @Strings.FldMonthDay + @Strings.FldDayOfMonth + @Strings.FldDayShort + @Strings.FldDayFull + @Strings.FldMonthName + @Strings.FldMonthNum + @Strings.FldMeridiem + @Strings.FldWeekIso + @Strings.FldWeekCal + @Strings.FldSteps + @Strings.FldStepsRound + @Strings.FldStepsToGo + @Strings.FldCalories + @Strings.FldActiveCal + @Strings.FldActiveMin + @Strings.FldActiveMod + @Strings.FldActiveVig + @Strings.FldActiveWeek + @Strings.FldDistance + @Strings.FldFloorsUp + @Strings.FldFloorsDown + @Strings.FldMetersUp + @Strings.FldPushDist + @Strings.FldPushes + @Strings.FldHeartRate + @Strings.FldRestingHr + @Strings.FldStress + @Strings.FldPulseOx + @Strings.FldRespiration + @Strings.FldBodyBattery + @Strings.FldMoveBar + @Strings.FldMoveBarRev + @Strings.FldWeight + @Strings.FldBmi + @Strings.FldRecovery + @Strings.FldVo2Run + @Strings.FldVo2Bike + @Strings.FldBattery + @Strings.FldBatteryDays + @Strings.FldBatteryBoth + @Strings.FldAlarms + @Strings.FldNotifications + @Strings.FldDoNotDisturb + @Strings.FldBluetooth + @Strings.FldSolar + @Strings.FldAltitude + @Strings.FldSeaPressure + @Strings.FldAmbPressure + @Strings.FldSunrise + @Strings.FldSunset + @Strings.FldNextSun + @Strings.FldUntilSun + @Strings.FldMoonAge + @Strings.FldMoonLit + @Strings.FldTemp + @Strings.FldTempHiLo + @Strings.FldFeelsLike + @Strings.FldTempHigh + @Strings.FldTempLow + @Strings.FldHumidity + @Strings.FldPrecip + @Strings.FldWindSpeed + @Strings.FldWindBearing + @Strings.FldWindDir + @Strings.FldWeatherTime + @Strings.FldCustom1 + @Strings.FldCustom2 + @Strings.FldCustom3 + + + @Strings.MetricSteps @@ -18,6 +339,8 @@ @Strings.MetricDistance @Strings.MetricFloors @Strings.MetricMoveBar + @Strings.MetricActiveMin + @Strings.MetricBodyBattery @@ -28,6 +351,8 @@ @Strings.MetricDistance @Strings.MetricFloors @Strings.MetricMoveBar + @Strings.MetricActiveMin + @Strings.MetricBodyBattery @@ -38,6 +363,8 @@ @Strings.MetricDistance @Strings.MetricFloors @Strings.MetricMoveBar + @Strings.MetricActiveMin + @Strings.MetricBodyBattery @@ -48,68 +375,11 @@ @Strings.MetricDistance @Strings.MetricFloors @Strings.MetricMoveBar + @Strings.MetricActiveMin + @Strings.MetricBodyBattery - - - @Strings.FieldWeather - @Strings.FieldHiLo - @Strings.FieldSunrise - @Strings.FieldSunset - @Strings.FieldCalories - @Strings.FieldSteps - @Strings.FieldDistance - @Strings.FieldFloors - @Strings.FieldBattery - @Strings.FieldHeartRate - - - - - - @Strings.FieldWeather - @Strings.FieldHiLo - @Strings.FieldSunrise - @Strings.FieldSunset - @Strings.FieldCalories - @Strings.FieldSteps - @Strings.FieldDistance - @Strings.FieldFloors - @Strings.FieldBattery - @Strings.FieldHeartRate - - - - - - @Strings.FieldWeather - @Strings.FieldHiLo - @Strings.FieldSunrise - @Strings.FieldSunset - @Strings.FieldCalories - @Strings.FieldSteps - @Strings.FieldDistance - @Strings.FieldFloors - @Strings.FieldBattery - @Strings.FieldHeartRate - - - - - - @Strings.FieldWeather - @Strings.FieldHiLo - @Strings.FieldSunrise - @Strings.FieldSunset - @Strings.FieldCalories - @Strings.FieldSteps - @Strings.FieldDistance - @Strings.FieldFloors - @Strings.FieldBattery - @Strings.FieldHeartRate - - @@ -119,6 +389,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/resources/strings/fields.xml b/resources/strings/fields.xml new file mode 100644 index 0000000..99e8030 --- /dev/null +++ b/resources/strings/fields.xml @@ -0,0 +1,89 @@ + + + Off + Time + UTC Time + Alternate Time 1 + Alternate Time 2 + Day Month Day + Day Date + Month Day + Day of Month + Day (Tue) + Full Day (Tuesday) + Month (Jan) + Month (6) + AM/PM + Week Number (ISO) + Week Number + Steps + Steps Rounded + Steps to Go + Calories + Active Calories + Active Minutes + Active Min (Moderate) + Active Min (Vigorous) + Active Minutes (Week) + Distance + Floors Climbed + Floors Descended + Distance Climbed + Push Distance + Pushes + Heart Rate + Resting Heart Rate + Stress Score + Pulse Ox + Respiration Rate + Body Battery + Move Bar + Move Bar Reversed + Weight + BMI + Time to Recovery + VO2 Max (Run) + VO2 Max (Bike) + Battery + Battery in Days + Battery / Days + Alarms + Notifications + Do Not Disturb + Bluetooth + Solar Intensity + Altitude + Sea Level Pressure + Ambient Pressure + Sunrise + Sunset + Next Sun Event + Time Until Sun Event + Moon Age + Moon Illumination + Temperature + Temperature High/Low + Feels Like + High Temperature + Low Temperature + Humidity + Precipitation % + Wind Speed + Wind Bearing + Wind Direction + Weather Update Time + Custom Text 1 + Custom Text 2 + Custom Text 3 + + + ,TIME,UTC,TZ1,TZ2,DATE,DATE,DATE,DAY,DAY,DAY,MON,MON,,WEEK,WEEK,STEP,STEP,TO GO,KCAL,ACAL,MIN,MOD,VIG,WEEK,DST,FLR,DESC,ASC,PUSH,PUSH,HR,RHR,STRS,SPO2,RESP,BODY,MOVE,MOVE,WT,BMI,RCVR,VO2,VO2,BATT,DAYS,BATT,ALRM,MSG,DND,BT,SOLR,ALT,hPa,hPa,RISE,SET,SUN,SUN,MOON,MOON,TEMP,HI/LO,FEEL,HIGH,LOW,HUM,RAIN,WIND,WIND,WIND,WX,,, + + Steps + Calories + Distance + Floors + Move Bar + Active Minutes + Body Battery + diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index ae399b9..babfaf1 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -6,14 +6,24 @@ Ring · Top-Left Ring · Bottom-Right Ring · Bottom-Left - Top-Left Complication - Top-Right Complication + Top-Left Field + Top-Right Field Bottom-Left Field Bottom-Right Field + Temperature Unit + Alternate Time 1 (minutes from local) + Alternate Time 2 (minutes from local) + Custom Text 1 + Custom Text 2 + Custom Text 3 Show Top Row Show Date Band Show Bottom Row + Follow watch + Celsius (°C) + Fahrenheit (°F) + 1 · Ember 2 · Aurora 3 · Brass @@ -22,32 +32,9 @@ 6 · Acid 7 · Kelp - Steps - Calories - Distance - Floors - Move Bar - - Temperature - Hi / Lo Temp - Sunrise - Sunset - Calories - Steps - Distance - Floors - Battery - Heart Rate - SUN,MON,TUE,WED,THU,FRI,SAT JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC $1$ $2$ AM,PM - TEMP,KCAL,STEP,DST,FLR,BATT,RISE,SET,HI/LO,HR - - Temperature Unit - Follow watch - Celsius (°C) - Fahrenheit (°F) diff --git a/source/Fenix8V3View.mc b/source/Fenix8V3View.mc index be4633d..a2d6500 100644 --- a/source/Fenix8V3View.mc +++ b/source/Fenix8V3View.mc @@ -11,6 +11,8 @@ import Toybox.Time; import Toybox.Time.Gregorian; import Toybox.WatchUi; import Themes; +import Fields; +import Toybox.SensorHistory; // Every literal below is a coordinate on the 500x500 design canvas, measured // from design/watchface-*.svg. Nothing here is eyeballed: ring geometry comes @@ -47,7 +49,7 @@ class Fenix8V3View extends WatchUi.WatchFace { const LEFT_X = 157.00; // top complications, centred const RIGHT_X = 343.70; - const ICON_CY = 90.00; + const ICON_CY = 89.50; const VALUE_CY = 131.06; const TIME_CY = 221.10; @@ -89,22 +91,37 @@ class Fenix8V3View extends WatchUi.WatchFace { private var mFontBand = Graphics.FONT_SMALL; private var mFontComp = Graphics.FONT_MEDIUM; private var mFontTime = Graphics.FONT_NUMBER_HOT; - private var mDow = null; - private var mMon = null; - private var mLabels = null; - private var mMeridiem = null; - private var mDateFmt = " "; + // Always-on display: AMOLED devices must cut lit pixels hard while + // asleep, and shift what is left so nothing burns in. + private var mSleeping = false; + private var mBurnIn = false; + private var mIconSunrise = null; + private var mIconSunset = null; + private var mIconWeather = null; function initialize() { WatchFace.initialize(); } + function onEnterSleep() as Void { + mSleeping = true; + WatchUi.requestUpdate(); + } + + function onExitSleep() as Void { + mSleeping = false; + WatchUi.requestUpdate(); + } + function onLayout(dc as Dc) as Void { mW = dc.getWidth(); mH = dc.getHeight(); mS = mW / 500.0; mCx = mW / 2; mCy = mH / 2; + var ds = System.getDeviceSettings(); + mBurnIn = (ds != null) && (ds has :requiresBurnInProtection) + && ds.requiresBurnInProtection; var text = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL, Graphics.FONT_MEDIUM, Graphics.FONT_LARGE]; var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM, @@ -115,28 +132,11 @@ class Fenix8V3View extends WatchUi.WatchFace { loadStrings(); } - // Every piece of on-face wording lives in resources so resources-chn can - // replace it. loadResource is not cheap, so pull it all once per layout. function loadStrings() as Void { - mDow = splitCsv(WatchUi.loadResource(Rez.Strings.DowNames) as String); - mMon = splitCsv(WatchUi.loadResource(Rez.Strings.MonNames) as String); - mLabels = splitCsv(WatchUi.loadResource(Rez.Strings.FieldLabels) as String); - mMeridiem = splitCsv(WatchUi.loadResource(Rez.Strings.Meridiem) as String); - mDateFmt = WatchUi.loadResource(Rez.Strings.DateFormat) as String; - } - - // Monkey C has no String.split. - function splitCsv(str as String) as Array { - var out = [] as Array; - var rest = str; - var cut = rest.find(","); - while (cut != null) { - out.add(rest.substring(0, cut) as String); - rest = rest.substring(cut + 1, rest.length()) as String; - cut = rest.find(","); - } - out.add(rest); - return out; + Fields.loadStrings(); + mIconSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise); + mIconSunset = WatchUi.loadResource(Rez.Drawables.IconSunset); + mIconWeather = WatchUi.loadResource(Rez.Drawables.IconWeather); } // Connect IQ exposes ascent and descent but not cap height, and the ascent @@ -254,6 +254,15 @@ class Fenix8V3View extends WatchUi.WatchFace { ? info.floorsClimbedGoal.toFloat() : 10.0f; p = info.floorsClimbed.toFloat() / goal; } + } else if (idx == 5) { // Active minutes vs the weekly goal + if (info.activeMinutesWeek != null && info.activeMinutesWeekGoal != null + && info.activeMinutesWeekGoal > 0) { + p = info.activeMinutesWeek.total.toFloat() + / info.activeMinutesWeekGoal.toFloat(); + } + } else if (idx == 6) { // Body battery is already 0..100 + var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1})); + if (bb != null) { p = bb.toFloat() / 100.0; } } else if (idx == 4) { // Move bar: an integer level, not a ratio if (info.moveBarLevel != null) { var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX @@ -268,103 +277,6 @@ class Fenix8V3View extends WatchUi.WatchFace { return p; } - // ------------------------------------------------------------ data bits - function distanceString(info as ActivityMonitor.Info, ds as System.DeviceSettings) as String { - if (info == null || info.distance == null) { return "0.0"; } - var km = info.distance.toFloat() / 100000.0; - if (ds != null && ds.distanceUnits == System.UNIT_STATUTE) { km = km * 0.621371; } - return km.format("%.1f"); - } - - // TempUnit: 0 follow the watch, 1 force Celsius, 2 force Fahrenheit. - function tempString(c as Numeric?, ds as System.DeviceSettings) as String { - if (c == null) { return "--"; } - var t = c.toFloat(); - var mode = getSetting("TempUnit", 0); - var fahrenheit = (mode == 2) - || (mode == 0 && ds != null && ds.temperatureUnits == System.UNIT_STATUTE); - if (fahrenheit) { t = t * 9.0 / 5.0 + 32.0; } - return t.format("%d") + "°"; - } - - function clockString(m as Time.Moment?, ds as System.DeviceSettings) as String { - if (m == null) { return "--:--"; } - var g = Gregorian.info(m, Time.FORMAT_SHORT); - var h = g.hour; - if (ds == null || !ds.is24Hour) { - if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; } - } - return h.format("%d") + ":" + g.min.format("%02d"); - } - - function here(weather as Weather.CurrentConditions?) as Position.Location? { - if (weather != null && weather.observationLocationPosition != null) { - return weather.observationLocationPosition; - } - var act = Activity.getActivityInfo(); - if (act != null && act.currentLocation != null) { return act.currentLocation; } - return null; - } - - // value string for a complication slot - function complicationValue(typeIdx as Number, info as ActivityMonitor.Info, - weather as Weather.CurrentConditions?, stats as System.Stats, - ds as System.DeviceSettings) as String { - if (typeIdx == 1) { // Calories - return info != null && info.calories != null ? info.calories.format("%d") : "0"; - } else if (typeIdx == 2) { // Steps - return info != null && info.steps != null ? info.steps.format("%d") : "0"; - } else if (typeIdx == 3) { // Distance - return distanceString(info, ds); - } else if (typeIdx == 4) { // Floors - return info != null && info.floorsClimbed != null - ? info.floorsClimbed.format("%d") : "0"; - } else if (typeIdx == 5) { // Battery - var pct = 0; - if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); } - return pct.format("%d") + "%"; - } else if (typeIdx == 6 || typeIdx == 7) { // Sunrise / sunset - var loc = here(weather); - if (loc == null) { return "--:--"; } - var m = typeIdx == 6 ? Weather.getSunrise(loc, Time.now()) - : Weather.getSunset(loc, Time.now()); - return clockString(m, ds); - } else if (typeIdx == 9) { // Heart rate - var act = Activity.getActivityInfo(); - if (act != null && act.currentHeartRate != null) { - return act.currentHeartRate.format("%d"); - } - var hist = ActivityMonitor.getHeartRateHistory(1, true); - if (hist != null) { - var sample = hist.next(); - if (sample != null && sample.heartRate != null - && sample.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) { - return sample.heartRate.format("%d"); - } - } - return "--"; - } else if (typeIdx == 8) { // Forecast high / low - var fc = Weather.getDailyForecast(); - if (fc != null && fc.size() > 0) { - return tempString(fc[0].highTemperature, ds) + "/" - + tempString(fc[0].lowTemperature, ds); - } - return "--/--"; - } - // 0: current temperature - if (weather != null && weather.temperature != null) { - return tempString(weather.temperature, ds); - } - return "--"; - } - - // Short caption under a bottom-row value. Indexed like the field types, so - // the label always follows whatever the slot is set to. - function fieldLabel(typeIdx as Number) as String { - if (mLabels == null || typeIdx < 0 || typeIdx >= mLabels.size()) { return ""; } - return mLabels[typeIdx]; - } - // ----------------------------------------------------------------- ring function tick(dc as Dc, ang as Float, hw as Float) as Void { var a0 = ang - hw; @@ -441,79 +353,19 @@ class Fenix8V3View extends WatchUi.WatchFace { } } - // Filled half-ellipse, flat side down. The comp's dome is 14.90 wide but - // 8.93 tall, so it is not a plain semicircle. - function dome(dc as Dc, cx as Numeric, baseY as Numeric, - rx as Numeric, ry as Numeric) as Void { - var pts = []; - for (var i = 0; i <= 12; i++) { - var rad = Math.toRadians(180.0 - 15.0 * i); - pts.add([Math.round(px(cx) + Math.cos(rad) * px(rx)).toNumber(), - Math.round(px(baseY) - Math.sin(rad) * px(ry)).toNumber()]); - } - dc.fillPolygon(pts); - } - function stub(dc as Dc, x1 as Numeric, y1 as Numeric, x2 as Numeric, y2 as Numeric) as Void { dc.drawLine(px(x1), px(y1), px(x2), px(y2)); } - // Sun over a horizon bar with a chevron. Every ray in the comp is a short - // stub roughly as long as the pen is wide -- drawing them longer is what - // made this icon read taller than the weather one. - // dir = -1 flips the chevron up, which is what the sunset slot uses. - function iconSun(dc as Dc, cx as Numeric, cy as Numeric, scale as Float, - col as Number, dir as Number) as Void { - var k = scale; - var ox = cx - 156.32 * k; // design-space offset of this icon - var oy = cy - 90.34 * k; - dc.setColor(col, Graphics.COLOR_TRANSPARENT); - dome(dc, ox + 156.39 * k, oy + 94.02 * k, 7.45 * k, 8.93 * k); - dc.setPenWidth(maxNum(2, px(2.96 * k))); - stub(dc, ox + 156.39 * k, oy + 79.4 * k, ox + 156.39 * k, oy + 81.1 * k); - stub(dc, ox + 148.2 * k, oy + 83.3 * k, ox + 146.5 * k, oy + 84.5 * k); - stub(dc, ox + 164.6 * k, oy + 84.5 * k, ox + 166.3 * k, oy + 83.3 * k); - stub(dc, ox + 142.6 * k, oy + 92.5 * k, ox + 144.7 * k, oy + 92.5 * k); - stub(dc, ox + 168.0 * k, oy + 92.5 * k, ox + 170.1 * k, oy + 92.5 * k); - dc.setPenWidth(maxNum(2, px(2.6 * k))); - var by = oy + 98.45 * k; - stub(dc, ox + 142.3 * k, by, ox + 170.3 * k, by); - stub(dc, ox + 152.6 * k, by, ox + 156.3 * k, by + dir * 3.05 * k); - stub(dc, ox + 156.3 * k, by + dir * 3.05 * k, ox + 160.0 * k, by); - } - - // Sun peeking out from behind a cloud. The comp keeps a dark notch where - // the cloud crosses the sun; without it the two shapes merge into one blob - // and the icon reads much heavier than the sunrise one next to it. So the - // cloud is stamped twice: once inflated in black to cut the notch, then at - // true size in the accent colour. - function cloudBody(dc as Dc, ox as Numeric, oy as Numeric, k as Float, - grow as Float) as Void { - dc.fillCircle(px(ox + 336.6 * k), px(oy + 94.4 * k), px(8.7 * k + grow)); - dc.fillCircle(px(ox + 346.4 * k), px(oy + 96.9 * k), px(6.3 * k + grow)); - var g = px(grow); - dc.fillRoundedRectangle(px(ox + 328.0 * k) - g, px(oy + 95.5 * k) - g, - px(24.6 * k) + 2 * g, px(7.75 * k) + g, - px(3.4 * k)); - } - - function iconCloud(dc as Dc, cx as Numeric, cy as Numeric, scale as Float, - col as Number) as Void { - var k = scale; - var ox = cx - 343.55 * k; - var oy = cy - 89.25 * k; - dc.setColor(col, Graphics.COLOR_TRANSPARENT); - dc.fillCircle(px(ox + 347.0 * k), px(oy + 88.9 * k), px(7.8 * k)); - dc.setPenWidth(maxNum(2, px(2.3 * k))); - stub(dc, ox + 346.2 * k, oy + 76.0 * k, ox + 346.2 * k, oy + 78.7 * k); - stub(dc, ox + 354.9 * k, oy + 87.7 * k, ox + 357.5 * k, oy + 87.7 * k); - dc.setPenWidth(maxNum(2, px(2.7 * k))); - stub(dc, ox + 339.5 * k, oy + 79.5 * k, ox + 338.1 * k, oy + 80.9 * k); - stub(dc, ox + 353.5 * k, oy + 80.9 * k, ox + 354.6 * k, oy + 79.8 * k); - dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT); - cloudBody(dc, ox, oy, k, 1.7 * k); - dc.setColor(col, Graphics.COLOR_TRANSPARENT); - cloudBody(dc, ox, oy, k, 0.0); + // The comps' sunrise and weather icons are traced outlines with two- and + // three-pixel features. Rebuilding them from circles and lines held up at + // 454 px and turned to mush at 280, so they ship as bitmaps rasterised + // from the design paths at each screen size (tools/gen_icons.py) and get + // the theme's accent applied through :tintColor. + function blitIcon(dc as Dc, bmp, cx as Numeric, cy as Numeric, col as Number) as Void { + if (bmp == null) { return; } + dc.drawBitmap2(px(cx) - bmp.getWidth() / 2, px(cy) - bmp.getHeight() / 2, + bmp, {:tintColor => col}); } function maxNum(a as Number, b as Number) as Number { @@ -522,47 +374,52 @@ class Fenix8V3View extends WatchUi.WatchFace { function drawComplicationIcon(dc as Dc, cx as Numeric, cy as Numeric, scale as Float, col as Number, typeIdx as Number, level as Float) as Void { - if (typeIdx == 6) { - iconSun(dc, cx, cy, scale, col, 1); - } else if (typeIdx == 7) { - iconSun(dc, cx, cy, scale, col, -1); - } else if (typeIdx == 0 || typeIdx == 8) { - iconCloud(dc, cx, cy, scale, col); - } else if (typeIdx == 5) { + if (typeIdx == 712 || typeIdx == 700) { + blitIcon(dc, mIconSunrise, cx, ICON_CY, col); + } else if (typeIdx == 717 || typeIdx == 731) { + blitIcon(dc, mIconSunset, cx, ICON_CY, col); + } else if (typeIdx == 306 || typeIdx == 639 || typeIdx == 300 + || typeIdx == 301 || typeIdx == 302 || typeIdx == 304 + || typeIdx == 305 || typeIdx == 308 || typeIdx == 309 + || typeIdx == 310 || typeIdx == 307) { + blitIcon(dc, mIconWeather, cx, ICON_CY, col); + } else if (typeIdx == 250 || typeIdx == 251 || typeIdx == 257) { iconBattery(dc, cx - 14.4 * scale, cy, scale, col, level); } else { var r = 13.0 * scale; dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.setPenWidth(maxNum(2, px(3.0 * scale))); - if (typeIdx == 1) { // calories: flame + if (typeIdx == 2 || typeIdx == 603) { // calories: flame dc.fillPolygon([[px(cx), px(cy - r)], [px(cx + r * 0.72), px(cy + r * 0.25)], [px(cx + r * 0.40), px(cy + r * 0.85)], [px(cx - r * 0.40), px(cy + r * 0.85)], [px(cx - r * 0.72), px(cy + r * 0.25)]]); - } else if (typeIdx == 2) { // steps: two prints + } else if (typeIdx == 1 || typeIdx == 14 || typeIdx == 707) { // steps dc.fillRoundedRectangle(px(cx - r * 0.85), px(cy - r * 0.75), px(r * 0.62), px(r * 1.15), px(r * 0.3)); dc.fillRoundedRectangle(px(cx + r * 0.22), px(cy - r * 0.35), px(r * 0.62), px(r * 1.15), px(r * 0.3)); - } else if (typeIdx == 3) { // distance: arrow + } else if (typeIdx == 3 || typeIdx == 9 || typeIdx == 615) { // distance dc.fillPolygon([[px(cx), px(cy - r)], [px(cx + r * 0.75), px(cy + r * 0.8)], [px(cx), px(cy + r * 0.35)], [px(cx - r * 0.75), px(cy + r * 0.8)]]); - } else if (typeIdx == 9) { // heart rate + } else if (typeIdx == 618 || typeIdx == 276 || typeIdx == 623) { // heart dc.fillCircle(px(cx - r * 0.38), px(cy - r * 0.28), px(r * 0.44)); dc.fillCircle(px(cx + r * 0.38), px(cy - r * 0.28), px(r * 0.44)); dc.fillPolygon([[px(cx - r * 0.78), px(cy - r * 0.12)], [px(cx + r * 0.78), px(cy - r * 0.12)], [px(cx), px(cy + r * 0.85)]]); - } else { // floors: stairs + } else if (typeIdx == 4 || typeIdx == 5 || typeIdx == 6) { // stairs for (var i = 0; i < 3; i++) { dc.fillRectangle(px(cx - r * 0.85 + r * 0.58 * i), px(cy + r * 0.6 - r * 0.55 * i), px(r * 0.62), px(r * 0.4)); } } + // Everything else -- dates, week numbers, custom text -- reads fine + // without a glyph, so no icon is drawn. } } @@ -578,14 +435,10 @@ class Fenix8V3View extends WatchUi.WatchFace { } function drawTopComplication(dc as Dc, ti as Number, typeIdx as Number, isLeft as Boolean, - info as ActivityMonitor.Info, weather as Weather.CurrentConditions?, - stats as System.Stats, ds as System.DeviceSettings) as Void { + ctx as Fields.Ctx, level as Float) as Void { var x = isLeft ? LEFT_X : RIGHT_X; - var level = 0.0f; - if (stats != null && stats.battery != null) { level = stats.battery / 100.0; } drawComplicationIcon(dc, x, ICON_CY, 1.0, Themes.ACCENT[ti] as Number, typeIdx, level); - text(dc, x, VALUE_CY, mFontComp, - complicationValue(typeIdx, info, weather, stats, ds), + text(dc, x, VALUE_CY, mFontComp, Fields.value(typeIdx, ctx), Graphics.TEXT_JUSTIFY_CENTER, Themes.TEXTPRIMARY[ti] as Number); } @@ -615,15 +468,12 @@ class Fenix8V3View extends WatchUi.WatchFace { // FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns // day_of_week and month as numbers rather than localised strings. var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); - text(dc, DOW_CX, BAND_CY, mFontBand, mDow[g.day_of_week - 1], + text(dc, DOW_CX, BAND_CY, mFontBand, Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col); - // DateFormat is "$1$ $2$" in English and "$1$$2$日" in Chinese, where - // the Chinese month names already carry their own 月. - text(dc, MD_CX, BAND_CY, mFontBand, - Lang.format(mDateFmt, [mMon[g.month - 1], g.day.format("%d")]), + text(dc, MD_CX, BAND_CY, mFontBand, Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, col); if (ds == null || !ds.is24Hour) { - text(dc, AMPM_R, BAND_CY, mFontBand, mMeridiem[g.hour >= 12 ? 1 : 0], + text(dc, AMPM_R, BAND_CY, mFontBand, Fields.meridiem(g), Graphics.TEXT_JUSTIFY_RIGHT, col); } } @@ -653,49 +503,120 @@ class Fenix8V3View extends WatchUi.WatchFace { dc.fillRectangle(px(COLON_X), px(COLON_Y2), px(COLON_W), px(COLON_H)); } - function drawBottom(dc as Dc, ti as Number, info as ActivityMonitor.Info, - weather as Weather.CurrentConditions?, stats as System.Stats, - ds as System.DeviceSettings) as Void { + 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; var left = getSetting("BottomLeft", 3); - var right = getSetting("BottomRight", 2); - text(dc, DIST_R, BOT_VAL_CY, mFontComp, - complicationValue(left, info, weather, stats, ds), + var right = getSetting("BottomRight", 1); + text(dc, DIST_R, BOT_VAL_CY, mFontComp, Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT, value); - text(dc, DIST_R, BOT_LAB_CY, mFontComp, fieldLabel(left), + text(dc, DIST_R, BOT_LAB_CY, mFontComp, Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT, label); - text(dc, STEP_L, BOT_VAL_CY, mFontComp, - complicationValue(right, info, weather, stats, ds), + text(dc, STEP_L, BOT_VAL_CY, mFontComp, Fields.value(right, ctx), Graphics.TEXT_JUSTIFY_LEFT, value); - text(dc, STEP_L, BOT_LAB_CY, mFontComp, fieldLabel(right), + text(dc, STEP_L, BOT_LAB_CY, mFontComp, Fields.label(right), Graphics.TEXT_JUSTIFY_LEFT, label); } + // ------------------------------------------------------------------ AOD + function dim(col as Number, f as Float) as Number { + var r = (((col >> 16) & 0xFF) * f).toNumber(); + var g = (((col >> 8) & 0xFF) * f).toNumber(); + var b = ((col & 0xFF) * f).toNumber(); + return (r << 16) | (g << 8) | b; + } + + // Low-power face: no band fill, no unlit ticks, no complications -- 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. + 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 / mS; // +/- 4 device px, in design units + var oy = ((g.min / 5) % 5 - 2) * 2.0 / mS; + + var top = Themes.RINGTOP[ti] as Array; + var bot = Themes.RINGBOTTOM[ti] as Array; + var litTR = (pTR * TOP_N).toNumber(); + var litTL = (pTL * TOP_N).toNumber(); + var litBR = (pBR * BOT_N).toNumber(); + var litBL = (pBL * BOT_N).toNumber(); + for (var i = 0; i < TOP_N; i++) { + var a = TOP_PH + TOP_PITCH * i; + if (i >= TOP_N - litTR) { + dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); + tick(dc, a, TOP_HW); + } + if (i >= TOP_N - litTL) { + dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); + tick(dc, -a, TOP_HW); + } + } + for (var i = 0; i < BOT_N; i++) { + var a = BOT_PH + BOT_PITCH * i; + if (i < litBL) { + dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); + tick(dc, 180.0 + a, BOT_HW); + } + if (i < litBR) { + dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); + tick(dc, 180.0 - a, BOT_HW); + } + } + + var h = g.hour; + if (ds == null || !ds.is24Hour) { + if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; } + } + var hours = Themes.HOURS[ti] as Array; + var mins = Themes.MINUTES[ti] as Array; + text(dc, HOURS_R + ox, TIME_CY + oy, mFontTime, h.format("%d"), + Graphics.TEXT_JUSTIFY_RIGHT, dim(hours[hours.size() / 2], 0.55)); + text(dc, MINUTES_L + ox, TIME_CY + oy, mFontTime, g.min.format("%02d"), + Graphics.TEXT_JUSTIFY_LEFT, dim(mins[mins.size() / 2], 0.55)); + dc.setColor(dim(Themes.COLON[ti] as Number, 0.55), Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(px(COLON_X + ox), px(COLON_Y1 + oy), px(COLON_W), px(COLON_H)); + dc.fillRectangle(px(COLON_X + ox), px(COLON_Y2 + oy), px(COLON_W), px(COLON_H)); + + var band = dim(Themes.ACCENT[ti] as Number, 0.55); + text(dc, DOW_CX + ox, BAND_CY + oy, mFontBand, Fields.dow(g), + Graphics.TEXT_JUSTIFY_CENTER, band); + text(dc, MD_CX + ox, BAND_CY + oy, mFontBand, Fields.monthDay(g), + Graphics.TEXT_JUSTIFY_CENTER, band); + } + // ----------------------------------------------------------------- main function onUpdate(dc as Dc) as Void { - if (mW != dc.getWidth() || mDow == null) { onLayout(dc); } + if (mW != dc.getWidth() || !Fields.ready()) { onLayout(dc); } if (dc has :setAntiAlias) { dc.setAntiAlias(true); } var ti = themeIndex(); - var info = ActivityMonitor.getInfo(); - var stats = System.getSystemStats(); var ds = System.getDeviceSettings(); - var weather = Weather.getCurrentConditions(); + var ctx = new Fields.Ctx(ds, getSetting("TempUnit", 0)); + var info = ctx.info(); + var stats = ctx.stats(); dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); dc.clear(); - drawRing(dc, ti, - metricProgress(getSetting("RingTR", 0), info), - metricProgress(getSetting("RingTL", 1), info), - metricProgress(getSetting("RingBR", 4), info), - metricProgress(getSetting("RingBL", 3), info)); + var pTR = metricProgress(getSetting("RingTR", 0), info); + var pTL = metricProgress(getSetting("RingTL", 1), info); + var pBR = metricProgress(getSetting("RingBR", 4), info); + var pBL = metricProgress(getSetting("RingBL", 3), info); + + if (mSleeping && mBurnIn) { + drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL); + return; + } + + drawRing(dc, ti, pTR, pTL, pBR, pBL); if (getBool("ShowTop", true)) { + var level = 0.0f; + if (stats != null && stats.battery != null) { level = stats.battery / 100.0; } drawBattery(dc, ti, stats); - drawTopComplication(dc, ti, getSetting("LeftTop", 6), true, info, weather, stats, ds); - drawTopComplication(dc, ti, getSetting("RightTop", 8), false, info, weather, stats, ds); + drawTopComplication(dc, ti, getSetting("LeftTop", 712), true, ctx, level); + drawTopComplication(dc, ti, getSetting("RightTop", 639), false, ctx, level); } drawTime(dc, ti, ds); @@ -705,7 +626,7 @@ class Fenix8V3View extends WatchUi.WatchFace { } if (getBool("ShowBottom", true)) { - drawBottom(dc, ti, info, weather, stats, ds); + drawBottom(dc, ti, ctx); } } } diff --git a/source/FieldTable.mc b/source/FieldTable.mc new file mode 100644 index 0000000..00b1cfb --- /dev/null +++ b/source/FieldTable.mc @@ -0,0 +1,22 @@ +// 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. +import Toybox.Lang; + +module FieldTable { + + // Field ids, in the same order as the Labels resource string. + 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 + ]; + + function indexOf(id as Number) as Number { + for (var i = 0; i < IDS.size(); i++) { + if (IDS[i] == id) { return i; } + } + return 0; + } +} diff --git a/source/Fields.mc b/source/Fields.mc new file mode 100644 index 0000000..3cca859 --- /dev/null +++ b/source/Fields.mc @@ -0,0 +1,491 @@ +import Toybox.Lang; +import Toybox.Math; +import Toybox.System; +import Toybox.Time; +import Toybox.Time.Gregorian; +import Toybox.Application; +import Toybox.ActivityMonitor; +import Toybox.Activity; +import Toybox.UserProfile; +import Toybox.SensorHistory; +import Toybox.Weather; +import Toybox.Position; +import Toybox.WatchUi; +import FieldTable; + +// 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. +// +// 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. +module Fields { + + const DASH = "--"; + + var mDow as Array?; + var mMon as Array?; + var mLabels as Array?; + var mMeridiem as Array?; + var mDateFmt as String = "$1$ $2$"; + + function loadStrings() as Void { + mDow = splitCsv(WatchUi.loadResource(Rez.Strings.DowNames) as String); + mMon = splitCsv(WatchUi.loadResource(Rez.Strings.MonNames) as String); + mLabels = splitCsv(WatchUi.loadResource(Rez.Strings.FieldLabels) as String); + mMeridiem = splitCsv(WatchUi.loadResource(Rez.Strings.Meridiem) as String); + mDateFmt = WatchUi.loadResource(Rez.Strings.DateFormat) as String; + } + + function ready() as Boolean { return mDow != null; } + + // Monkey C has no String.split. + function splitCsv(str as String) as Array { + var out = [] as Array; + var rest = str; + var cut = rest.find(","); + while (cut != null) { + out.add(rest.substring(0, cut) as String); + rest = rest.substring(cut + 1, rest.length()) as String; + cut = rest.find(","); + } + out.add(rest); + return out; + } + + function dow(g as Gregorian.Info) as String { + return mDow == null ? "" : mDow[g.day_of_week - 1]; + } + + function month(g as Gregorian.Info) as String { + return mMon == null ? "" : mMon[g.month - 1]; + } + + function monthDay(g as Gregorian.Info) as String { + return Lang.format(mDateFmt, [month(g), g.day.format("%d")]); + } + + function meridiem(g as Gregorian.Info) as String { + return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0]; + } + + // Short caption printed under a bottom-row value. + 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. + class Ctx { + var greg as Gregorian.Info; + var is24 as Boolean = true; + var metric as Boolean = true; + var fahrenheit as Boolean = false; + + private var mInfo as ActivityMonitor.Info?; + private var mStats as System.Stats?; + private var mDs as System.DeviceSettings?; + private var mAct as Activity.Info?; + private var mWx as Weather.CurrentConditions?; + private var mFc as Array?; + private var mProf as UserProfile.Profile?; + private var mGotAct = false; + private var mGotWx = false; + private var mGotFc = false; + private var mGotProf = false; + + function initialize(ds as System.DeviceSettings?, tempUnit as Number) { + mDs = ds; + greg = Gregorian.info(Time.now(), Time.FORMAT_SHORT); + if (ds != null) { + is24 = ds.is24Hour; + metric = (ds.distanceUnits != System.UNIT_STATUTE); + fahrenheit = (tempUnit == 2) + || (tempUnit == 0 && ds.temperatureUnits == System.UNIT_STATUTE); + } + if (tempUnit == 1) { fahrenheit = false; } + } + + function ds() as System.DeviceSettings? { return mDs; } + + function info() as ActivityMonitor.Info? { + if (mInfo == null) { mInfo = ActivityMonitor.getInfo(); } + return mInfo; + } + + function stats() as System.Stats? { + if (mStats == null) { mStats = System.getSystemStats(); } + return mStats; + } + + function act() as Activity.Info? { + if (!mGotAct) { mAct = Activity.getActivityInfo(); mGotAct = true; } + return mAct; + } + + function wx() as Weather.CurrentConditions? { + if (!mGotWx) { mWx = Weather.getCurrentConditions(); mGotWx = true; } + return mWx; + } + + function forecast() as Weather.DailyForecast? { + if (!mGotFc) { mFc = Weather.getDailyForecast(); mGotFc = true; } + if (mFc != null && mFc.size() > 0) { return mFc[0]; } + return null; + } + + function profile() as UserProfile.Profile? { + if (!mGotProf) { mProf = UserProfile.getProfile(); mGotProf = true; } + return mProf; + } + + function where() as Position.Location? { + var w = wx(); + if (w != null && w.observationLocationPosition != null) { + return w.observationLocationPosition; + } + var a = act(); + if (a != null && a.currentLocation != null) { return a.currentLocation; } + return null; + } + } + + // ------------------------------------------------------------- helpers + function num(v as Numeric?) as String { + return v == null ? DASH : v.format("%d"); + } + + function one(v as Float?) as String { + return v == null ? DASH : v.format("%.1f"); + } + + function clock(m as Time.Moment?, c as Ctx) as String { + if (m == null) { return DASH; } + return clockInfo(Gregorian.info(m, Time.FORMAT_SHORT), c); + } + + function clockInfo(g as Gregorian.Info, c as Ctx) as String { + var h = g.hour; + if (!c.is24) { + if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; } + } + return h.format("%d") + ":" + g.min.format("%02d"); + } + + function offsetClock(minutes as Number, c as Ctx) as String { + var m = Time.now().add(new Time.Duration(minutes * 60)); + return clock(m, c); + } + + function temp(celsius as Numeric?, c as Ctx) as String { + if (celsius == null) { return DASH; } + var t = celsius.toFloat(); + if (c.fahrenheit) { t = t * 9.0 / 5.0 + 32.0; } + return t.format("%d") + "°"; + } + + // ActivityMonitor distances are centimetres. + function dist(cm as Numeric?, c as Ctx) as String { + if (cm == null) { return DASH; } + var km = cm.toFloat() / 100000.0; + return (c.metric ? km : km * 0.621371).format("%.1f"); + } + + function metres(m as Numeric?, c as Ctx) as String { + if (m == null) { return DASH; } + var v = m.toFloat(); + return (c.metric ? v : v * 3.28084).format("%d"); + } + + function rounded(v as Numeric?) as String { + if (v == null) { return DASH; } + var n = v.toNumber(); + if (n < 1000) { return n.format("%d"); } + return (n / 1000.0).format("%.1f") + "k"; + } + + function setting(name as String, def as Number) as Number { + var v = Application.Properties.getValue(name); + if (v == null) { return def; } + return v as Number; + } + + function settingText(name as String) as String { + var v = Application.Properties.getValue(name); + if (v == null) { return ""; } + return v.toString(); + } + + // Newest reading out of a SensorHistory iterator, or null. + function latest(iter) as Numeric? { + if (iter == null) { return null; } + var s = iter.next(); + if (s == null) { return null; } + return s.data; + } + + 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; + var y = g.year; + var leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); + if (leap && g.month > 2) { d = d + 1; } + return d; + } + + // ISO 8601 week: weeks start Monday, week 1 holds the first Thursday. + 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 + var w = (dayOfYear(g) - iso + 10) / 7; + if (w < 1) { return 52; } + if (w > 52) { return 1; } + return w; + } + + // Days since the new moon of 2000-01-06, folded into one synodic month. + function moonAge(g as Gregorian.Info) as Float { + 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 + g.hour / 24.0; + // Monkey C has no float modulo. + var syn = 29.530588853; + var x = jd - 2451550.1; + var age = x - (x / syn).toNumber() * syn; + if (age < 0) { age = age + syn; } + return age.toFloat(); + } + + function windDir(bearingDeg as Numeric?) as String { + if (bearingDeg == null) { return DASH; } + var pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; + var b = bearingDeg.toFloat(); + while (b < 0) { b = b + 360.0; } + var i = ((b + 11.25) / 22.5).toNumber() % 16; + return pts[i]; + } + + // --------------------------------------------------------------- value + function value(id as Number, c as Ctx) as String { + // ---- date / time + if (id == 860) { return clockInfo(c.greg, c); } + if (id == 880) { + var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT); + return clockInfo(utc, c); + } + if (id == 864) { return offsetClock(setting("AltOffset1", 0), c); } + if (id == 865) { return offsetClock(setting("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 == 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) + " " + c.greg.day.format("%d"); } + if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } + if (id == 858) { return meridiem(c.greg); } + + // ---- activity + 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 (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 (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 (i == null || i.activeMinutesDay == null) { return DASH; } + var am = i.activeMinutesDay; + if (id == 152) { return num(am.moderate); } + if (id == 154) { return num(am.vigorous); } + return num(am.total); + } + if (id == 151) { + if (i == null || 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); } + if (id == 5) { return i == null ? DASH : num(i.floorsDescended); } + if (id == 6) { return i == null ? DASH : metres(i.metersClimbed, c); } + if (id == 9) { return i == null ? DASH : dist(i.pushDistance, c); } + if (id == 10) { return i == null ? DASH : num(i.pushes); } + 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) { return i == null ? DASH : num(i.timeToRecovery); } + if (id == 11) { return i == null ? DASH : num(i.respirationRate); } + if (id == 12) { return i == null ? DASH : num(i.stressScore); } + + // ---- body + if (id == 618) { + var a = c.act(); + if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); } + var h = ActivityMonitor.getHeartRateHistory(1, true); + if (h != null) { + var s = h.next(); + if (s != null && s.heartRate != null + && s.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) { + return num(s.heartRate); + } + } + return DASH; + } + if (id == 276) { + var p = c.profile(); + return (p == null) ? DASH : num(p.restingHeartRate); + } + if (id == 635) { + if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; } + return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1}))); + } + if (id == 623) { + if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; } + return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1}))); + } + if (id == 281 || id == 742) { + var p = c.profile(); + if (p == null || p.weight == null) { return DASH; } + var kg = p.weight.toFloat() / 1000.0; + if (id == 281) { + return (c.metric ? kg : kg * 2.20462).format("%.1f"); + } + if (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) { + var p = c.profile(); + if (p == null) { return DASH; } + return num(id == 278 ? p.vo2maxRunning : p.vo2maxCycling); + } + + // ---- system + var st = c.stats(); + if (id == 250) { + return st == null || st.battery == null ? DASH + : st.battery.format("%d") + "%"; + } + if (id == 251) { + if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) { + return DASH; + } + return st.batteryInDays.format("%d") + "d"; + } + 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 (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) { + return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF"); + } + if (id == 256) { + return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF"); + } + + // ---- environment + if (id == 615) { + var a = c.act(); + return (a == null) ? DASH : metres(a.altitude, c); + } + if (id == 616 || id == 733) { + var a = c.act(); + if (a == null) { return DASH; } + var pa = (id == 616) ? a.meanSeaLevelPressure : a.ambientPressure; + if (pa == null) { return DASH; } + return (pa.toFloat() / 100.0).format("%d"); + } + 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 (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); } + 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) { + 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") + "%"; + } + + // ---- weather + var w = c.wx(); + if (id == 306) { return w == null ? DASH : temp(w.temperature, c); } + if (id == 300) { return w == null ? DASH : temp(w.feelsLikeTemperature, c); } + if (id == 305) { return w == null ? DASH : num(w.relativeHumidity) + "%"; } + if (id == 304) { + if (w == null || !(w has :precipitationChance)) { return DASH; } + return num(w.precipitationChance) + "%"; + } + if (id == 308) { + if (w == null || w.windSpeed == null) { return DASH; } + var kmh = w.windSpeed.toFloat() * 3.6; + return (c.metric ? kmh : kmh * 0.621371).format("%d"); + } + if (id == 307) { return w == null ? DASH : num(w.windBearing); } + if (id == 310) { return w == null ? DASH : windDir(w.windBearing); } + if (id == 309) { + if (w == null || w.observationTime == null) { return DASH; } + return clock(w.observationTime, c); + } + if (id == 301 || id == 302 || id == 639) { + var f = c.forecast(); + if (f == null) { return DASH; } + if (id == 301) { return temp(f.highTemperature, c); } + if (id == 302) { return temp(f.lowTemperature, c); } + return temp(f.highTemperature, c) + "/" + temp(f.lowTemperature, c); + } + + // ---- custom + if (id == 703) { return settingText("Custom1"); } + if (id == 704) { return settingText("Custom2"); } + if (id == 705) { return settingText("Custom3"); } + + return ""; + } +} diff --git a/tools/gen_fields.py b/tools/gen_fields.py new file mode 100644 index 0000000..81f82e8 --- /dev/null +++ b/tools/gen_fields.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""One table -> the field id list, both languages, and the settings 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. + + 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 +""" +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) + +# id, key, English name, Chinese name, on-face label (en), on-face label (cn) +FIELDS = [ + (0, "Off", "Off", "关闭", "", ""), + + # ---- date / time ---------------------------------------------------- + (860, "Time", "Time", "时间", "TIME", "时间"), + (880, "UtcTime", "UTC Time", "UTC 时间", "UTC", "UTC"), + (864, "AltTime1", "Alternate Time 1", "第二时区 1", "TZ1", "时区1"), + (865, "AltTime2", "Alternate Time 2", "第二时区 2", "TZ2", "时区2"), + (851, "DayMonthDay", "Day Month Day", "星期 月 日", "DATE", "日期"), + (872, "DayDate", "Day Date", "星期 日", "DATE", "日期"), + (852, "MonthDay", "Month Day", "月 日", "DATE", "日期"), + (855, "DayOfMonth", "Day of Month", "日", "DAY", "日"), + (870, "DayShort", "Day (Tue)", "星期简写", "DAY", "星期"), + (856, "DayFull", "Full Day (Tuesday)", "星期全称", "DAY", "星期"), + (853, "MonthName", "Month (Jan)", "月份", "MON", "月"), + (868, "MonthNum", "Month (6)", "月份数字", "MON", "月"), + (858, "Meridiem", "AM/PM", "上午/下午", "", ""), + (859, "WeekIso", "Week Number (ISO)", "周数 (ISO)", "WEEK", "周"), + (873, "WeekCal", "Week Number", "周数", "WEEK", "周"), + + # ---- activity ------------------------------------------------------- + (1, "Steps", "Steps", "步数", "STEP", "步数"), + (14, "StepsRound", "Steps Rounded", "步数 (取整)", "STEP", "步数"), + (707, "StepsToGo", "Steps to Go", "剩余步数", "TO GO", "还差"), + (2, "Calories", "Calories", "卡路里", "KCAL", "千卡"), + (603, "ActiveCal", "Active Calories", "活动卡路里", "ACAL", "活动"), + (150, "ActiveMin", "Active Minutes", "活动分钟", "MIN", "分钟"), + (152, "ActiveMod", "Active Min (Moderate)", "中强度分钟", "MOD", "中强度"), + (154, "ActiveVig", "Active Min (Vigorous)", "高强度分钟", "VIG", "高强度"), + (151, "ActiveWeek", "Active Minutes (Week)", "本周活动分钟", "WEEK", "周分钟"), + (3, "Distance", "Distance", "距离", "DST", "距离"), + (4, "FloorsUp", "Floors Climbed", "上楼层数", "FLR", "楼层"), + (5, "FloorsDown", "Floors Descended", "下楼层数", "DESC", "下楼"), + (6, "MetersUp", "Distance Climbed", "爬升高度", "ASC", "爬升"), + (9, "PushDist", "Push Distance", "推行距离", "PUSH", "推行"), + (10, "Pushes", "Pushes", "推行次数", "PUSH", "推行"), + + # ---- body ----------------------------------------------------------- + (618, "HeartRate", "Heart Rate", "心率", "HR", "心率"), + (276, "RestingHr", "Resting Heart Rate", "静息心率", "RHR", "静息"), + (12, "Stress", "Stress Score", "压力指数", "STRS", "压力"), + (635, "PulseOx", "Pulse Ox", "血氧", "SPO2", "血氧"), + (11, "Respiration", "Respiration Rate", "呼吸频率", "RESP", "呼吸"), + (623, "BodyBattery", "Body Battery", "身体电量", "BODY", "体能"), + (8, "MoveBar", "Move Bar", "活动条", "MOVE", "活动"), + (15, "MoveBarRev", "Move Bar Reversed", "活动条 (反向)", "MOVE", "活动"), + (281, "Weight", "Weight", "体重", "WT", "体重"), + (742, "Bmi", "BMI", "BMI", "BMI", "BMI"), + (13, "Recovery", "Time to Recovery", "恢复时间", "RCVR", "恢复"), + (278, "Vo2Run", "VO2 Max (Run)", "最大摄氧量 (跑)", "VO2", "摄氧"), + (277, "Vo2Bike", "VO2 Max (Bike)", "最大摄氧量 (骑)", "VO2", "摄氧"), + + # ---- system --------------------------------------------------------- + (250, "Battery", "Battery", "电量", "BATT", "电量"), + (251, "BatteryDays", "Battery in Days", "剩余天数", "DAYS", "天数"), + (257, "BatteryBoth", "Battery / Days", "电量 / 天数", "BATT", "电量"), + (253, "Alarms", "Alarms", "闹钟", "ALRM", "闹钟"), + (255, "Notifications", "Notifications", "通知", "MSG", "通知"), + (254, "DoNotDisturb", "Do Not Disturb", "勿扰模式", "DND", "勿扰"), + (256, "Bluetooth", "Bluetooth", "蓝牙", "BT", "蓝牙"), + (252, "Solar", "Solar Intensity", "太阳能强度", "SOLR", "太阳能"), + + # ---- environment ---------------------------------------------------- + (615, "Altitude", "Altitude", "海拔", "ALT", "海拔"), + (616, "SeaPressure", "Sea Level Pressure", "海平面气压", "hPa", "气压"), + (733, "AmbPressure", "Ambient Pressure", "环境气压", "hPa", "气压"), + (712, "Sunrise", "Sunrise", "日出", "RISE", "日出"), + (717, "Sunset", "Sunset", "日落", "SET", "日落"), + (700, "NextSun", "Next Sun Event", "下一太阳事件", "SUN", "太阳"), + (731, "UntilSun", "Time Until Sun Event", "距太阳事件", "SUN", "太阳"), + (706, "MoonAge", "Moon Age", "月龄", "MOON", "月龄"), + (732, "MoonLit", "Moon Illumination", "月球照明", "MOON", "月相"), + + # ---- weather -------------------------------------------------------- + (306, "Temp", "Temperature", "气温", "TEMP", "气温"), + (639, "TempHiLo", "Temperature High/Low", "最高/最低气温", "HI/LO", "高低温"), + (300, "FeelsLike", "Feels Like", "体感温度", "FEEL", "体感"), + (301, "TempHigh", "High Temperature", "最高气温", "HIGH", "最高"), + (302, "TempLow", "Low Temperature", "最低气温", "LOW", "最低"), + (305, "Humidity", "Humidity", "湿度", "HUM", "湿度"), + (304, "Precip", "Precipitation %", "降水概率", "RAIN", "降水"), + (308, "WindSpeed", "Wind Speed", "风速", "WIND", "风速"), + (307, "WindBearing", "Wind Bearing", "风向角", "WIND", "风向"), + (310, "WindDir", "Wind Direction", "风向", "WIND", "风向"), + (309, "WeatherTime", "Weather Update Time", "天气更新时间", "WX", "更新"), + + # ---- custom --------------------------------------------------------- + (703, "Custom1", "Custom Text 1", "自定义文字 1", "", ""), + (704, "Custom2", "Custom Text 2", "自定义文字 2", "", ""), + (705, "Custom3", "Custom Text 3", "自定义文字 3", "", ""), +] + +SLOTS = [("LeftTop", "Top-Left Field", "左上数据"), + ("RightTop", "Top-Right Field", "右上数据"), + ("BottomLeft", "Bottom-Left Field", "左下数据"), + ("BottomRight", "Bottom-Right Field", "右下数据")] + +RING = [(0, "Steps", "Steps", "步数"), (1, "Calories", "Calories", "卡路里"), + (2, "Distance", "Distance", "距离"), (3, "Floors", "Floors", "楼层"), + (4, "MoveBar", "Move Bar", "动动条"), (5, "ActiveMin", "Active Minutes", "活动分钟"), + (6, "BodyBattery", "Body Battery", "身体电量")] + +OTHER_SETTINGS = """ + + + @Strings.TempUnitAuto + @Strings.TempUnitC + @Strings.TempUnitF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def field_list(indent=8): + pad = " " * indent + return "\n".join('%s@Strings.Fld%s' + % (pad, fid, key) for fid, key, _, _, _, _ in FIELDS) + + +def write(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + open(path, "w").write(text) + print("wrote", os.path.relpath(path, ROOT)) + + +def main(): + ids = [f[0] for f in FIELDS] + 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.", + "import Toybox.Lang;", "", "module FieldTable {", "", + " // Field ids, in the same order as the Labels resource string.", + " const IDS = ["] + row = " " + for i, fid in enumerate(ids): + piece = "%d%s" % (fid, "," if i < len(ids) - 1 else "") + if len(row) + len(piece) > 92: + L.append(row) + row = " " + row += piece + L += [row, " ];", "", + " function indexOf(id as Number) as Number {", + " for (var i = 0; i < IDS.size(); i++) {", + " if (IDS[i] == id) { return i; }", + " }", + " return 0;", + " }", + "}"] + write(os.path.join(ROOT, "source", "FieldTable.mc"), "\n".join(L) + "\n") + + # ---- settings.xml ---- + S = ['', '', + ' ', + ' '] + names = ["Ember", "Aurora", "Brass", "Voltage", "Reef", "Acid", "Kelp"] + for i, n in enumerate(names): + S.append(' @Strings.Theme%s' % (i + 1, n)) + S += [' ', ' ', ''] + for slot, _, _ in SLOTS: + S += [' ' % (slot, slot), + ' ', field_list(12), + ' ', ' ', ''] + for ring in ("RingTR", "RingTL", "RingBR", "RingBL"): + S += [' ' % (ring, ring), + ' '] + for rid, key, _, _ in RING: + S.append(' @Strings.Metric%s' % (rid, key)) + S += [' ', ' ', ''] + S.append(OTHER_SETTINGS.rstrip()) + S.append('') + write(os.path.join(ROOT, "resources", "settings", "settings.xml"), "\n".join(S) + "\n") + + # ---- field strings, both languages ---- + for lang, folder, ni, li in (("en", "resources", 2, 4), ("chn", "resources-chn", 3, 5)): + X = [''] + X.append(' ') + for f in FIELDS: + X.append(' %s' % (f[1], f[ni])) + X.append('') + X.append(' ') + X.append(' %s' + % ",".join(f[li] for f in FIELDS)) + X.append('') + for rid, key, en, cn in RING: + X.append(' %s' % (key, en if lang == "en" else cn)) + X.append('') + write(os.path.join(ROOT, folder, "strings", "fields.xml"), "\n".join(X) + "\n") + + print("%d fields, %d slots" % (len(FIELDS), len(SLOTS))) + + +if __name__ == "__main__": + main() diff --git a/tools/gen_icons.py b/tools/gen_icons.py new file mode 100644 index 0000000..69e5ce5 --- /dev/null +++ b/tools/gen_icons.py @@ -0,0 +1,199 @@ +#!/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. + + python3 tools/gen_icons.py + +Writes resources-icons-/drawables/{sunrise,sunset,weather}.png plus the +matching drawables.xml. White pixels, alpha coverage; the theme's accent +colour is applied at draw time via :tintColor. + +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. +""" +import os +import re + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +SRC = os.path.join(ROOT, "design", "watchface-01-ember.svg") + +try: + from PIL import Image +except ImportError: + raise SystemExit("this generator needs Pillow: python3 -m pip install pillow") + +# The three icons the comps actually contain, and the design-space centre each +# one is drawn around. A common 32x32 design box keeps blitting trivial. +BOX = 32.0 +ICONS = { + "sunrise": ("sunrise-icon", 156.32, 89.5, False), + "sunset": ("sunrise-icon", 156.32, 89.5, True), # same art, chevron flipped + "weather": ("weather-icon", 343.55, 89.5, False), +} + +# screen width -> whether the panel can blend alpha (MIP cannot) +WIDTHS = {240: False, 260: False, 280: False, 390: True, 416: True, 454: True} + +SS = 8 # supersampling factor + + +def subpaths(svg, pid): + d = re.search(r'= 3: + out.append(pts) + return out + + +def flip_chevron(paths): + """Mirror the horizon bar so its notch points up -- our sunset variant.""" + out = [] + for p in paths: + ys = [q[1] for q in p] + if min(ys) > 96.0: # the bar is the lowest subpath + mid = (min(ys) + max(ys)) / 2.0 + p = [(x, 2 * mid - y) for x, y in p] + out.append(p) + return out + + +def scanline_evenodd(paths, size, ox, oy, scale): + """Even-odd fill at supersampled resolution, returned as a coverage mask.""" + n = size * SS + cov = [bytearray(n) for _ in range(n)] + edges = [] + for p in paths: + for i in range(len(p)): + x0, y0 = p[i] + x1, y1 = p[(i + 1) % len(p)] + X0 = (x0 - ox) * scale * SS + Y0 = (y0 - oy) * scale * SS + X1 = (x1 - ox) * scale * SS + Y1 = (y1 - oy) * scale * SS + if Y0 != Y1: + edges.append((Y0, Y1, X0, X1)) + for row in range(n): + yc = row + 0.5 + xs = [] + for Y0, Y1, X0, X1 in edges: + if (Y0 <= yc < Y1) or (Y1 <= yc < Y0): + xs.append(X0 + (yc - Y0) * (X1 - X0) / (Y1 - Y0)) + if not xs: + continue + xs.sort() + line = cov[row] + for i in range(0, len(xs) - 1, 2): + a = max(0, int(round(xs[i]))) + b = min(n, int(round(xs[i + 1]))) + for x in range(a, b): + line[x] = 255 + return cov + + +def render(paths, size, cx, cy, blend): + scale = size / BOX + ox, oy = cx - BOX / 2, cy - BOX / 2 + cov = scanline_evenodd(paths, size, ox, oy, scale) + n = size * SS + big = Image.frombytes("L", (n, n), b"".join(bytes(r) for r in cov)) + small = big.resize((size, size), Image.LANCZOS) + if not blend: + small = small.point(lambda v: 255 if v >= 110 else 0) + white = Image.new("RGB", (size, size), (255, 255, 255)) + img = white.convert("RGBA") + img.putalpha(small) + return img + + +# Launcher icon sizes the target devices ask for. +LAUNCHER = [40, 56, 60, 65, 70] + + +def launcher(size): + """A miniature of the face: the gradient ring plus the date band.""" + from PIL import ImageDraw + ss = 8 + n = size * ss + img = Image.new("RGBA", (n, n), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.ellipse([0, 0, n - 1, n - 1], fill=(0, 0, 0, 255)) + svg = open(SRC).read() + top = re.findall(r'fill="(#[0-9A-Fa-f]{6})"', + re.search(r'(.*?)', svg, re.S).group(1)) + bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"', + re.search(r'(.*?)', svg, re.S).group(1)) + ro, ri = n * 0.495, n * 0.375 + box_o = [n / 2 - ro, n / 2 - ro, n / 2 + ro, n / 2 + ro] + # sweep the whole rim, mirroring the comp's two ramps into four quadrants + for q, ramp in ((0, top), (1, top), (2, bot), (3, bot)): + for i, c in enumerate(ramp): + col = tuple(int(c[1 + 2 * j:3 + 2 * j], 16) for j in range(3)) + (255,) + span = 90.0 / len(ramp) + if q == 0: + a0 = -90 + i * span + elif q == 1: + a0 = -90 - (i + 1) * span + elif q == 2: + a0 = 90 - (i + 1) * span + else: + a0 = 90 + i * span + d.pieslice(box_o, a0, a0 + span, fill=col) + d.ellipse([n / 2 - ri, n / 2 - ri, n / 2 + ri, n / 2 + ri], fill=(0, 0, 0, 255)) + band = tuple(int("FEAA00"[2 * j:2 * j + 2], 16) for j in range(3)) + (255,) + d.rectangle([0, n * 0.44, n, n * 0.60], fill=band) + mask = Image.new("L", (n, n), 0) + ImageDraw.Draw(mask).ellipse([0, 0, n - 1, n - 1], fill=255) + img.putalpha(mask) + return img.resize((size, size), Image.LANCZOS) + + +def main(): + svg = open(SRC).read() + for size in LAUNCHER: + outdir = os.path.join(ROOT, "resources-launcher-%d" % size, "drawables") + os.makedirs(outdir, exist_ok=True) + launcher(size).save(os.path.join(outdir, "launcher_icon.png")) + with open(os.path.join(outdir, "drawables.xml"), "w") as f: + f.write('\n' + ' \n' + '\n') + print("wrote %s (%dx%d)" % (outdir, size, size)) + cache = {} + for width, blend in sorted(WIDTHS.items()): + size = int(round(BOX * width / 500.0)) + outdir = os.path.join(ROOT, "resources-icons-%d" % width, "drawables") + os.makedirs(outdir, exist_ok=True) + names = [] + for name, (pid, cx, cy, flip) in ICONS.items(): + key = (pid, flip) + if key not in cache: + p = subpaths(svg, pid) + cache[key] = flip_chevron(p) if flip else p + img = render(cache[key], size, cx, cy, blend) + img.save(os.path.join(outdir, "%s.png" % name)) + names.append(name) + with open(os.path.join(outdir, "drawables.xml"), "w") as f: + f.write("\n") + for n in names: + # packingFormat="png" keeps the alpha channel instead of + # collapsing the image to a palette; drawBitmap2(:tintColor) + # refuses a palettised source. + f.write(' \n' % (n.capitalize(), n)) + f.write("\n") + print("wrote %s (%dx%d, %s edges)" + % (outdir, size, size, "soft" if blend else "hard")) + + +if __name__ == "__main__": + main()