Compare commits

...

9 Commits

Author SHA1 Message Date
00bce1b6ab 新增 tools/install.sh:一条命令编译并侧载到手表
编译 release 版(enduro3 上 49.6 KB,debug 版是 168 KB),自动找已挂载的
GARMIN 卷复制到 GARMIN/APPS/。找不到卷时打印 .prg 路径和排查提示 —— macOS
上 fenix 7 之后的机型多数走 MTP,Finder 根本看不到这个盘,得用 OpenMTP 之类
的工具,这一点脚本里和 README 里都写了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:16:01 +08:00
230180febf 新增 docs/:商店文案、图标与各机型截图
Connect IQ Store 后台要填的素材都放进 docs/,代码不依赖其中任何文件。

截图
  八个屏宽各一张(218/240/260/280/360/390/416/454),外加七套主题各一张。
  全部是模拟器的真实渲染 —— 字体是 Garmin 内置的 Roboto / Bionic,和真机一致。
  preview-themes.svg 用的是网页字体,不能拿去当商店截图,docs/README.md 里
  特别提了这点。

  ⚠️ 进度环是摆拍的。模拟器的 Edit Activity Monitor Info 只能改活动分钟、
  恢复时间、呼吸和压力,改不了步数/距离/卡路里(那几个输入框是 Java 自绘的
  表格,AppleScript 也够不着),所以环在模拟器里永远全灰。截图构建把四段弧
  固定成了设计稿的填充状态。除环之外画面上每个数字都是模拟器的真实取值。

  数据位用的是日出/高低温/心率/身体电量,不是默认的日出/高低温/距离/步数 ——
  后两项在模拟器里是 0,截出来底部是 "0.0 0"。这些都写进 docs/README.md 了。

文案
  中英文各一份,含名称、简介、详细描述、关键词、以及要向用户解释的四项权限。
  详细描述按「进度环 / 四个数据位 / 七套主题 / 常亮 / OWM 选用 / 其它」组织,
  156 项字段按分类列出。

图标
  store-icon-1024.png 与 512.png,与启动图标同一套视觉(渐变环 + 橙色日期带),
  由 tools/gen_icons.py 的 launcher() 渲染。

docs/README.md 还记了后台要填但不在仓库里的项(分类、价格、隐私政策 —— 启用
OWM 时会把坐标发给 api.openweathermap.org,这点建议在描述里写明),以及发布
前必须先解决的设计稿授权问题。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:21:22 +08:00
696b728c78 收紧常亮模式:去掉进度环,时间日期压暗到 38%
按要求把常亮再砍一档。常亮下现在不画:整条日期带底色、整个进度环、
四个数据位与电池;保留的时间、冒号、日期由 55% 压暗到 38%。

实测(fenix847mm,取表盘内区):

                  点亮像素数    平均亮度(功耗代理)
    普通模式         27.6%          14.1%
    常亮(改前)      10.4%           3.0%
    常亮(改后)       9.7%           2.5%

两个口径的差别值得记一笔:「点亮像素数」把每个抗锯齿边缘像素都算成亮的,
对大号数字表盘天然不友好 —— 时间数字压到 38% 后白色仍有 ~97 亮度,照样计入。
AMOLED 的实际功耗更接近各子像素亮度之和,也就是「平均亮度」那一列。
两个口径现在都留了余量,38% 下时间和日期在模拟器里依然清晰可读。

LICENSE、UserProfile 的 try 保护、CHANGELOG 与版本号 1.1.0 已在上一个提交
22340b0 完成,本次只动常亮相关部分与对应文档。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:37:58 +08:00
22340b09fa 机型覆盖 17 → 55 款,补 LICENSE 与 CHANGELOG
机型
  新增 38 款圆屏 + CIQ ≥ 4.2 的机型:fenix 7 Pro 全系、Venu 2/3S、
  Approach S50/S70、Descent G2/Mk3、D2 系列、Instinct 3 AMOLED、
  FR255S/265S/570/955 等。其中 34 款的屏宽已有现成图标资源。

  机型列表和 monkey.jungle 改由 tools/gen_devices.py 扫描本机 SDK 生成,
  筛选条件是「支持 watchFace + 圆屏 + CIQ ≥ 4.2」(Complications 要 4.2)。
  55 条资源路径不再手写。

  新增 218px 与 360px 两档图标。218px 上图标只有 14×14,设计稿里太阳与云
  之间那道 1.7 设计像素的暗缝连一个物理像素都占不到,光栅化出来是一坨没有
  结构的橙块 —— 该尺寸改用按目标像素直接摆的简化标记。

常亮模式:结论是不改
  为了压所谓的 10% 点亮率,一度把进度环也砍掉了。换成平均亮度(AMOLED 功耗
  的合理代理)重新量之后发现:普通模式 14.1%,常亮 2.9%,本来就只有 1/5;
  砍掉环只从 3.01% 降到 2.91%。0.1 个百分点换掉一个可见特征不划算,环留着。
  两种度量的差异和这个决定都写进了代码注释和 README。

其它
  - LICENSE(MIT)。只覆盖代码 —— design/ 下的设计稿版权归第三方,
    发布前需取得原作者授权或替换视觉,文件内已注明。
  - UserProfile.getProfile() 加异常保护(这个 API 家族已经坑过三次)
  - CHANGELOG.md,版本号 1.0.0 → 1.1.0

验证:55 款全部编译通过(分三批 18+18+19);.iq 上架包可正常导出。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:27:54 +08:00
d9d45d983b 接入系统 Complication,补齐倒计时/自定义格式/28天累计/空气质量
字段 125 → 156 项。

Toybox.Complications(source/Comp.mc)
  这条路推翻了我之前几个「表盘做不到」的判断 —— 当时只查了 ActivityMonitor /
  UserProfile / Weather,漏了系统把这些作为 complication 发布出来:
    - 下一个日程(CIQ 确实没有日历 API,但有 CALENDAR_EVENTS complication)
    - 训练状态、完赛预测 ×4、配速预测 ×4、最近高尔夫得分
    - 本周跑步 / 骑行距离(按运动分类的,按天历史做不出来)
    - 第三方 App 1–5(CGM、补水、行情这类 App 发布的)
  需要 ComplicationSubscriber 权限。

其余补齐
  - 近 28 天累计:步数 / 卡路里 / 距离 / 楼层
  - 事件倒计时:设置里填 YYYY-MM-DD,显示还剩几天
  - 自定义时间格式、日期格式 1/2:单字母占位符(h/H/m/s/a/D/M/N/Y/y/w)
  - OWM 空气质量:AQI 等级 + 描述 + PM2.5 + PM10 + CO,走
    /data/2.5/air_pollution —— 那个接口在免费档就开放,不需要 One Call 订阅

运行时踩的坑
  - Complication.unit 不保证是 String(周跑步距离返回的就不是),直接调
    .length() 抛 Symbol Not Found。unit 和 shortLabel 都改成先 instanceof 判型。
  - 浮点 value 直接 toString 印成 "0.000000",统一格式化成一位小数。
  - 第三方 App 槽位每取一次就要走完整个迭代器,四个格子都选 App 时会走四遍。
    改成每帧只枚举一次,由 view 在绘制开始时调 Comp.beginFrame() 失效。

测试
  全字段验证的 harness 本身会触发看门狗(一次 onUpdate 里算 156 个字段),
  改成分批每帧 12 个。实际使用一帧只算 4 个,不受影响。
  156 项全部可渲染,零运行时错误;17 款设备全部编译通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:03:21 +08:00
4d96ab0912 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
2026-09-10 07:10:55 +08:00
52346bdd09 Split the face into modules, comment it, and verify every field
Architecture
  Fenix8V3View had grown to 632 lines mixing layout constants, font
  metrics, icon drawing, data lookup and orchestration.  Split into:

    Layout    the 500-canvas constants, scaling, font choice and text drawing
    Icons     the three bitmap icons plus the primitive glyphs
    Settings  every stored preference and its default, in one place
    Fields    unchanged in scope, but value() is now seven category
              functions that each return null for ids they do not own --
              the ids interleave, so dispatching on ranges does not work
    View      what is left: the drawing order

  The view is down to 359 lines and no longer reaches for a property or a
  font metric directly.

Verification
  Added a harness that walks all 75 ids in the simulator and reports each
  rendered value with its pixel width.  It caught a real one: pushDistance
  and pushes compile on every device but throw "Symbol Not Found" at
  runtime on a fenix 8 -- they are wheelchair counters that only some
  firmware carries.  Every optional ActivityMonitor, UserProfile, Weather
  and Activity member is now behind a `has` check.

  The same run showed two date fields overflowing their slot, so long
  values now step down a font size rather than running into the ring.

  Always-on mode checked against the simulator's Always-On display mode:
  band fill gone, unlit ticks gone, slots hidden, time and date dimmed.

Also: Chinese "Day Date" was missing its 日, since it built the string by
hand instead of going through the localised date format.

All 17 devices build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:48:58 +08:00
d457f8244e Add the on-device data field catalogue, always-on mode, and real icons
Fields
  75 selectable fields across date/time, activity, body, system,
  environment, weather and custom text, numbered to match
  watchface.io/docs/datafields so the two catalogues line up.  All four
  slots (both top, both bottom) pick from the same table and the bottom
  captions follow the selection.  tools/gen_fields.py owns the table and
  emits FieldTable.mc, settings.xml and both languages' strings, so those
  cannot drift apart.

  Fields.Ctx fetches each API at most once per draw, and only if some slot
  asks -- the forecast, user profile and sensor history lookups are lazy.

  The rest of that catalogue is deliberately absent: OpenWeatherMap and
  StormGlass need network calls and user API keys, the per-sport weekly and
  28-day aggregates come from a phone-side Garmin Connect integration rather
  than any on-device API, and the app complications need those apps
  installed.  README section 7 lists what was left out and why.

Permissions
  UserProfile and SensorHistory are now declared; without them resting heart
  rate, VO2 max, pulse ox and body battery throw at runtime.  Sensor turns
  out to be rejected outright for type="watchface", so altitude and pressure
  read from Activity.Info instead.

Always-on display
  AMOLED devices need the lit-pixel count cut while asleep.  drawAmbient
  drops the band fill and the unlit ticks, dims what remains, hides the
  complications, and walks the layout within +/-4 px per minute so nothing
  burns in.  Gated on requiresBurnInProtection, so MIP devices are untouched.

Icons
  The comps' sunrise and weather icons have two- and three-pixel features.
  Rebuilding them from circles and lines looked right at 454 px and turned to
  mush at 280.  They are now rasterised from the design paths once per screen
  width and tinted with drawBitmap2(:tintColor) -- which needs
  packingFormat="png", since a palettised source is refused at runtime.
  Launcher icons are generated at the five sizes the devices ask for.

All 17 devices build; checked on fenix847mm and enduro3 in the simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:53:18 +08:00
398018b51e Rebuild the face against the design kit; make fields configurable
Vendor the watchface-kit into design/ and derive everything from it instead
of from hand-transcribed constants.

Geometry, from design/rebuild.py and the ink boxes of the traced SVG paths:
  - ring ticks are annular sectors (fillPolygon), not radial lines
  - top arc is 21 ticks at 6.5+4k, not 22 at 2+4k
  - exact radii, tick widths, anchor dots, band and colon rectangles
  - element positions taken from measured ink bounding boxes

Colour, generated by tools/gen_themes.py straight out of the comps:
  - per-tick colour tables (21+21) replace 9/7 interpolated stops
  - 11-stop vertical gradients on the time digits, drawn with clip banding
  - per-element accent / value colours and per-theme unlit tick colour

Data: the design's sunrise and hi/lo fields turn out to be available after
all -- Weather.getSunrise and getDailyForecast both exist in SDK 9.1.0 --
so both are restored, and heart rate is added alongside them.

Bugs found on the way:
  - Gregorian FORMAT_MEDIUM returns day_of_week/month as strings, so the
    date band crashed on every update (caught in the simulator)
  - Stats.battery is already 0-100, multiplying by 100 gave "4000%"
  - moveBarLevel is an integer level 0..5, not a 0..1 ratio, so the move
    bar arc was all-or-nothing

Also: all four field slots are configurable with labels that follow the
selection, temperature unit is a setting, on-face wording moved into
resources with a Simplified Chinese variant, and 24h/distance/temperature
now follow the watch. Fonts are chosen by measured cap height rather than
by ascent.

Verified in the Connect IQ simulator on fenix847mm and by compiling all
17 target devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:04:27 +08:00
110 changed files with 7678 additions and 1112 deletions

34
CHANGELOG.md Normal file
View File

@@ -0,0 +1,34 @@
# 更新记录
## 1.1.0
**机型覆盖 17 → 55 款**
新增 38 款圆屏、Connect IQ ≥ 4.2 的机型,包括 fenix 7 Pro 全系、Venu 2/3S、
Approach S50/S70、Descent G2/Mk3、D2 系列、Instinct 3 AMOLED、FR255S/265S/570/955 等。
机型列表与资源路径改由 `tools/gen_devices.py` 扫描本机 SDK 生成,不再手写。
新增两档图标尺寸218px、360px。218px 上设计稿的天气图标只有 14×14
太阳与云之间的暗缝不足一个像素,会糊成一块 —— 该尺寸改用专画的简化标记。
**常亮模式收紧**
砍掉整个进度环,时间/日期由 55% 压暗到 38%。实测点亮像素数 10.4% → 9.7%
平均亮度 3.0% → 2.5%(普通模式分别是 27.6% / 14.1%)。
**其它**
- 补 LICENSEMIT仅覆盖代码`design/` 下的设计稿版权归第三方,见文件内说明)
- `UserProfile.getProfile()` 加异常保护
- 版本号 1.0.0 → 1.1.0
## 1.0.0
`watchface-kit` 设计稿 1:1 还原的多指标数据表盘。
- 156 项可选数据字段,编号对齐 watchface.io 目录
- 七套主题,配色逐根刻度实测自设计稿
- 常亮AOD模式含防烧屏位移
- 简体中文 / 英文双语,表盘文字亦本地化
- OpenWeatherMap 接入(用户自备 API key后台定时拉取
- 系统 Complication 接入(日历、训练状态、完赛预测、第三方 App

29
LICENSE Normal file
View File

@@ -0,0 +1,29 @@
MIT License
Copyright (c) 2026 ericwyuan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---------------------------------------------------------------------------
注意:本许可只覆盖**本仓库的代码**。
design/ 目录下的设计稿watchface-*.svg、watchface-tokens.json、rebuild.py
来自第三方 watchface-kit版权归其原作者所有不在本许可范围内。
表盘的视觉呈现是对那套设计的 1:1 还原 —— 若要发布到 Connect IQ Store 或以
其它形式分发,需先取得原作者授权,或将视觉部分替换为原创设计。

515
README.md
View File

@@ -1,168 +1,411 @@
# Fenix 8 V3 — Garmin 数据表盘 # Fenix 8 V3 — Garmin 数据表盘
一款基于 Connect IQ 设计稿(`watchface-kit`1:1 还原的多指标数据表盘。设计画布 **500×500** 正方形,渲染时按屏幕宽度等比缩放,支持圆形 AMOLEDFenix 8 / Enduro 3 / Epix 2与圆屏 MIPFenix 7 / FR965 等)设备 `watchface-kit` 设计稿 1:1 还原的多指标数据表盘。设计画布 **500×500**,渲染时按屏等比缩放 `s = screenWidth / 500`,支持圆形 AMOLED 与圆屏 MIP 共 **55 款**设备Fenix 7/8 全系含 Pro、Epix 2、Enduro 3、Venu 2/3、FR 系列、Approach S50/S70、Descent、D2、Instinct 3 AMOLED 等)。
- **SDK**Connect IQ SDK 9.1.0`monkeyc` 9.1.0API level `minSdkVersion="4.2.0"` - **SDK**Connect IQ SDK 9.1.0`monkeyc` 9.1.0`minSdkVersion="4.2.0"`
- **语言**Monkey C - **语言**Monkey C;界面文案含英文与简体中文
- **类型**`watchface` - **类型**`watchface`
- **应用 ID**`f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b` - **应用 ID**`f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b`
--- 设计稿本体7 张 SVG + tokens + 逆向脚本)在 `design/`,是本仓库所有坐标与配色的唯一来源。
## 1. 布局还原(与设计稿基准)
所有坐标以 500×500 设计画布为基准,渲染时统一乘以缩放系数 `s = screenWidth / 500.0`Enduro 3 屏宽 416px → `s≈0.832`)。下表中 `y` 为设计稿原始像素值。
| 区域 | 设计坐标500 基准) | 内容 | 说明 |
|------|----------------------|------|------|
| 进度环 | 外半径 `rOut=249.5`,内半径 `rIn=231.5` | 四段进度弧 + 4 个锚点圆点 | 见 §2 |
| 电量 | `y=53.5`,电池框居中偏左 | 电池图标 + 百分比 | `System.getSystemStats().battery` |
| 上方双复杂功能 | 图标 `y=90`,数值 `y=130.5`;左侧 `x=156`,右侧 `x=343` | 天气温度 / 卡路里 / 步数 / 距离 / 楼层 / 电量 | 见 §4 |
| 中央时间 | `y=220`;小时 `x=211`(右对齐),分钟 `x=272`(左对齐) | `HH : MM`12 小时制) | 冒号用两块矩形块,位于 `x=238` |
| 日期带 | 矩形 `y=286.5`,高 `44` | 蓝牙图标 + 星期 + 月日 + AM/PM | 蓝牙图标 `x=36.5`,星期 `x=177.5`,月日 `x=297.5` |
| 底部指标 | 数值 `y=366`,标签 `y=410.5` | 左侧距离(`DST`),右侧步数(`STEP` | 距离单位 km |
进度环几何(取自设计稿 `rebuild.py`
- **顶弧**22 根刻度,角度 `TOP = [2,6,…,86]`(步距 4线宽 `13`,渐变停止点 9 个(`8→86`)。
- **底弧**21 根刻度,角度 `BOT = [6,9,…,66]`(步距 3线宽 `9`,渐变停止点 7 个(`7→47`)。
- **锚点圆点**:位于角度 `[0.0, 93.3, 180.0, 266.7]`,圆心半径 `240.5`,圆点半径 `9.5`
- 刻度“点亮”数量按对应指标进度换算:`litTR/TL = progress × 22``litBR/BL = progress × 21`
时间字体为 `FONT_NUMBER_HOT`(大号数字),其余为 `FONT_MEDIUM` / `FONT_SMALL`
--- ---
## 2. 七套主题Themes ## 1. 目录结构
颜色数据由 `watchface-tokens.json``gen_themes.py` 生成到 `source/Themes.mc`**请勿手改**,改 tokens 后重新生成)。每套主题包含:顶/底弧渐变停止点、未点亮刻度色 `tickOff`、锚点色 `anchorDot`、日期带填充 `bandFill` / 文字 `bandText`、强调色 `accent`、主文字 `textPrimary`、冒号色 `colon`、小时 `hours`、分钟 `minutes` ```
fenix8v3-watchface/
├── manifest.xml # 应用清单ID / 支持设备 / 语言)
├── monkey.jungle # 构建配置
├── design/ # ⭐ 设计稿原件(唯一事实来源)
│ ├── watchface-0N-*.svg # 7 套主题的矢量还原稿
│ ├── watchface-tokens.json # 配色 token
│ └── rebuild.py # 原作者从位图逆向出 SVG 的脚本(仅存档,不参与构建)
├── tools/
│ ├── gen_themes.py # design/*.svg -> source/Themes.mc
│ ├── gen_fields.py # 字段表 -> FieldTable.mc + settings.xml + 两种语言文案
│ ├── gen_devices.py # 扫描 SDK -> manifest 机型列表 + monkey.jungle
│ ├── gen_icons.py # design/*.svg -> 各尺寸图标位图 + 启动图标
│ └── gen_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html
├── source/
│ ├── Fenix8V3App.mc # 应用入口12 行壳子)
│ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式
│ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制
│ ├── Icons.mc # 图标:位图三件套 + 其余图元
│ ├── Fields.mc # 125 项数据字段的取值与格式化
│ ├── Solar.mc # 日出/晨昏蒙影/黄金蓝调时刻的太阳高度角计算
│ ├── Owm.mc # OpenWeatherMap 缓存读取
│ ├── Comp.mc # 系统 / 第三方 Complication 桥接
│ ├── Fenix8V3Background.mc # 后台服务:拉取 OWM
│ ├── Settings.mc # 所有设置项的读取与默认值
│ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表
│ └── Themes.mc # ⚠️ 自动生成:七套主题配色
├── resources/ # 英文文案 + 设置项定义(部分自动生成)
├── resources-chn/ # 简体中文文案
├── resources-icons-<宽度>/ # ⚠️ 自动生成:按屏幕尺寸光栅化的图标
├── resources-launcher-<尺寸>/ # ⚠️ 自动生成:各尺寸启动图标
├── docs/ # 上架素材:商店文案、图标、各机型截图
└── bin/ # 编译产物(已 gitignore
```
| # | 主题 | 主色调 | 分钟数字 | 日期带 | 重新生成全部派生文件:
|---|------|--------|----------|--------|
| 1 | Ember | 橙红渐变(黄→红) | `0xFCA901` | 橙底黑字 |
| 2 | Aurora | 青绿渐变(黄绿→青) | `0xAAF701` | 深灰底白字 |
| 3 | Brass | 金铜渐变 | `0xECCC93` | 深棕底白字 |
| 4 | Voltage | 蓝→黄渐变 | `0x52A8FD` | 蓝底黑字 |
| 5 | Reef | 橙→青渐变 | `0xFC7801` | 青底黑字 |
| 6 | Acid | 黄绿(近单色) | `0xC4F900` | 深灰底黑字 |
| 7 | Kelp | 橙→绿渐变 | `0xF4880A` | 深灰底黑字 |
---
## 3. 可配置设置Garmin Connect / 表盘设置)
通过 `resources/settings/` 暴露,键名与默认值见 `properties.xml`
| 设置 | 键 | 可选值 | 默认 |
|------|----|--------|------|
| 主题 | `Theme` | 17Ember…Kelp | 1 |
| 顶右弧指标 | `RingTR` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 |
| 顶左弧指标 | `RingTL` | 同上 | 1 |
| 底右弧指标 | `RingBR` | 同上 | 4 |
| 底左弧指标 | `RingBL` | 同上 | 3 |
| 左上复杂功能 | `LeftTop` | 0 天气 / 1 卡路里 / 2 步数 / 3 距离 / 4 楼层 / 5 电量 | 1 |
| 右上复杂功能 | `RightTop` | 同上 | 0 |
| 显示顶行 | `ShowTop` | 布尔 | true |
| 显示日期带 | `ShowBand` | 布尔 | true |
| 显示底行 | `ShowBottom` | 布尔 | true |
> 指标默认值对应设计稿:**顶右=步数、顶左=卡路里、底右=动动条、底左=楼层**;左上=卡路里、右上=天气。
### 指标进度换算规则(`metricProgress`
| idx | 指标 | 进度分母 |
|-----|------|----------|
| 0 | 步数 Steps | `stepGoal`(目标步数) |
| 1 | 卡路里 Calories | 固定 2000 kcal |
| 2 | 距离 Distance | 10 km1,000,000 cm |
| 3 | 楼层 Floors | `floorsClimbedGoal`(默认 10 |
| 4 | 动动条 Move Bar | `moveBarLevel`(归一化 01 |
进度超出 1 会被钳制,弧长即“点亮”刻度数。
---
## 4. 数据源与已知偏差
| 元素 | 数据来源 | 备注 |
|------|----------|------|
| 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` | SDK 9.1.0 字段:`steps/stepGoal/calories/distance/floorsClimbed/floorsClimbedGoal/moveBarLevel` |
| 天气温度 | `Weather.getCurrentConditions().temperature` | 需手表已配对天气 |
| 电量 | `System.getSystemStats().battery` | 01 浮点×100 显示 |
| 日期 / 时间 | `Time.Gregorian` | 12 小时制,带 AM/PM |
| 蓝牙图标 | `System.getDeviceSettings().phoneConnected`(仅取状态) | 见下方偏差 |
### ⚠️ 与设计稿的关键偏差
1. **“日出sunrise”元素 → 实时天气温度。** 设计稿顶部复杂功能中有一个日出图标及时间,但 `Weather.CurrentConditions` 在 SDK 9.1.0 **不暴露 sunrise 字段**。因此该位置改为显示**实时天气温度**`NN°`),图标保留太阳造型。
2. **进度环为实时数据**,非设计稿中的静态填充演示值;四段弧指标可在设置中自由分配。
3. **蓝牙图标**目前按日期带文字色常显(未根据 `phoneConnected` 切换亮/暗),如需“未连接变暗”可后续在 `drawDateBand` 中据 `ds.phoneConnected` 调色。
---
## 5. 编译 / 构建
需安装 Connect IQ SDK本文基于 9.1.0)并把 `monkeyc` 加入 PATH且准备开发者密钥 `developer_key.der`(已在仓库内)。
```bash ```bash
# 单设备Enduro 3 主产物) python3 tools/gen_devices.py \
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 && python3 tools/gen_themes.py && python3 tools/gen_fields.py \
&& python3 tools/gen_icons.py && python3 tools/gen_preview_svg.py
```
# 导出调试资源(含 -settings.json便于模拟器载入设置 `gen_devices.py` 要先跑 —— 它决定了需要哪些图标尺寸,`gen_icons.py`
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 -x `WIDTHS` / `LAUNCHER` 两张表必须跟它对齐,少一档就会有机型编译不过。
# 多设备批量(已验证可编译) 三份渲染实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**`Themes.mc`。预览脚本直接解析它,不会与固件漂移。字段表、设置项、两种语言的文案同样只有 `tools/gen_fields.py` 里那一张表。
for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm \
fenix8solar51mm epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x \ ---
fenix7s fr965 fr265 fr255 venu3 vivoactive5; do
## 1.5 代码结构
一句话:**位置归 `Layout`,内容归 `Fields`,配色归 `Themes`,设置归 `Settings``Fenix8V3View` 只剩画的顺序。**
| 模块 | 负责 | 不负责 |
|------|------|--------|
| `Layout` | 500 基准画布的全部坐标常量、`px()` 缩放、极坐标换算、字号选择与自动缩字、渐变文字的分带绘制 | 具体画什么 |
| `Icons` | 三个位图图标的加载与染色、电池、蓝牙、其余数据项的图元 | 图标该出现在哪个数据位(由字段 id 决定,但查表在这里) |
| `Fields` | 75 项字段的取值、单位换算、本地化文案(星期/月份/标签) | 排版 |
| `Settings` | 每个设置项的读取与默认值,包含空值回退 | 设置项的 UI 定义(在 `settings.xml` |
| `Fenix8V3View` | `onUpdate` 的绘制顺序、进度环、常亮模式 | 以上任何一项的细节 |
`Fields.value()` 按分类拆成 7 个函数(`dateTime` / `activity` / `body` / `system` / `environment` / `weather` / `custom`),每个在不认识 id 时返回 `null``value()` 依次尝试。id 是交错的1、2、3 是活动8、11、12 是身体14 又是活动),所以按区间派发行不通。
`Fields.Ctx` 每次绘制构造一个,把 `ActivityMonitor` / `Weather` / `UserProfile` / `SensorHistory` 的查询**懒加载并缓存**——四个数据位可能都要天气,但只查一次;没有数据位用到 `UserProfile` 时就完全不查。
## 2. 布局500 基准,取自设计稿 SVG 路径的墨迹包围盒)
| 元素 | 位置 | 说明 |
|------|------|------|
| 进度环 | 内半径 `231.09`,外半径 `249.10` | 见 §3 |
| 电量图标 | `x=192.70``cy=53.20``26.20×17.81` | 圆角外框 + 按电量填充 + 右侧触点 |
| 电量数值 | 左对齐 `x=232.00` | `NN%` |
| 上方双数据 | 图标 `cy=90`,数值 `cy=131.06`;左 `x=157.00`,右 `x=343.70` | 居中对齐 |
| 时间 | `cy=221.10`;小时右对齐 `x=211.40`,分钟左对齐 `x=276.02` | 竖向渐变,见 §4 |
| 冒号 | `x=237.88``24.24×21.30``y=183.68 / 234.57` | 两块矩形 |
| 日期带 | `y=286.30`,高 `44.19`,文字 `cy=308.10` | 蓝牙 `cx=36.88`、星期 `cx=178.05`、月日 `cx=298.28`、AM/PM 右对齐 `465.89` |
| 底部数值 | `cy=366.76`;左格右对齐 `234.30`,右格左对齐 `267.40` | 过宽时自动降一档字号 |
| 底部标签 | `cy=410.88`,同上对齐 | 跟随所选数据(`DST` / `STEP` / `距离` …) |
字号按设计稿的**字面高度cap height**选取:日期带与电量 `25.34`、上方数据与底部行 `31.50`、时间 `98.70`
---
## 3. 进度环
刻度是**等角宽的环形扇区**`fillPolygon` 四点),不是径向线段。
- **顶弧**21 根,角度 `6.5 + 4.0k`k=0..20),角宽 `3.108°`
- **底弧**21 根,角度 `6.005 + 3.0k`,角宽 `2.086°`
- **四象限**:右上 `+a`、左上 `-a`、左下 `180+a`、右下 `180-a`(角度为自 12 点起顺时针)
- **锚点圆点**:半径 `9.00`,位于圆心距 `240.10`、角度 `0 / 93.15 / 180 / 266.85`
**点亮方向**(与设计稿一致):两条顶弧从 9 点 / 3 点一侧朝 12 点填充;两条底弧从 6 点朝两侧填充。
每根刻度的颜色是设计稿里**逐根取样**的实测值(`Themes.RINGTOP[21]` / `RINGBOTTOM[21]`),不是插值近似。
> **已知近似**:设计稿中底弧最多只点亮 14/21 根,因此 `RINGBOTTOM` 的第 1521 项无法实测,由 `gen_themes.py` 对最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会显示这几根。
### 指标进度换算
| idx | 指标 | 分母 |
|-----|------|------|
| 0 | 步数 | `stepGoal` |
| 1 | 卡路里 | 固定 2000 kcal |
| 2 | 距离 | 10 km |
| 3 | 楼层 | `floorsClimbedGoal`(缺省 10 |
| 4 | 动动条 | `MOVE_BAR_LEVEL_MAX`=5 |
---
## 4. 时间数字的竖向渐变
设计稿的小时 / 分钟数字带竖向多段渐变(如 Ember 分钟由 `#FEAA00` 渐变到 `#FF2600`。Monkey C 无法给文字直接填渐变,实现方式是**同一串数字画 11 遍,每遍用 `dc.setClip` 限制在一条横向色带内**,色带颜色取自 `Themes.HOURS` / `Themes.MINUTES`(各 11 个停止点,实测自设计稿的 `linearGradient`)。首尾两条色带向外延伸,保证字形不会有未绘制的缝隙。
---
## 5. 字号选择
Connect IQ 只暴露 `getFontAscent` / `getFontDescent` / `getFontHeight`**没有字面高度接口**,而 ascent 里含有数字和大写字母用不到的变音符空间。因此代码用两个实测比例把 ascent 折算成 cap height
```
文本字体Roboto 系) cap ≈ 0.76 × ascent
数字字体Bionic 系) cap ≈ 0.71 × ascent
```
比例是在模拟器里渲染后、从截图上量出墨迹得到的fenix847mm`FONT_XTINY` ascent 29 → 大写高 22px`FONT_NUMBER_MILD` ascent 82 → 数字高 58px`pickFont` 取折算后**最接近**目标 cap height 的字体,而不是"最大且不超过"——后者会在设计尺寸落在两档字体之间时白白小一号。
各机型内置字体不完全相同Roboto-Regular / RobotoCondensed-Bold / Yantramanav / 各机型位图字体),比例会有几个百分点误差。设计稿使用的是更粗更方的第三方字体,不随包分发,字形差异无法消除。
---
## 6. 七套主题
`tools/gen_themes.py``design/watchface-0N-*.svg` 生成。每套包含:顶/底弧各 21 根刻度色、未点亮刻度色、锚点色、日期带底/字色、冒号色、小时/分钟各 11 段渐变、强调色(图标与 DST/STEP 标签)、主文字色(各项数值)。
| # | 主题 | 主色调 | 强调色 | 日期带 |
|---|------|--------|--------|--------|
| 1 | Ember | 黄 → 红 | `#FEAA05` | 橙底黑字 |
| 2 | Aurora | 黄绿 → 青 | `#ADABAE` | 深灰底白字 |
| 3 | Brass | 金铜 | `#B57E4B` | 深棕底白字 |
| 4 | Voltage | 蓝 → 黄 | `#ADABAE` | 蓝底黑字 |
| 5 | Reef | 橙 → 青 | `#FEAA05` | 青底黑字 |
| 6 | Acid | 黄绿(近单色) | `#C6FA03` | 深灰底黑字 |
| 7 | Kelp | 橙 → 绿 | `#34C294` | 深灰底黑字 |
改配色:编辑 / 替换 `design/` 里的 SVG然后
```bash
python3 tools/gen_themes.py && python3 tools/gen_preview_svg.py
```
新增主题需同步在 `settings.xml``Theme` 列表与 `strings.xml`(含 `resources-chn/`)中登记。
---
## 7. 可配置设置与数据字段
19 项设置,在 Garmin Connect 手机 App 的「表盘设置」里改(定义在 `resources/settings/settings.xml`,默认值在 `properties.xml`,两者由 `tools/gen_fields.py` 生成)。
| 设置 | 键 | 默认 |
|------|----|------|
| 主题 | `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 |
进度环指标:步数、卡路里、距离、楼层、动动条、活动分钟(对周目标)、身体电量。
### 数据字段
四个数据位共用一张 **156 项** 的字段表,编号沿用 [watchface.io/docs/datafields](https://watchface.io/docs/datafields),两边可以直接对照。底部两格的小标签跟随所选字段自动变化,不需要单独配置。
| 分类 | 字段 |
|------|------|
| 日期时间 | 秒前导零两种、时间、UTC 时间、第二时区 1/2、星期月日、星期日、月日、日、星期简写/全称)、月份(名称/数字、上午下午、周数ISO / 日历) |
| 活动 | 步数、步数取整、剩余步数、卡路里、活动卡路里、活动分钟(总/中强度/高强度/本周)、距离、上楼层数、下楼层数、爬升高度、推行距离、推行次数、**本周 / 近 7 天 / 近 28 天累计**(步数/卡路里/距离/楼层/活动分钟) |
| 身体 | 心率、静息心率、压力指数、血氧、呼吸频率、身体电量、活动条(正/反向、体重、BMI、恢复时间、最大摄氧量跑/骑) |
| 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 |
| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明、民用/航海/天文晨昏蒙影(各 2 项)、黄金时刻(晨/昏)、蓝调时刻(晨/昏) |
| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间、海平面气压、紫外线、能见度、露点 |
| OpenWeatherMap | 气温、体感、最高/最低、天气描述(简/详、湿度、气压、能见度、风速、风向角、风向、阵风、1h 降雨、1h 降雪、云量、降水概率、露点、明日与后日气温及高低温、城市名、更新时间、**空气质量AQI 等级 + 描述 + PM2.5 + PM10 + CO**(共 30 项) |
| 系统 Complication | 下一个日程、训练状态、完赛预测5k/10k/半马/全马)、配速预测(同上四项)、最近高尔夫得分、本周跑步距离、本周骑行距离、第三方 App 15 |
| 自定义 | 自定义文字 1 / 2 / 3、自定义时间格式、日期格式 1 / 2、事件倒计时 |
加字段:在 `tools/gen_fields.py``FIELDS` 表里加一行,跑一遍生成器,再到 `source/Fields.mc``value()` 里补一个分支。设置项、两种语言的文案、表盘标签会自动跟上。
### 系统 Complication
`Toybox.Complications`CIQ 4.2+17 款设备全都有)让表盘可以订阅系统和**其它 App 发布**的复杂功能。这条路解锁了一批原本以为做不到的字段:
- **日历事件** —— CIQ 确实没有日历 API但系统把「下一个日程」作为 complication 发布了出来
- **训练状态、完赛/配速预测、最近高尔夫得分** —— `ActivityMonitor.Info``UserProfile` 上都没有,这里有
- **本周跑步 / 骑行距离** —— 这是**按运动分类**的,`ActivityMonitor` 的按天历史做不出来
- **第三方 App 15** —— CGM、补水、行情这类 App 发布的复杂功能,按序号取
需要 `ComplicationSubscriber` 权限。第三方 App 那五个槽位按「有值且有短标签」过滤后按序号取,避免序号随手表上装了什么 App 而漂移。
### 自定义格式与倒计时
时间/日期格式串用单字母占位符(设置项能填的长度有限,这样中英文都好写):
| 占位符 | 含义 | 占位符 | 含义 |
|--------|------|--------|------|
| `h` / `H` | 小时(跟随 12/24 制)/ 补零 | `D` | 日 |
| `m` / `s` | 分钟 / 秒(补零) | `M` / `N` | 月(数字 / 名称) |
| `a` | 上午/下午24 小时制下为空) | `Y` / `y` | 四位年 / 两位年 |
| | | `w` | 星期 |
其它字符原样输出,所以 `D/M``10/9``w N D``周四 9月 10`
事件倒计时填 `YYYY-MM-DD`,显示还剩几天。
### 秒针与后台天气
**秒**850/869通过 `onPartialUpdate` 每秒重绘:只有当某个数据位真的选了秒时才请求这个回调,并且只在该格的裁剪区内重绘,不动整个表盘。常亮的 AMOLED 机型不参与——那时数据位本来就不显示。
**OpenWeatherMap** 需要用户在设置里填自己的 API key免费档即可。没填就完全不联网25 个 OWM 字段都显示 `--`。填了之后由后台服务每 30 分钟拉一次(当前天气 + `cnt=16` 的短预报,够算出明日/后日高低温),结果压平成一个小字典存进 `Application.Storage`,表盘只读不联网。
位置来自天气观测点或最近一次 GPS 定位,并缓存在 Storage 里——两个来源都会间歇性为空,缓存让日出和晨昏蒙影不会一会儿有一会儿没有。
### ⚠️ 仍然没有实现的,以及原因
| 没做的 | 原因 |
|--------|------|
| 按运动细分的多数汇总(游泳、自定义运动 1/2 的距离/时长/次数,约 60 项) | `ActivityMonitor.getHistory()` 只有按天总计,没有按运动分类的历史。**跑步和骑行的本周距离例外**——系统把这两项作为 complication 发布了,已经做了;游泳和自定义运动没有对应的 complication 类型 |
| 日历事件、计时器 | 日历已通过 Complication 做了。**计时器**仍然做不了:表盘拿不到触摸输入,没法启停 |
| StormGlass.io 潮汐5 项) | 需要额外的付费 API key且与本表盘的定位无关 |
| 第三方 App 复杂功能CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App并通过 Complications API 订阅 |
| 睡眠得分 | `ActivityMonitor.Info` 上没有,也没有对应的 complication 类型SDK 9.1.0 实测)。训练状态与完赛/配速预测已改由 Complication 提供 |
| 高尔夫成绩 | 无对应 API |
| 标记类字段Marker约 35 项) | 那是给刻度型表盘做环形标记用的,本表盘的进度环用的是自己的指标体系 |
| OWM 的 UVI | 要 One Call 3.0 订阅。**AQI 已经做了**——空气质量走 `/data/2.5/air_pollution`,那个接口在免费档就开放。露点用 Magnus 公式从温湿度算出 |
---
## 8. 数据来源与权限
| 元素 | API |
|------|-----|
| 步数 / 卡路里 / 距离 / 楼层 / 动动条 / 活动分钟 / 呼吸 / 压力 / 恢复时间 | `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` 是 0100 的百分比,不是 01 |
| 闹钟 / 通知 / 勿扰 / 蓝牙 / 单位制 | `System.getDeviceSettings()` |
| 月龄 / 月球照明 | 自算(儒略日 + 朔望月 29.530588853 天) |
| 周数 | 自算ISO 8601 与日历周两种) |
### 权限
`manifest.xml` 声明五项:`UserProfile`静息心率、体重、VO2max`SensorHistory`(血氧、身体电量)、`Communications``Background`OpenWeatherMap`ComplicationSubscriber`(日历、训练状态、第三方 App。用户安装时会看到授权提示。
> `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 机型不受影响,照常全量绘制。
实测fenix847mm取表盘内区
| | 点亮像素数 | 平均亮度(功耗代理) |
|---|---|---|
| 普通模式 | 27.6% | 14.1% |
| 常亮模式 | **9.7%** | **2.5%** |
常亮下砍掉的东西:整条日期带底色、**整个进度环**、四个数据位与电池;保留的时间、冒号、日期压暗到 38%。
两个口径要分清「点亮像素数」把每个抗锯齿边缘像素都算成亮的对大号数字表盘天然不友好AMOLED 的功耗更接近各子像素亮度之和,也就是表里的「平均亮度」。本实现两个口径都留了余量。
---
## 8.5 多语言
`manifest.xml` 声明了 `eng``chn`,两套文案分别在 `resources/strings/``resources-chn/strings/`,由 `monkey.jungle``base.resourcePath = resources;resources-chn` 同时纳入编译(目录名的 `-chn` 后缀就是语言标记)。手表会按系统语言自动选择。
**表盘上的文字也走资源**,不是硬编码:
| 资源 | 英文 | 中文 |
|------|------|------|
| `DowNames` | `SUN,MON,…,SAT` | `周日,周一,…,周六` |
| `MonNames` | `JAN,FEB,…,DEC` | `1月,2月,…,12月` |
| `DateFormat` | `$1$ $2$``SEP 9` | `$1$$2$日``9月9日` |
| `Meridiem` | `AM,PM` | `上午,下午` |
| `FieldLabels` | `TEMP,KCAL,STEP,DST,…` | `气温,卡路里,步数,距离,…` |
逗号分隔的列表在 `loadStrings()` 里一次性读入并拆分(`loadResource` 每次调用开销不低,不宜放进 `onUpdate`)。
> 中文字形依赖手表固件里的 APAC 字体包(`fontSet: apac_twcn` / `apac_chn`)。国行机型自带;国际版若未安装中文字体包,会退回英文或显示方块。模拟器可用 `Settings → Language → Chinese (Simplified)` 验证。
---
## 9. 构建
需安装 Connect IQ SDK 9.1.0`monkeyc` 在 PATH 中,并备好 `developer_key.der`**不在仓库内**,需自行生成或提供)。
```bash
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d fenix847mm
```
全部 17 款设备批量编译(已验证通过):
```bash
for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm fenix8solar51mm \
epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x fenix7s fr965 fr265 fr255 \
venu3 vivoactive5; do
monkeyc -o "bin/Fenix8V3_${d}.prg" -f monkey.jungle -y developer_key.der -d "$d" \ monkeyc -o "bin/Fenix8V3_${d}.prg" -f monkey.jungle -y developer_key.der -d "$d" \
&& echo "[$d] OK" || echo "[$d] FAIL" && echo "[$d] OK" || echo "[$d] FAIL"
done done
``` ```
已验证编译通过的设备:`enduro3, fenix843mm, fenix847mm, fenix8pro47mm, fenix8solar47mm, fenix8solar51mm, epix2, epix2pro47mm, epix2pro51mm, fenix7, fenix7x, fenix7s, fr965, fr265, fr255, venu3, vivoactive5`。产物位于 `bin/`,每个设备对应一个 `.prg``.prg.debug.xml`(调试版含资源镜像) 屏幕分辨率跨度较大FR255S 218×218 至 454×454 共 8 档),全部按 `s = width/500` 缩放
机型列表和 `monkey.jungle``tools/gen_devices.py` 扫描本机已安装的 SDK 设备生成:筛选条件是「支持 watchFace + 圆屏 + CIQ ≥ 4.2」Complications 需要 4.2)。**注意结果取决于本机装了哪些设备**,跑之前先确认 SDK 设备装全了,脚本会打印找到的数量。
--- ---
## 6. 模拟器与侧载 ## 10. 模拟器与侧载
1. **Connect IQ Simulator**Garmin 自带):`File → Open App Project` 选本目录,选设备后 `Run`。在 `bin/*.prg.debug.xml` 旁会生成 `-settings.json`,可在模拟器菜单 `View → Settings` 载入并实时切换主题/指标验证。 ```bash
2. **真机侧载Garmin Connect / Garmin Express** connectiq & # 启动模拟器
- 用 Garmin Express 或 Connect 手机 App 的“侧载sideload”功能导入对应设备的 `bin/Fenix8V3_<device>.prg` monkeydo bin/Fenix8V3_fenix847mm.prg fenix847mm # 载入表盘
- 或把 `.prg` 拷入手表的 `/GARMIN/Apps/` 目录(需开发者模式 / WiFi 文件传输)。
3. 安装后在手表“表盘”列表选择 **Fenix 8 V3**,长按进入设置即可调主题与指标。
---
## 7. 目录结构
```
fenix8v3-watchface/
├── manifest.xml # 应用清单ID / 支持设备 / 语言)
├── monkey.jungle # 构建配置manifest + 源码/资源路径)
├── developer_key.der # Connect IQ 开发者签名密钥
├── source/
│ ├── Fenix8V3App.mc # 应用入口getInitialView / onSettingsChanged
│ ├── Fenix8V3View.mc # 表盘渲染主逻辑onUpdate + 各 draw*
│ └── Themes.mc # ⚠️ 自动生成7 套主题颜色(勿手改)
├── resources/
│ ├── strings/strings.xml # AppName / 设置项 / 主题 / 指标 / 字段文案
│ └── settings/
│ ├── properties.xml # 设置默认值
│ └── settings.xml # 设置 UI 定义(列表/布尔)
└── bin/ # 编译产物(.prg + 调试 xml + settings.json
``` ```
--- 非 release 编译会同时产出 `bin/*-settings.json`,可在模拟器 `View → Settings` 载入以实时切换主题与指标。注意模拟器会**持久化**已保存的设置,改 `properties.xml` 的默认值对已运行过的实例不生效,需先重置应用数据。
## 8. 二次开发与扩展 ### 侧载到真机
- **改主题配色**:编辑 `watchface-tokens.json`(设计稿目录下),运行 `gen_themes.py` 重新生成 `source/Themes.mc`。新增主题时同步在 `Themes.mc``ORDER` 数组、`settings.xml``Theme` 列表、`strings.xml``ThemeXX` 文案中登记。 ```bash
- **加指标**:在 `metricProgress`(进度换算)与 `complicationValue`(复杂功能文本)两个方法里按 `idx` 分支扩展,并在 `settings.xml` / `properties.xml` 的列表项补充。 tools/install.sh # 默认 enduro3
- **调布局**:所有坐标集中在 `Fenix8V3View.mc``draw*` 方法里,均为 “设计像素 × `s`” 形式,改一处即可全局缩放生效。 tools/install.sh fenix847mm # 指定机型id 见 manifest.xml
```
脚本会编译 release 版并自动找已挂载的 GARMIN 卷复制过去。找不到卷时会打印
`.prg` 的路径和排查提示。
手动做的话就是:编译出 `bin/Fenix8V3_<机型>.prg`,拷进手表的 `GARMIN/APPS/`
推出后拔线,表上长按 UP/MENU → 表盘 → 选 Fenix 8 V3。再长按 MENU 进表盘设置。
> macOS 上 fenix 7 之后的机型多数走 MTPFinder 看不到这个盘。装
> [OpenMTP](https://openmtp.ganeshrvel.com)(免费)或 Android File Transfer
> 就能拖文件了。
--- ---
## 9. 设计稿来源 ## 11. 预览
还原基准为 `watchface-kit.zip` 内的 8 张 SVG 变体 + `watchface-tokens.json`(配色)+ `watchface-spec.html`(像素测量规范)+ `rebuild.py`(几何常量)。本仓库为按该规范落地的 Monkey C 实现,设计版权归原作者所有。 ```bash
python3 tools/gen_preview_svg.py
```
产出 `preview-themes.svg`(七套主题一览)与 `preview.html`(可切换主题的单页)。两者用网页字体渲染文字,**几何与配色可信,字形不可信** —— 真机字形以模拟器截图为准。
---
## 12. 与设计稿的差异
1. **字体**。设计稿用的是一款更粗、更方的第三方字体Garmin 只提供内置 Roboto / Bionic 系列,且各机型不同。字号按 cap height 对齐,字形无法一致。
2. **图标**。设计稿里的图标是位图描出的密集矢量路径(数百个点),不适合逐帧绘制;代码用圆、多边形、线段按实测的子形状位置重建了电量、日出/日落、多云三个图标。其余数据项(卡路里 / 步数 / 距离 / 楼层)设计稿未提供图标,为自绘。
3. **底弧高段颜色**为外推值,见 §3。
4. **进度环是实时数据**,设计稿中的是固定演示状态(右上 21/21、左上 10/21、右下 9/21、左下 14/21
---
## 12.5 上架素材
商店后台要填的文案、图标和截图都在 `docs/`,见 `docs/README.md`
截图是模拟器的真实渲染Garmin 内置字体),八个屏宽各一张,外加七套主题各一张。
其中**进度环是摆拍的** —— 模拟器灌不进步数和距离,环会全灰,具体说明在
`docs/README.md` 里写清楚了。
---
## 13. 设计稿版权
`design/` 内的 SVG、tokens 与 `rebuild.py` 来自 `watchface-kit`,版权归原作者所有。本仓库是按该设计稿实现的 Monkey C 表盘。

329
design/rebuild.py Normal file
View File

@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Watch face vector reconstruction, v2.
Changes over v1:
* ring ticks are annular sectors (constant angular width) instead of rects
* every tick's colour is sampled directly from the source, no LUT interpolation
* glyph alpha is normalised against the *local* fill colour, so vertically
graded digits keep their true weight in the dark rows
* time digits get multi-stop gradients sampled every 8 px
* sub-pixel geometry throughout
"""
import json, math, os
import numpy as np
from PIL import Image
from skimage import measure
SRC = "/mnt/user-data/uploads"
OUT = "/mnt/user-data/outputs"
os.makedirs(OUT, exist_ok=True)
CX_IX, CY_IX = 250.038, 250.049 # dial centre, pixel-index space
CX, CY = CX_IX + 0.5, CY_IX + 0.5 # same point in SVG user space
PX = 0.5 # index -> SVG offset
THEMES = [
("0fbde6715a76f357bcc5c20cff74c0bf2f156e89.png", "01", "Ember"),
("304a904845dbc44aa0c87eecad2dd3a2621d2ffc.png", "02", "Aurora"),
("a61e76acdc7f17edfda3b11d36ba5d1c25e0739b.png", "03", "Brass"),
("f43ad1228487aa0261183e7268277f0e9ab328f4.png", "04", "Voltage"),
("8fd4a2811dc0227b004a5832479cc0a5d4e26a9f.png", "05", "Reef"),
("f087f3345e5036bfd406719e8d7517c13109d998.png", "06", "Acid"),
("d3a77eea386551ff09b352a77f522bb3c68e925d.png", "07", "Kelp"),
]
# ---------------------------------------------------------- ring geometry ---
R_IN, R_OUT = 231.09, 249.10
TOP_PHASE, TOP_PITCH, TOP_W, TOP_N = 6.500, 4.000, 3.108, 21
BOT_PHASE, BOT_PITCH, BOT_W, BOT_N = 6.005, 3.000, 2.086, 21
DOT_R, DOT_RAD = 240.10, 9.00
DOT_ANGLES = [0.0, 93.15, 180.0, 266.85]
BAND_Y, BAND_H = 286.30, 44.19
COLON = [(237.88, 183.68, 24.24, 21.30), (237.88, 234.57, 24.24, 21.30)]
def tick_angles():
"""(group, signed centre angle) for all 86 ticks."""
out = []
for k in range(TOP_N):
a = TOP_PHASE + TOP_PITCH * k
out.append(("top-right", a, TOP_W))
out.append(("top-left", -a, TOP_W))
for k in range(BOT_N):
a = BOT_PHASE + BOT_PITCH * k
out.append(("bottom-left", 180.0 + a, BOT_W))
out.append(("bottom-right", 180.0 - a, BOT_W))
return out
def polar(ang, r):
t = math.radians(ang - 90.0)
return CX + math.cos(t) * r, CY + math.sin(t) * r
def polar_ix(ang, r):
t = math.radians(ang - 90.0)
return CX_IX + math.cos(t) * r, CY_IX + math.sin(t) * r
def sector_path(centre, width, r0=R_IN, r1=R_OUT):
a0, a1 = centre - width / 2, centre + width / 2
x1, y1 = polar(a0, r1)
x2, y2 = polar(a1, r1)
x3, y3 = polar(a1, r0)
x4, y4 = polar(a0, r0)
return (f"M{x1:.2f},{y1:.2f} A{r1:.2f},{r1:.2f} 0 0 1 {x2:.2f},{y2:.2f} "
f"L{x3:.2f},{y3:.2f} A{r0:.2f},{r0:.2f} 0 0 0 {x4:.2f},{y4:.2f} Z")
def sample_tick(arr, centre, width):
"""Median colour of a tick's interior (AA edges excluded)."""
px = []
for r in np.arange(R_IN + 3.5, R_OUT - 3.5, 1.0):
for d in np.arange(-width * 0.32, width * 0.32 + 1e-9, width * 0.16):
x, y = polar_ix(centre + d, r)
px.append(arr[int(round(y)), int(round(x))])
return np.median(np.array(px), axis=0)
# --------------------------------------------------------------- tracing ---
def rdp(pts, eps):
if len(pts) < 3:
return pts
a, b = pts[0], pts[-1]
ab = b - a
n = math.hypot(ab[0], ab[1])
if n < 1e-9:
d = np.hypot(*(pts - a).T)
else:
d = np.abs(ab[0] * (pts[:, 1] - a[1]) - ab[1] * (pts[:, 0] - a[0])) / n
i = int(np.argmax(d))
if d[i] > eps:
return np.vstack([rdp(pts[: i + 1], eps)[:-1], rdp(pts[i:], eps)])
return np.vstack([a, b])
def trace(alpha, x0, y0, eps=0.07, ss=6):
half = 0.5 / ss # supersampled index -> source continuous coordinate
up = np.asarray(Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8))
.resize((alpha.shape[1] * ss, alpha.shape[0] * ss), Image.BICUBIC)) / 255.0
up = np.pad(up, 1, mode="constant")
parts = []
for c in measure.find_contours(up, 0.5):
c = (c - 1) / ss
pts = np.column_stack([c[:, 1] + x0 + half, c[:, 0] + y0 + half])
if len(pts) < 10:
continue
pts = rdp(pts, eps)
if len(pts) < 4:
continue
parts.append("M" + " ".join(f"{x:.2f},{y:.2f}" for x, y in pts[:-1]) + "Z")
return " ".join(parts)
def full_colour(reg, q=0.97):
"""Colour of a fully-opaque pixel in a solid-filled region."""
flat = reg.reshape(-1, 3)
l = flat.mean(axis=1)
cut = np.quantile(l, q)
sel = flat[l >= cut]
return sel.mean(axis=0)
def alpha_solid(arr, box):
x0, y0, x1, y1 = box
reg = arr[y0:y1, x0:x1].astype(float)
e = full_colour(reg)
a = reg.mean(axis=2) / max(e.mean(), 1e-6)
return np.clip(a, 0, 1), e, x0, y0
def row_ramp(reg):
"""Per-row fully-opaque colour for a vertically graded glyph."""
h = reg.shape[0]
ys, cols = [], []
for y in range(h):
row = reg[y]
l = row.mean(axis=1)
if l.max() < 30:
continue
sel = row[l >= l.max() * 0.92]
if len(sel) < 3:
continue
ys.append(y)
cols.append(sel.mean(axis=0))
if len(ys) < 4:
return None, None
ys = np.array(ys)
cols = np.array(cols)
# smooth then extend to every row
out = np.zeros((h, 3))
for c in range(3):
out[:, c] = np.interp(np.arange(h), ys, np.convolve(
cols[:, c], np.ones(5) / 5, mode="same") if len(ys) > 8 else cols[:, c])
out[: ys[0], c] = cols[0, c]
out[ys[-1] + 1:, c] = cols[-1, c]
return out, (ys[0], ys[-1])
def alpha_graded(arr, box):
x0, y0, x1, y1 = box
reg = arr[y0:y1, x0:x1].astype(float)
ramp, span = row_ramp(reg)
if ramp is None:
return alpha_solid(arr, box) + (None,)
denom = np.maximum(ramp.mean(axis=1), 1e-6)[:, None]
a = np.clip(reg.mean(axis=2) / denom, 0, 1)
return a, ramp, x0, y0, span
def alpha_on_fill(arr, box, fill):
x0, y0, x1, y1 = box
reg = arr[y0:y1, x0:x1].astype(float)
fill = np.array(fill, float)
d = reg - fill
far = d.reshape(-1, 3)
mag = np.linalg.norm(far, axis=1)
e = far[mag >= np.quantile(mag, 0.97)].mean(axis=0)
a = (d @ e) / max(e @ e, 1e-6)
return np.clip(a, 0, 1), fill + e, x0, y0
def hx(c):
return "#%02X%02X%02X" % tuple(int(round(min(255, max(0, v)))) for v in c)
# --------------------------------------------------------------- elements ---
SOLID = [
("battery-icon", (188, 38, 228, 68)),
("battery-value", (228, 38, 312, 70)),
("sunrise-icon", (136, 72, 178, 108)),
("sunrise-value", (106, 110, 208, 152)),
("weather-icon", (322, 70, 364, 108)),
("weather-value", (258, 110, 428, 152)),
("dist-value", (166, 345, 240, 388)),
("dist-label", (143, 390, 238, 432)),
("steps-value", (261, 345, 366, 388)),
("steps-label", (263, 390, 386, 432)),
]
BAND_EL = [
("bt-icon", (20, 289, 54, 326)),
("date-day", (132, 290, 223, 326)),
("date-md", (225, 290, 370, 326)),
("meridiem", (404, 290, 472, 326)),
]
TIME_L = (76, 162, 218, 278)
TIME_R = (264, 162, 434, 278)
def grad_def(gid, ramp, y0, span):
ya, yb = y0 + span[0] + 0.5, y0 + span[1] + 0.5
n = max(2, int((span[1] - span[0]) // 8))
stops = []
for i in range(n + 1):
f = i / n
yy = int(round(span[0] + f * (span[1] - span[0])))
stops.append(f'<stop offset="{f:.3f}" stop-color="{hx(ramp[yy])}"/>')
return (f'<linearGradient id="{gid}" x1="0" y1="{ya}" x2="0" y2="{yb}" '
f'gradientUnits="userSpaceOnUse">{"".join(stops)}</linearGradient>')
def build(path, tid, tname):
arr = np.array(Image.open(os.path.join(SRC, path)).convert("RGB")).astype(int)
defs, parts, tok = [], [], {}
parts.append('<rect id="background" width="500" height="500" fill="#000000"/>')
# ---- ring: every tick measured individually
groups = {}
for gname, centre, width in tick_angles():
raw = sample_tick(arr, centre, width)
if raw.mean() < 12: # slot occupied by an anchor dot / not drawn
continue
col = hx(raw)
groups.setdefault(gname, []).append(
f'<path d="{sector_path(centre, width)}" fill="{col}"/>')
ring = []
for g in ("top-right", "top-left", "bottom-left", "bottom-right"):
ring.append(f'<g id="ring-{g}">\n ' + "\n ".join(groups[g]) + "\n </g>")
dot_col = hx(np.median(arr[4:13, 245:255].reshape(-1, 3), axis=0))
dots = []
for a in DOT_ANGLES:
x, y = polar(a, DOT_R)
dots.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{DOT_RAD}" fill="{dot_col}"/>')
ring.append('<g id="ring-anchors">\n ' + "\n ".join(dots) + "\n </g>")
parts.append('<g id="ring">\n ' + "\n ".join(ring) + "\n</g>")
tok["anchorDot"] = dot_col
# ---- date band
fill = arr[300, 110].astype(float)
band = [f'<rect x="0" y="{BAND_Y}" width="500" height="{BAND_H}" fill="{hx(fill)}"/>']
band_text = None
for name, box in BAND_EL:
a, e, x0, y0 = alpha_on_fill(arr, box, fill)
d = trace(a, x0, y0)
if not d:
continue
band_text = hx(e)
band.append(f'<path id="{name}" d="{d}" fill="{band_text}" fill-rule="evenodd"/>')
parts.append('<g id="date-band">\n ' + "\n ".join(band) + "\n</g>")
tok["bandFill"], tok["bandText"] = hx(fill), band_text
# ---- time
tg = []
for gid, name, box in (("gHours", "time-hours", TIME_L),
("gMinutes", "time-minutes", TIME_R)):
a, ramp, x0, y0, span = alpha_graded(arr, box)
if span is None:
continue
defs.append(grad_def(gid, ramp, y0, span))
tok[gid] = [hx(ramp[span[0]]), hx(ramp[span[1]])]
tg.append((name, f'<path id="{name}" d="{trace(a, x0, y0)}" '
f'fill="url(#{gid})" fill-rule="evenodd"/>'))
colon_col = hx(np.median(arr[188:200, 242:258].reshape(-1, 3), axis=0))
tok["colon"] = colon_col
colon = '<g id="time-colon">' + "".join(
f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{colon_col}"/>'
for x, y, w, h in COLON) + "</g>"
parts.append('<g id="time">\n ' + tg[0][1] + "\n " + colon + "\n " + tg[1][1] + "\n</g>")
# ---- complications
comp = []
for name, box in SOLID:
a, e, x0, y0 = alpha_solid(arr, box)
d = trace(a, x0, y0)
if not d:
continue
comp.append(f'<path id="{name}" d="{d}" fill="{hx(e)}" fill-rule="evenodd"/>')
tok[name] = hx(e)
parts.append('<g id="complications">\n ' + "\n ".join(comp) + "\n</g>")
svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" '
'height="500" shape-rendering="geometricPrecision">\n'
f'<title>Watch face {tid} {tname}</title>\n'
f'<defs>{"".join(defs)}</defs>\n' + "\n".join(parts) + "\n</svg>\n")
p = os.path.join(OUT, f"watchface-{tid}-{tname.lower()}.svg")
open(p, "w").write(svg)
# ring colour table for the spec
tok["ringTop"] = {f"{TOP_PHASE + TOP_PITCH * k:.2f}":
hx(sample_tick(arr, TOP_PHASE + TOP_PITCH * k, TOP_W))
for k in range(TOP_N)}
tok["ringBottom"] = {f"{BOT_PHASE + BOT_PITCH * k:.2f}":
hx(sample_tick(arr, 180 + BOT_PHASE + BOT_PITCH * k, BOT_W))
for k in range(BOT_N)}
return p, tok
if __name__ == "__main__":
tokens = {}
for p, tid, tname in THEMES:
out, tok = build(p, tid, tname)
tok["name"] = tname
tokens[tid] = tok
print("wrote", out)
json.dump(tokens, open(os.path.join(OUT, "watchface-tokens.json"), "w"), indent=2)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -0,0 +1,499 @@
{
"01": {
"anchorDot": "#525551",
"bandFill": "#FEAA00",
"bandText": "#000002",
"gHours": [
"#6F6F6F",
"#4A4A4B"
],
"gMinutes": [
"#7E5501",
"#931500"
],
"colon": "#525551",
"battery-icon": "#FEAA03",
"battery-value": "#FFFFFF",
"sunrise-icon": "#FEAA01",
"sunrise-value": "#FFFFFF",
"weather-icon": "#FEAA05",
"weather-value": "#FFFFFF",
"dist-value": "#FFFFFF",
"dist-label": "#FEAB05",
"steps-value": "#FFFFFF",
"steps-label": "#FEAA05",
"ringTop": {
"6.50": "#FFF200",
"10.50": "#FFE601",
"14.50": "#FFDB01",
"18.50": "#FFCE00",
"22.50": "#FFC200",
"26.50": "#FEB600",
"30.50": "#FEAA00",
"34.50": "#FF9D01",
"38.50": "#FF9100",
"42.50": "#FE8600",
"46.50": "#FE7900",
"50.50": "#FE6E00",
"54.50": "#FF6100",
"58.50": "#FF5500",
"62.50": "#FF4801",
"66.50": "#FF3C01",
"70.50": "#FF3002",
"74.50": "#FF2600",
"78.50": "#FF2600",
"82.50": "#FF2600",
"86.50": "#FF2600"
},
"ringBottom": {
"6.00": "#AD1602",
"9.00": "#AD1602",
"12.00": "#B21902",
"15.00": "#B32301",
"18.00": "#B43102",
"21.00": "#BC3C01",
"24.00": "#C54801",
"27.00": "#C45501",
"30.00": "#CE6100",
"33.01": "#CC6D00",
"36.01": "#D57900",
"39.01": "#D58401",
"42.01": "#DD9201",
"45.01": "#DE9D02",
"48.01": "#525551",
"51.01": "#525551",
"54.01": "#525551",
"57.01": "#525551",
"60.01": "#525551",
"63.01": "#525551",
"66.00": "#525551"
},
"name": "Ember"
},
"02": {
"anchorDot": "#0F3C82",
"bandFill": "#525551",
"bandText": "#FFFFFF",
"gHours": [
"#6F6F6F",
"#2F312F"
],
"gMinutes": [
"#567D01",
"#036E6F"
],
"colon": "#525551",
"battery-icon": "#ACAAAD",
"battery-value": "#FFFFFF",
"sunrise-icon": "#ACAAAD",
"sunrise-value": "#FFFFFF",
"weather-icon": "#ADABAE",
"weather-value": "#FFFFFF",
"dist-value": "#FFFFFF",
"dist-label": "#ADABAE",
"steps-value": "#FFFFFF",
"steps-label": "#ADABAE",
"ringTop": {
"6.50": "#A4F909",
"10.50": "#A4F918",
"14.50": "#9DF91E",
"18.50": "#9EFA31",
"22.50": "#95FA39",
"26.50": "#94F94A",
"30.50": "#8AFA52",
"34.50": "#8BFA61",
"38.50": "#84FA6A",
"42.50": "#85FA7C",
"46.50": "#7BFA82",
"50.50": "#7BFA94",
"54.50": "#73FB9B",
"58.50": "#74FBAB",
"62.50": "#6BFBB4",
"66.50": "#6AFCC5",
"70.50": "#63FCCD",
"74.50": "#62FCDE",
"78.50": "#5AFCE6",
"82.50": "#59FDF6",
"86.50": "#52FDFF"
},
"ringBottom": {
"6.00": "#52FDFF",
"9.00": "#59FCF6",
"12.00": "#5AFCE6",
"15.00": "#62FCDE",
"18.00": "#62FCCD",
"21.00": "#6AFCC5",
"24.00": "#6BFBB4",
"27.00": "#74FBAB",
"30.00": "#73FB9B",
"33.01": "#7BFA94",
"36.01": "#7BFA83",
"39.01": "#85FA7C",
"42.01": "#84FA6A",
"45.01": "#8BFA61",
"48.01": "#525551",
"51.01": "#525551",
"54.01": "#525551",
"57.01": "#525551",
"60.01": "#525551",
"63.01": "#525551",
"66.00": "#525551"
},
"name": "Aurora"
},
"03": {
"anchorDot": "#62441E",
"bandFill": "#392810",
"bandText": "#FFFFFF",
"gHours": [
"#6F6F6F",
"#2F312F"
],
"gMinutes": [
"#78674B",
"#4D3620"
],
"colon": "#525551",
"battery-icon": "#B67E4B",
"battery-value": "#F0CF98",
"sunrise-icon": "#B57D4A",
"sunrise-value": "#F0CF97",
"weather-icon": "#B57E4B",
"weather-value": "#F0CF97",
"dist-value": "#F0CF97",
"dist-label": "#B57E4C",
"steps-value": "#F0CF97",
"steps-label": "#B57E4C",
"ringTop": {
"6.50": "#B3814A",
"10.50": "#B5854B",
"14.50": "#B48951",
"18.50": "#BD8E52",
"22.50": "#BD915B",
"26.50": "#C5955B",
"30.50": "#C59962",
"34.50": "#C59D62",
"38.50": "#CD9D6B",
"42.50": "#CCA169",
"46.50": "#CCA573",
"50.50": "#D6AA73",
"54.50": "#D5AE7B",
"58.50": "#D5B37A",
"62.50": "#DEB682",
"66.50": "#DEBA83",
"70.50": "#DEBD8B",
"74.50": "#E5C28B",
"78.50": "#E6C693",
"82.50": "#E6CA94",
"86.50": "#EFCE95"
},
"ringBottom": {
"6.00": "#B47D4A",
"9.00": "#B3814A",
"12.00": "#B4844A",
"15.00": "#B48951",
"18.00": "#BD8E52",
"21.00": "#BD915B",
"24.00": "#C5955B",
"27.00": "#C49962",
"30.00": "#C59962",
"33.01": "#CD9D6A",
"36.01": "#CDA169",
"39.01": "#CCA572",
"42.01": "#D6AA73",
"45.01": "#D5AE7B",
"48.01": "#392810",
"51.01": "#392710",
"54.01": "#392810",
"57.01": "#392810",
"60.01": "#392810",
"63.01": "#392810",
"66.00": "#392810"
},
"name": "Brass"
},
"04": {
"anchorDot": "#53AAFF",
"bandFill": "#53AAFF",
"bandText": "#000000",
"gHours": [
"#6F6F6F",
"#2F312F"
],
"gMinutes": [
"#29557F",
"#848301"
],
"colon": "#525551",
"battery-icon": "#ACAAAD",
"battery-value": "#55ABFF",
"sunrise-icon": "#ACAAAD",
"sunrise-value": "#55ABFF",
"weather-icon": "#ADABAE",
"weather-value": "#55ABFF",
"dist-value": "#55ABFF",
"dist-label": "#ADABAE",
"steps-value": "#55ABFF",
"steps-label": "#ADABAE",
"ringTop": {
"6.50": "#5AAEEE",
"10.50": "#62B2E5",
"14.50": "#6AB6DF",
"18.50": "#72BACC",
"22.50": "#7CBEC5",
"26.50": "#83C1B4",
"30.50": "#8CC6AC",
"34.50": "#94C99C",
"38.50": "#9DCE94",
"42.50": "#A3D283",
"46.50": "#ACD67A",
"50.50": "#B5DA6A",
"54.50": "#BEDD62",
"58.50": "#C5E252",
"62.50": "#CDE64B",
"66.50": "#D5EA3A",
"70.50": "#DEED30",
"74.50": "#E7F221",
"78.50": "#EDF617",
"82.50": "#F6FA08",
"86.50": "#FFFB01"
},
"ringBottom": {
"6.00": "#FFFB01",
"9.00": "#F6FA08",
"12.00": "#EDF617",
"15.00": "#E7F221",
"18.00": "#DEED30",
"21.00": "#D5EA38",
"24.00": "#CDE64B",
"27.00": "#C5E252",
"30.00": "#BEDE62",
"33.01": "#B5DA6A",
"36.01": "#ACD67A",
"39.01": "#A3D182",
"42.01": "#9CCE94",
"45.01": "#94C99C",
"48.01": "#525551",
"51.01": "#525551",
"54.01": "#525551",
"57.01": "#525551",
"60.01": "#525551",
"63.01": "#525551",
"66.00": "#525551"
},
"name": "Voltage"
},
"05": {
"anchorDot": "#FE7900",
"bandFill": "#52FDFF",
"bandText": "#000000",
"gHours": [
"#6F6F6F",
"#2F312F"
],
"gMinutes": [
"#7F3C01",
"#2B8384"
],
"colon": "#525551",
"battery-icon": "#FEAA03",
"battery-value": "#FFFFFF",
"sunrise-icon": "#FEAA01",
"sunrise-value": "#FFFFFF",
"weather-icon": "#FEAA05",
"weather-value": "#FFFFFF",
"dist-value": "#FFFFFF",
"dist-label": "#FEAB05",
"steps-value": "#FFFFFF",
"steps-label": "#FEAA05",
"ringTop": {
"6.50": "#F57D07",
"10.50": "#ED8618",
"14.50": "#E68D20",
"18.50": "#DE9130",
"22.50": "#D49939",
"26.50": "#CC9D49",
"30.50": "#C5A552",
"34.50": "#BCAA61",
"38.50": "#B3B26A",
"42.50": "#ADB67C",
"46.50": "#A4BE83",
"50.50": "#9CC694",
"54.50": "#94CA9C",
"58.50": "#8CD2AB",
"62.50": "#83D6B5",
"66.50": "#7BDEC5",
"70.50": "#74E6CE",
"74.50": "#6AEADF",
"78.50": "#63F2E7",
"82.50": "#5BF6F6",
"86.50": "#52FDFF"
},
"ringBottom": {
"6.00": "#FE7900",
"9.00": "#F57C07",
"12.00": "#ED8618",
"15.00": "#E68920",
"18.00": "#DE9130",
"21.00": "#D49939",
"24.00": "#CD9D49",
"27.00": "#C5A552",
"30.00": "#BCAA61",
"33.01": "#B3B26A",
"36.01": "#ADB67C",
"39.01": "#A4BE83",
"42.01": "#9CC694",
"45.01": "#94C99C",
"48.01": "#525551",
"51.01": "#525551",
"54.01": "#525551",
"57.01": "#525551",
"60.01": "#525551",
"63.01": "#525551",
"66.00": "#525551"
},
"name": "Reef"
},
"06": {
"anchorDot": "#F58909",
"bandFill": "#525551",
"bandText": "#000000",
"gHours": [
"#85961E",
"#556D01"
],
"gMinutes": [
"#769600",
"#556D01"
],
"colon": "#525551",
"battery-icon": "#C6FA03",
"battery-value": "#FFFFFF",
"sunrise-icon": "#C5FA01",
"sunrise-value": "#FFFFFF",
"weather-icon": "#C7FA03",
"weather-value": "#FFFFFF",
"dist-value": "#FFFFFF",
"dist-label": "#C6FA04",
"steps-value": "#FFFFFF",
"steps-label": "#C7FA03",
"ringTop": {
"6.50": "#9CCA0B",
"10.50": "#9CCF08",
"14.50": "#A4CE08",
"18.50": "#A4D208",
"22.50": "#A4D60A",
"26.50": "#A4D60A",
"30.50": "#ABDA06",
"34.50": "#ADDA00",
"38.50": "#ADDE02",
"42.50": "#ADDE02",
"46.50": "#B4E200",
"50.50": "#B4E600",
"54.50": "#B4E600",
"58.50": "#B4EA00",
"62.50": "#B4EA00",
"66.50": "#BDEE00",
"70.50": "#BDF101",
"74.50": "#BDF101",
"78.50": "#BEF600",
"82.50": "#C6F600",
"86.50": "#C5FA00"
},
"ringBottom": {
"6.00": "#C5FA00",
"9.00": "#C6F600",
"12.00": "#BEF600",
"15.00": "#BDF101",
"18.00": "#BDF101",
"21.00": "#BDEE00",
"24.00": "#B4EA00",
"27.00": "#B4EA00",
"30.00": "#B4E600",
"33.01": "#B4E600",
"36.01": "#B4E100",
"39.01": "#ACDE01",
"42.01": "#ADDE02",
"45.01": "#ADD900",
"48.01": "#525551",
"51.01": "#525551",
"54.01": "#525551",
"57.01": "#525551",
"60.01": "#525551",
"63.01": "#525551",
"66.00": "#525551"
},
"name": "Acid"
},
"07": {
"anchorDot": "#F58909",
"bandFill": "#525551",
"bandText": "#000000",
"gHours": [
"#935205",
"#844103"
],
"gMinutes": [
"#935205",
"#762E05"
],
"colon": "#525551",
"battery-icon": "#34C294",
"battery-value": "#FFFFFF",
"sunrise-icon": "#32C294",
"sunrise-value": "#FFFFFF",
"weather-icon": "#34C294",
"weather-value": "#FFFFFF",
"dist-value": "#FFFFFF",
"dist-label": "#34C294",
"steps-value": "#FFFFFF",
"steps-label": "#34C294",
"ringTop": {
"6.50": "#EE8910",
"10.50": "#DD8D17",
"14.50": "#D49117",
"18.50": "#CD911F",
"22.50": "#C59528",
"26.50": "#BE9931",
"30.50": "#B49D3A",
"34.50": "#AB9D39",
"38.50": "#A3A141",
"42.50": "#94A54A",
"46.50": "#8BA552",
"50.50": "#83AA5A",
"54.50": "#7BAA5A",
"58.50": "#73AE63",
"62.50": "#69B269",
"66.50": "#63B273",
"70.50": "#52B67C",
"74.50": "#4BBA82",
"78.50": "#42B983",
"82.50": "#39BD8B",
"86.50": "#31C293"
},
"ringBottom": {
"6.00": "#CD5008",
"9.00": "#C5550E",
"12.00": "#BD5A1A",
"15.00": "#B46118",
"18.00": "#AC651F",
"21.00": "#A46A29",
"24.00": "#9B712F",
"27.00": "#9C763A",
"30.00": "#94793A",
"33.01": "#8B8241",
"36.01": "#83854A",
"39.01": "#7A8952",
"42.01": "#74915A",
"45.01": "#6A955A",
"48.01": "#525551",
"51.01": "#525551",
"54.01": "#525551",
"57.01": "#525551",
"60.01": "#525551",
"63.01": "#525551",
"66.00": "#525551"
},
"name": "Kelp"
}
}

72
docs/README.md Normal file
View File

@@ -0,0 +1,72 @@
# 上架素材
Connect IQ Store 后台要填的东西都在这个目录里。代码不依赖这里的任何文件。
```
docs/
├── store-listing-zh.md 中文商店文案(名称 / 简介 / 详细描述 / 关键词 / 权限说明)
├── store-listing-en.md 英文商店文案
├── store-icon-1024.png 商店图标(大)
├── store-icon-512.png 商店图标(小)
└── screenshots/
├── 218x218.png ~ 454x454.png 八个屏宽各一张
└── theme-01-ember.png ~ theme-07-kelp.png 七套主题454px
```
## 截图是怎么来的
全部是 **Connect IQ 模拟器的真实渲染**,不是网页预览图 —— 字体是 Garmin 内置的
Roboto / Bionic和真机一致。`preview-themes.svg` 那张用的是网页字体,**不要**拿去
当商店截图。
每个屏宽各挑了一台代表机型:
| 尺寸 | 机型 | 尺寸 | 机型 |
|------|------|------|------|
| 218 | FR255S | 360 | Venu 2S |
| 240 | fenix 7S | 390 | Vivoactive 5 |
| 260 | fenix 7 | 416 | fenix 8 43mm |
| 280 | Enduro 3 | 454 | fenix 8 47mm |
截图里显示的数据位是 **日出 / 最高最低气温 / 心率 / 身体电量** ——
这四项在模拟器里有真实取值,不是默认配置(默认是日出 / 高低温 / 距离 / 步数)。
换掉后两项是因为模拟器灌不进步数和距离,默认配置截出来底部是 `0.0``0`
### ⚠️ 进度环是摆拍的
模拟器的「Edit Activity Monitor Info」对话框只能改活动分钟、恢复时间、呼吸和
压力,**改不了步数 / 距离 / 卡路里**(那几个输入框是 Java 自绘的表格,
脚本也够不着)。所以进度环在模拟器里永远是全灰的,截图没法看。
截图构建把四段弧固定成了设计稿的填充状态(右上 21/21、左上 10/21、
右下 9/21、左下 14/21。**除进度环外,画面上每个数字都是模拟器的真实取值**
没有伪造。
如果要出完全真实的截图,把 `.prg` 侧载到真机戴一天再拍。
## 重新生成
图标:
```bash
python3 tools/gen_icons.py # 顺带产出 store-icon 用的同款视觉
```
截图:本目录下的 PNG 是用模拟器抓的,流程见提交记录里的 `shot.sh` / `theme.sh`
(脚本在临时目录,没有入库 —— 它们依赖具体的窗口位置,换台机器就要重调)。
手工重做的话:编译 → `monkeydo` 载入 → 截模拟器窗口 → 按日期带定位裁出表盘圆形区。
## 后台还要填但不在这里的
- **分类**Watch Faces
- **支持的机型**55 款,以 `manifest.xml``<iq:products>` 为准
- **价格**:免费 / 付费自行决定
- **隐私政策**:如果启用 OpenWeatherMap需要说明会把**坐标**发给
`api.openweathermap.org`。不填 key 时完全不联网,这一点建议在描述里写清楚
- **联系邮箱 / 支持链接**
## ⚠️ 发布前必须先解决
表盘的视觉是对第三方 `watchface-kit` 设计稿的 1:1 还原(见仓库根目录的
`LICENSE``README.md` 第 13 节)。**以自己的名义上架前,需要先取得原作者
授权,或把视觉部分替换成原创设计。** 这是法律问题,不是技术问题。

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
docs/store-icon-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
docs/store-icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

90
docs/store-listing-en.md Normal file
View File

@@ -0,0 +1,90 @@
# Connect IQ Store listing (English)
## App name
```
Fenix 8 V3
```
## Short description
```
A dense data watch face: four progress arcs, four configurable slots, 156 data
fields to choose from, seven colour themes.
```
## Full description
```
Fenix 8 V3 packs a lot of information onto the dial without making it feel
crowded.
■ Four progress arcs
The rim is split into four independent arcs. Each one tracks a metric of your
choosing - steps, calories, distance, floors, move bar, active minutes or body
battery - so a glance tells you where the day stands. Every tick is
individually coloured, so the arc shifts hue as it fills.
■ Four data slots, 156 fields
Top-left, top-right, bottom-left and bottom-right. Each can show any of:
· Date & time seconds, UTC, two alternate time zones, week number
(ISO or calendar), custom time and date formats
· Activity steps, calories, distance, floors, ascent, active minutes,
plus week-to-date, 7-day and 28-day totals
· Body heart rate, resting HR, stress, pulse ox, respiration,
body battery, weight, BMI, recovery time, VO2 max
· System battery, battery in days, alarms, notifications, do not
disturb, bluetooth, solar intensity
· Environment altitude, pressure, sunrise/sunset, next sun event, moon age
and illumination, civil/nautical/astronomical twilight,
golden hour, blue hour
· Weather temperature, feels-like, high/low, humidity, precipitation,
wind speed and direction, UV index, visibility, dew point
· From watch next calendar event, training status, race and pace
predictors (5k/10k/half/marathon), weekly run and bike
distance, and complications published by your other apps
· Custom three custom text lines, event countdown
The captions under the bottom two slots follow whatever you pick - no separate
setting to keep in sync.
■ Seven themes
Ember, Aurora, Brass, Voltage, Reef, Acid and Kelp. Each carries its own arc
gradient, date band colours and time-digit gradient.
■ Always-on display
On AMOLED watches the face switches to a low-power layout while asleep: the
date band fill and the progress ring are dropped, the time and date are dimmed,
and the whole layout drifts a few pixels each minute to avoid burn-in.
■ OpenWeatherMap (optional)
Add your own OpenWeatherMap API key for 30 further weather fields, including
air quality (AQI, PM2.5, PM10, CO) and the next two days' forecast. Without a
key the watch face never touches the network.
■ Also
· English and Simplified Chinese - weekday, month and field captions all follow
the watch language
· Temperature unit can be forced to Celsius or Fahrenheit; distance and
12/24-hour follow the watch
· The bluetooth glyph dims when the phone is out of range
```
## Keywords / tags
```
data field, progress ring, complications, always-on, AMOLED, weather,
heart rate, body battery, sunrise sunset, chinese
```
## Permissions to explain to users
```
· UserProfile - resting heart rate, weight, VO2 max
· SensorHistory - pulse ox, body battery
· Communications + Background - only used to fetch weather, and only when an
OpenWeatherMap API key has been entered
· ComplicationSubscriber - calendar, training status, and data published by
other apps
```

75
docs/store-listing-zh.md Normal file
View File

@@ -0,0 +1,75 @@
# Connect IQ Store 上架文案(简体中文)
## 应用名称
```
Fenix 8 V3
```
## 一句话简介(短描述)
```
多指标数据表盘:四段进度环 + 四个可自由配置的数据位156 项数据任选,七套配色。
```
## 详细描述
```
Fenix 8 V3 是一款信息密度高、但不拥挤的数据表盘。
■ 四段进度环
表盘外圈是四段独立的进度弧,各自绑定一项指标(步数、卡路里、距离、楼层、
动动条、活动分钟、身体电量),一眼就能看出今天的完成度。每根刻度的颜色都
是渐变的,进度越高颜色越靠近弧段末端的色相。
■ 四个数据位156 项数据任选
左上、右上、左下、右下四格,每格都能从 156 项数据里任选:
· 时间日期 秒、UTC、第二时区 ×2、周数ISO / 日历)、自定义时间与日期格式
· 活动 步数、卡路里、距离、楼层、爬升、活动分钟,以及本周 / 近 7 天 /
近 28 天累计
· 身体 心率、静息心率、压力、血氧、呼吸、身体电量、体重、BMI、
恢复时间、最大摄氧量
· 系统 电量、剩余天数、闹钟、通知、勿扰、蓝牙、太阳能强度
· 环境 海拔、气压、日出日落、下一太阳事件、月龄月相,
以及民用 / 航海 / 天文晨昏蒙影、黄金时刻、蓝调时刻
· 天气 气温、体感、最高最低、湿度、降水概率、风速风向、紫外线、
能见度、露点
· 手表数据 下一个日程、训练状态、完赛与配速预测5k/10k/半马/全马)、
本周跑步与骑行距离、以及其它 App 发布的复杂功能
· 自定义 三段自定义文字、事件倒计时
底部两格的小标签会跟着所选数据自动变化,不用另外设置。
■ 七套配色
余烬、极光、黄铜、电压、珊瑚礁、酸绿、海藻。每套都有自己的进度环渐变、
日期带配色和时间数字渐变。
■ 常亮显示
AMOLED 机型支持常亮。常亮时自动切换到低功耗版面:去掉日期带底色和进度环,
时间与日期压暗,整个画面每分钟在几个像素内轻微游走以避免烧屏。
■ OpenWeatherMap选用
填入自己的 OpenWeatherMap API key 后,可额外获得 30 项天气数据,包括空气质量
AQI、PM2.5、PM10、CO与未来两日预报。不填则完全不联网。
■ 其它
· 简体中文 / 英文双语,表盘上的星期、月份、数据标签都会跟随手表语言
· 温度单位可强制摄氏或华氏,距离与时间制式跟随手表设置
· 手机断连时蓝牙图标自动压暗
```
## 关键词 / 标签
```
数据表盘, 进度环, 数据字段, 常亮, AMOLED, 天气, 心率, 身体电量, 日出日落, 中文
```
## 需要向用户说明的权限
```
· 用户档案 —— 用于静息心率、体重、最大摄氧量
· 传感器历史 —— 用于血氧、身体电量
· 通信与后台 —— 仅在填写了 OpenWeatherMap API key 时用于拉取天气
· 复杂功能订阅 —— 用于日程、训练状态、以及其它 App 发布的数据
```

View File

@@ -1,172 +0,0 @@
#!/usr/bin/env python3
# 纯标准库生成表盘运行效果 SVG复用 Fenix8V3View.mc 的坐标与主题色)
import math
THEMES = {
"01":{"name":"Ember","ringTop":[[8,0xFFF203],[15,0xFDDB0D],[25,0xFFC203],[35,0xFFA00A],[45,0xFF8705],[55,0xFF610C],[65,0xFF4904],[75,0xFF2602],[86,0xFF2604]],
"ringBottom":[[7,0xAE1706],[14,0xB32504],[21,0xC03B03],[28,0xCD6206],[35,0xD57805],[42,0xDE9208],[47,0xC98D00]],
"tickOff":0x545655,"anchorDot":0x525551,"bandFill":0xFEAA00,"bandText":0x000002,"accent":0xFCA901,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xFCA901},
"02":{"name":"Aurora","ringTop":[[8,0xA5FA11],[15,0x9EFA23],[25,0x97F84B],[35,0x8CF965],[45,0x89F87F],[55,0x74FB9F],[65,0x6DFCC6],[75,0x64FCDF],[86,0x57FDFF]],
"ringBottom":[[7,0x55FDFF],[14,0x64FCDF],[21,0x6FFCC6],[28,0x74FBAE],[35,0x7EFA85],[42,0x88F96D],[47,0x81E654]],
"tickOff":0x545655,"anchorDot":0x0F3C82,"bandFill":0x525551,"bandText":0xFFFFFF,"accent":0xABA9AC,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xAAF701},
"03":{"name":"Brass","ringTop":[[8,0xB4814F],[15,0xB88954],[25,0xC5975C],[35,0xC59E65],[45,0xCBA775],[55,0xD7AE7F],[65,0xDEBC84],[75,0xE7C48D],[86,0xF0D097]],
"ringBottom":[[7,0xB97E4A],[14,0xB68954],[21,0xBD925E],[28,0xC69B67],[35,0xCEA26C],[42,0xD9AB74],[47,0xC59E70]],
"tickOff":0x3A2815,"anchorDot":0x62441E,"bandFill":0x392810,"bandText":0xFFFFFF,"accent":0xB37C4A,"textPrimary":0xEBCB93,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xECCC93},
"04":{"name":"Voltage","ringTop":[[8,0x5CAFF0],[15,0x6EB6E0],[25,0x7FBEC5],[35,0x96CA9D],[45,0xAAD87F],[55,0xBFDF66],[65,0xD6EA40],[75,0xE8F227],[86,0xFFFB0A]],
"ringBottom":[[7,0xF7FA11],[14,0xE9F225],[21,0xD6EB3F],[28,0xC0DE65],[35,0xADD880],[42,0xA0CF98],[47,0x88B98E]],
"tickOff":0x545655,"anchorDot":0x53AAFF,"bandFill":0x53AAFF,"bandText":0x000000,"accent":0xABA9AC,"textPrimary":0x51A7FA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0x52A8FD},
"05":{"name":"Reef","ringTop":[[8,0xF87E0D],[15,0xE78E25],[25,0xCE9B4D],[35,0xBDAC65],[45,0xA5BE88],[55,0x95CA9E],[65,0x7EDEC7],[75,0x6DEBE1],[86,0x57FDFF]],
"ringBottom":[[7,0xFF7905],[14,0xE78B29],[21,0xD59C3E],[28,0xBEAC63],[35,0xADB77F],[42,0xA0C796],[47,0x88B98E]],
"tickOff":0x545655,"anchorDot":0xFE7900,"bandFill":0x52FDFF,"bandText":0x000000,"accent":0xFCA901,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xFC7801},
"06":{"name":"Acid","ringTop":[[8,0x9CCC14],[15,0xA9CE0C],[25,0xA4D610],[35,0xACDB08],[45,0xB4E107],[55,0xB5E603],[65,0xBFEE04],[75,0xBDF108],[86,0xC7F808]],
"ringBottom":[[7,0xC7FA02],[14,0xBEF20A],[21,0xC0ED04],[28,0xB4E905],[35,0xB4E107],[42,0xADDD0C],[47,0x9BC603]],
"tickOff":0x545655,"anchorDot":0xF58909,"bandFill":0x525551,"bandText":0x000000,"accent":0xC4F801,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xDBF731,"minutes":0xC4F900},
"07":{"name":"Kelp","ringTop":[[8,0xEF8917],[15,0xD8901C],[25,0xBD9A34],[35,0xAD9E3C],[45,0x96A54E],[55,0x7DAE5D],[65,0x61B474],[75,0x4EBB84],[86,0x36C294]],
"ringBottom":[[7,0xCE520F],[14,0xB1631D],[21,0xA26A2F],[28,0x9D763E],[35,0x85874B],[42,0x76915D],[47,0x5E8651]],
"tickOff":0x545655,"anchorDot":0xF58909,"bandFill":0x525551,"bandText":0x000000,"accent":0x31C192,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xF28708,"minutes":0xF4880A},
}
ORDER=["01","02","03","04","05","06","07"]
TOP=[a for a in range(2,87,4)]; BOT=[a for a in range(6,67,3)]
DOW=["SUN","MON","TUE","WED","THU","FRI","SAT"]; MON=["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"]
def hx(c): return "#%06x"%(c&0xffffff)
def lerp(c1,c2,f):
r1=(c1>>16)&255;g1=(c1>>8)&255;b1=c1&255
r2=(c2>>16)&255;g2=(c2>>8)&255;b2=c2&255
return ((round(r1+(r2-r1)*f))<<16)|((round(g1+(g2-g1)*f))<<8)|round(b1+(b2-b1)*f)
def lerp_stops(stops,t):
n=len(stops)
if not n: return 0x888888
if t<=stops[0][0]: return stops[0][1]
if t>=stops[-1][0]: return stops[-1][1]
for i in range(n-1):
a,b=stops[i],stops[i+1]
if a[0]<=t<=b[0]:
return lerp(a[1],b[1],(t-a[0])/(b[0]-a[0]))
return stops[-1][1]
def tick(cx,cy,rIn,rOut,w,ang,col,mirror):
eff=-ang if mirror else ang; rad=math.radians(eff); s=math.sin(rad); c=math.cos(rad)
return f'<line x1="{cx+s*rIn:.2f}" y1="{cy-c*rIn:.2f}" x2="{cx+s*rOut:.2f}" y2="{cy-c*rOut:.2f}" stroke="{hx(col)}" stroke-width="{w}" stroke-linecap="round"/>'
def ring(theme,pTR,pTL,pBR,pBL):
out=[]
rOut=249.5;rIn=231.5;off=theme["tickOff"];ts=theme["ringTop"];bs=theme["ringBottom"]
topW=13;botW=9
litTR=round(pTR*22);litTL=round(pTL*22);litBR=round(pBR*21);litBL=round(pBL*21)
# TR anchorOuter, mirror=false
for i,a in enumerate(TOP):
on=i>=(22-litTR); col=lerp_stops(ts,a) if on else off
out.append(tick(250,250,rIn,rOut,topW,a,col,False))
# TL anchorOuter, mirror=true
for i,a in enumerate(TOP):
on=i>=(22-litTL); col=lerp_stops(ts,a) if on else off
out.append(tick(250,250,rIn,rOut,topW,a,col,True))
# BL mirror=true
for i,a in enumerate(BOT):
col=lerp_stops(bs,a) if i<litBL else off
out.append(tick(250,250,rIn,rOut,botW,180-a,col,True))
# BR mirror=false
for i,a in enumerate(BOT):
col=lerp_stops(bs,a) if i<litBR else off
out.append(tick(250,250,rIn,rOut,botW,180-a,col,False))
# anchor dots
dc=theme["anchorDot"];dr=9.5;drad=240.5
for da in [0,93.3,180,266.7]:
rad=math.radians(da)
out.append(f'<circle cx="{250+math.sin(rad)*drad:.2f}" cy="{250-math.cos(rad)*drad:.2f}" r="{dr}" fill="{hx(dc)}"/>')
return "".join(out)
def batt(theme,pct):
y=53.5;gw=30;gh=16;gx=250-52;gy=y-gh/2;ac=theme["accent"];tp=theme["textPrimary"]
fill_w=(gw-4)*pct/100
return (f'<rect x="{gx}" y="{gy}" width="{gw}" height="{gh}" fill="none" stroke="{hx(ac)}" stroke-width="2"/>'
f'<rect x="{gx+gw}" y="{gy+gh*0.28}" width="3" height="{gh*0.44}" fill="{hx(ac)}"/>'
f'<rect x="{gx+2}" y="{gy+2}" width="{fill_w:.2f}" height="{gh-4}" fill="{hx(ac)}"/>'
f'<text x="{250+16}" y="{y}" fill="{hx(tp)}" font-size="16" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{round(pct)}%</text>')
def complic_icon(x,y,r,col,t):
c=hx(col)
if t==0: # sun
s=f'<circle cx="{x}" cy="{y}" r="{r*0.45}" fill="{c}"/>'
for i in range(8):
rad=math.radians(i*45)
s+=f'<line x1="{x+math.sin(rad)*r*0.6:.2f}" y1="{y-math.cos(rad)*r*0.6:.2f}" x2="{x+math.sin(rad)*r:.2f}" y2="{y-math.cos(rad)*r:.2f}" stroke="{c}" stroke-width="2"/>'
return s
if t==1: # flame
return f'<polygon points="{x},{y-r} {x+r*0.8:.2f},{y+r*0.6:.2f} {x-r*0.8:.2f},{y+r*0.6:.2f}" fill="{c}"/>'
if t==2: # steps
return (f'<line x1="{x-r*0.5:.2f}" y1="{y-r*0.3:.2f}" x2="{x+r*0.5:.2f}" y2="{y-r*0.3:.2f}" stroke="{c}" stroke-width="2"/>'
f'<line x1="{x-r*0.3:.2f}" y1="{y}" x2="{x+r*0.6:.2f}" y2="{y}" stroke="{c}" stroke-width="2"/>')
if t==3: # dot
return f'<circle cx="{x}" cy="{y}" r="{r*0.5}" fill="{c}"/>'
if t==4: # chevron
return f'<polygon points="{x},{y-r} {x+r*0.7:.2f},{y+r*0.2:.2f} {x-r*0.7:.2f},{y+r*0.2:.2f}" fill="{c}"/>'
return f'<rect x="{x-r*0.6:.2f}" y="{y-r*0.4:.2f}" width="{r*1.2:.2f}" height="{r*0.8:.2f}" fill="none" stroke="{c}" stroke-width="2"/>'
def top_comp(theme,t,isLeft,val):
sx=156 if isLeft else 343; iy=90; vy=130.5
return (f'<text x="{sx}" y="{vy}" fill="{hx(theme["textPrimary"])}" font-size="22" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{val}</text>'
+complic_icon(sx,iy,13,theme["accent"],t))
def bluetooth(x,y,r,col):
c=hx(col)
return (f'<path d="M{x},{y-r} L{x},{y+r} M{x},{y-r} L{x+r*0.7:.2f},{y-r*0.3:.2f} L{x},{y} '
f'L{x+r*0.7:.2f},{y+r*0.3:.2f} L{x},{y+r}" fill="none" stroke="{c}" stroke-width="3" stroke-linejoin="round"/>')
def date_band(theme,gi):
by=286.5;bh=44;yc=308.5;bf=theme["bandFill"];bt=theme["bandText"]
dow=DOW[gi["dow"]-1]; md=MON[gi["m"]-1]+" "+str(gi["day"])
return (f'<rect x="0" y="{by}" width="500" height="{bh}" fill="{hx(bf)}"/>'
+bluetooth(36.5,yc,10,bt)
+f'<text x="177.5" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{dow}</text>'
+f'<text x="297.5" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{md}</text>'
+f'<text x="465" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{"PM" if gi["h"]>=12 else "AM"}</text>')
def time(theme,gi):
h=gi["h"]; h=12 if h==0 else (h-12 if h>12 else h)
hs=str(h); ms="%02d"%gi["min"]; ty=220
return (f'<text x="211" y="{ty}" fill="{hx(theme["hours"])}" font-size="66" font-weight="700" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{hs}</text>'
f'<text x="272" y="{ty}" fill="{hx(theme["minutes"])}" font-size="66" font-weight="700" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{ms}</text>'
f'<rect x="238" y="184" width="24" height="20" fill="{hx(theme["colon"])}"/>'
f'<rect x="238" y="235" width="24" height="20" fill="{hx(theme["colon"])}"/>')
def bottom(theme,dist,steps):
vy=366;ly=410.5;lx=234;rx=266;tp=theme["textPrimary"];ac=theme["accent"]
return (f'<text x="{lx}" y="{vy}" fill="{hx(tp)}" font-size="22" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{dist}</text>'
f'<text x="{lx}" y="{ly}" fill="{hx(ac)}" font-size="16" font-family="sans-serif" text-anchor="end" dominant-baseline="central">DST</text>'
f'<text x="{rx}" y="{vy}" fill="{hx(tp)}" font-size="22" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{steps}</text>'
f'<text x="{rx}" y="{ly}" fill="{hx(ac)}" font-size="16" font-family="sans-serif" text-anchor="start" dominant-baseline="central">STEP</text>')
def face(theme_id,m,battery,temp,gi):
th=THEMES[theme_id]
p=[m[0],m[1],m[2],m[3],m[4]]
idxTR,idxTL,idxBR,idxBL=0,1,4,3
s=[]
s.append(f'<rect x="0" y="0" width="500" height="500" fill="#000"/>')
s.append(ring(th,p[idxTR],p[idxTL],p[idxBR],p[idxBL]))
s.append(batt(th,battery))
s.append(top_comp(th,1,True,f'{round(m[1]*2000):,}')) # 左上 卡路里
s.append(top_comp(th,0,False,f'{temp}\u00b0')) # 右上 天气
s.append(time(th,gi))
s.append(date_band(th,gi))
s.append(bottom(th,f'{m[2]*10:.1f}',f'{round(m[0]*10000):,}'))
return "".join(s)
# 七主题合成图:单排行,每格 230px圆形裁切
m=[0.84,0.64,0.65,0.80,0.40]; battery=78; temp=23
gi={"h":10,"min":8,"day":9,"m":9,"dow":3}
cell=230; gap=24; n=len(ORDER); W=n*cell+(n+1)*gap; H=cell+70
parts=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">']
parts.append(f'<rect width="{W}" height="{H}" fill="#0a0a0a"/>')
for i,tid in enumerate(ORDER):
ox=gap+i*(cell+gap); oy=24
sc=cell/500.0
parts.append(f'<defs><clipPath id="c{i}"><circle cx="{ox+cell/2}" cy="{oy+cell/2}" r="{cell/2-2}"/></clipPath></defs>')
parts.append(f'<g clip-path="url(#c{i})"><g transform="translate({ox},{oy}) scale({sc:.4f})">'+face(tid,m,battery,temp,gi)+'</g></g>')
parts.append(f'<text x="{ox+cell/2}" y="{oy+cell+22}" fill="#cfccc4" font-size="15" font-family="sans-serif" text-anchor="middle">{i+1} · {THEMES[tid]["name"]}</text>')
parts.append('</svg>')
svg="\n".join(parts)
with open("/Users/ericwyuan/Desktop/Work/fenix8v3-watchface/preview-themes.svg","w") as f:
f.write(svg)
print("written preview-themes.svg", len(svg), "bytes; size", W,"x",H)

View File

@@ -7,26 +7,64 @@
minSdkVersion="4.2.0" minSdkVersion="4.2.0"
name="@Strings.AppName" name="@Strings.AppName"
type="watchface" type="watchface"
version="1.0.0"> version="1.1.0">
<iq:products> <iq:products>
<iq:product id="approachs50"/>
<iq:product id="approachs7042mm"/>
<iq:product id="approachs7047mm"/>
<iq:product id="d2airx10"/>
<iq:product id="d2mach1"/>
<iq:product id="d2mach2"/>
<iq:product id="descentg2"/>
<iq:product id="descentmk343mm"/>
<iq:product id="descentmk351mm"/>
<iq:product id="enduro3"/> <iq:product id="enduro3"/>
<iq:product id="epix2"/>
<iq:product id="epix2pro42mm"/>
<iq:product id="epix2pro47mm"/>
<iq:product id="epix2pro51mm"/>
<iq:product id="fenix7"/>
<iq:product id="fenix7pro"/>
<iq:product id="fenix7pronowifi"/>
<iq:product id="fenix7s"/>
<iq:product id="fenix7spro"/>
<iq:product id="fenix7x"/>
<iq:product id="fenix7xpro"/>
<iq:product id="fenix7xpronowifi"/>
<iq:product id="fenix843mm"/> <iq:product id="fenix843mm"/>
<iq:product id="fenix847mm"/> <iq:product id="fenix847mm"/>
<iq:product id="fenix8pro47mm"/> <iq:product id="fenix8pro47mm"/>
<iq:product id="fenix8solar47mm"/> <iq:product id="fenix8solar47mm"/>
<iq:product id="fenix8solar51mm"/> <iq:product id="fenix8solar51mm"/>
<iq:product id="epix2"/> <iq:product id="fenixe"/>
<iq:product id="epix2pro47mm"/> <iq:product id="fr165"/>
<iq:product id="epix2pro51mm"/> <iq:product id="fr165m"/>
<iq:product id="fenix7"/>
<iq:product id="fenix7x"/>
<iq:product id="fenix7s"/>
<iq:product id="fr965"/>
<iq:product id="fr265"/>
<iq:product id="fr255"/> <iq:product id="fr255"/>
<iq:product id="fr255m"/>
<iq:product id="fr255s"/>
<iq:product id="fr255sm"/>
<iq:product id="fr265"/>
<iq:product id="fr265s"/>
<iq:product id="fr57042mm"/>
<iq:product id="fr57047mm"/>
<iq:product id="fr955"/>
<iq:product id="fr965"/>
<iq:product id="fr970"/>
<iq:product id="instinct3amoled45mm"/>
<iq:product id="instinct3amoled50mm"/>
<iq:product id="instinctcrossoveramoled"/>
<iq:product id="marq2"/>
<iq:product id="marq2aviator"/>
<iq:product id="venu2"/>
<iq:product id="venu2plus"/>
<iq:product id="venu2s"/>
<iq:product id="venu3"/> <iq:product id="venu3"/>
<iq:product id="venu3s"/>
<iq:product id="venu441mm"/>
<iq:product id="venu445mm"/>
<iq:product id="vivoactive5"/> <iq:product id="vivoactive5"/>
<iq:product id="vivoactive6"/>
</iq:products> </iq:products>
<iq:languages> <iq:languages>
@@ -34,5 +72,15 @@
<iq:language>eng</iq:language> <iq:language>eng</iq:language>
</iq:languages> </iq:languages>
<!-- Sensor is rejected for type="watchface" unless Background is also
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="ComplicationSubscriber"/>
<iq:uses-permission id="SensorHistory"/>
</iq:permissions>
</iq:application> </iq:application>
</iq:manifest> </iq:manifest>

View File

@@ -1,9 +1,69 @@
project.manifest = manifest.xml project.manifest = manifest.xml
base.sourcePath = source base.sourcePath = source
base.resourcePath = resources
base.excludeAnnotations = release
# Uncomment and adjust once you have SDK devices installed, e.g.: # resources-chn 是简体中文文案;目录名的 -chn 后缀就是语言标记。
# fenix8.resourcePath = resources;resources-round-amoled base.resourcePath = resources;resources-chn
# fenix7.resourcePath = resources;resources-round-mip
# 下面每台设备再挂两棵资源树,都由 tools/gen_icons.py 生成:
# resources-icons-<屏宽> 按该屏幕像素网格光栅化的日出/日落/天气图标
# resources-launcher-<尺寸> 该机型要求尺寸的启动图标
# 这一整段由 tools/gen_devices.py 生成,不要手改。
approachs50.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-56
approachs7042mm.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-60
approachs7047mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-70
d2airx10.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-70
d2mach1.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
d2mach2.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
descentg2.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-60
descentmk343mm.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-60
descentmk351mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-60
enduro3.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
epix2.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
epix2pro42mm.resourcePath = $(base.resourcePath);resources-icons-390;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
fenix7pro.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fenix7pronowifi.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fenix7s.resourcePath = $(base.resourcePath);resources-icons-240;resources-launcher-40
fenix7spro.resourcePath = $(base.resourcePath);resources-icons-240;resources-launcher-40
fenix7x.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
fenix7xpro.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
fenix7xpronowifi.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
fenixe.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
fr165.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-54
fr165m.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-54
fr255.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fr255m.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fr255s.resourcePath = $(base.resourcePath);resources-icons-218;resources-launcher-40
fr255sm.resourcePath = $(base.resourcePath);resources-icons-218;resources-launcher-40
fr265.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
fr265s.resourcePath = $(base.resourcePath);resources-icons-360;resources-launcher-60
fr57042mm.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-54
fr57047mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
fr955.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fr965.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
fr970.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
instinct3amoled45mm.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-60
instinct3amoled50mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
instinctcrossoveramoled.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-38
marq2.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-60
marq2aviator.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-60
venu2.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-70
venu2plus.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-70
venu2s.resourcePath = $(base.resourcePath);resources-icons-360;resources-launcher-61
venu3.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-70
venu3s.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-70
venu441mm.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-54
venu445mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
vivoactive5.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-56
vivoactive6.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-54
base.excludeAnnotations = release

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,170 @@
<strings>
<!-- Field names shown in the settings picker. -->
<string id="FldOff">关闭</string>
<string id="FldTime">时间</string>
<string id="FldUtcTime">UTC 时间</string>
<string id="FldAltTime1">第二时区 1</string>
<string id="FldAltTime2">第二时区 2</string>
<string id="FldDayMonthDay">星期 月 日</string>
<string id="FldDayDate">星期 日</string>
<string id="FldMonthDay">月 日</string>
<string id="FldDayOfMonth"></string>
<string id="FldDayShort">星期简写</string>
<string id="FldDayFull">星期全称</string>
<string id="FldMonthName">月份</string>
<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="FldCustomTime">自定义时间格式</string>
<string id="FldDateFmt1">日期格式 1</string>
<string id="FldDateFmt2">日期格式 2</string>
<string id="FldCountdown">事件倒计时</string>
<string id="FldSteps">步数</string>
<string id="FldStepsRound">步数 (取整)</string>
<string id="FldStepsToGo">剩余步数</string>
<string id="FldCalories">卡路里</string>
<string id="FldActiveCal">活动卡路里</string>
<string id="FldActiveMin">活动分钟</string>
<string id="FldActiveMod">中强度分钟</string>
<string id="FldActiveVig">高强度分钟</string>
<string id="FldActiveWeek">本周活动分钟</string>
<string id="FldDistance">距离</string>
<string id="FldFloorsUp">上楼层数</string>
<string id="FldFloorsDown">下楼层数</string>
<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="FldSteps28d">近 28 天步数</string>
<string id="FldCal28d">近 28 天卡路里</string>
<string id="FldDist28d">近 28 天距离</string>
<string id="FldFloors28d">近 28 天楼层</string>
<string id="FldHeartRate">心率</string>
<string id="FldRestingHr">静息心率</string>
<string id="FldStress">压力指数</string>
<string id="FldPulseOx">血氧</string>
<string id="FldRespiration">呼吸频率</string>
<string id="FldBodyBattery">身体电量</string>
<string id="FldMoveBar">活动条</string>
<string id="FldMoveBarRev">活动条 (反向)</string>
<string id="FldWeight">体重</string>
<string id="FldBmi">BMI</string>
<string id="FldRecovery">恢复时间</string>
<string id="FldVo2Run">最大摄氧量 (跑)</string>
<string id="FldVo2Bike">最大摄氧量 (骑)</string>
<string id="FldBattery">电量</string>
<string id="FldBatteryDays">剩余天数</string>
<string id="FldBatteryBoth">电量 / 天数</string>
<string id="FldAlarms">闹钟</string>
<string id="FldNotifications">通知</string>
<string id="FldDoNotDisturb">勿扰模式</string>
<string id="FldBluetooth">蓝牙</string>
<string id="FldSolar">太阳能强度</string>
<string id="FldAltitude">海拔</string>
<string id="FldSeaPressure">海平面气压</string>
<string id="FldAmbPressure">环境气压</string>
<string id="FldSunrise">日出</string>
<string id="FldSunset">日落</string>
<string id="FldNextSun">下一太阳事件</string>
<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>
<string id="FldTempHigh">最高气温</string>
<string id="FldTempLow">最低气温</string>
<string id="FldHumidity">湿度</string>
<string id="FldPrecip">降水概率</string>
<string id="FldWindSpeed">风速</string>
<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="FldNextCal">下一个日程</string>
<string id="FldTrainStatus">训练状态</string>
<string id="FldRace5k">5k 完赛预测</string>
<string id="FldRace10k">10k 完赛预测</string>
<string id="FldRaceHalf">半马完赛预测</string>
<string id="FldRaceFull">全马完赛预测</string>
<string id="FldPace5k">5k 配速预测</string>
<string id="FldPace10k">10k 配速预测</string>
<string id="FldPaceHalf">半马配速预测</string>
<string id="FldPaceFull">全马配速预测</string>
<string id="FldGolfScore">最近高尔夫得分</string>
<string id="FldRunWeek">本周跑步距离</string>
<string id="FldBikeWeek">本周骑行距离</string>
<string id="FldApp1">第三方 App 1</string>
<string id="FldApp2">第三方 App 2</string>
<string id="FldApp3">第三方 App 3</string>
<string id="FldApp4">第三方 App 4</string>
<string id="FldApp5">第三方 App 5</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="FldOwmAqi">OWM 空气质量</string>
<string id="FldOwmAqiText">OWM 空气质量描述</string>
<string id="FldOwmPm25">OWM PM2.5</string>
<string id="FldOwmPm10">OWM PM10</string>
<string id="FldOwmCo">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,日期,日期,日期,日,星期,星期,月,月,,周,秒,秒,周,时间,日期,日期,倒计时,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,周步数,7天,周千卡,7天,周距离,7天,周楼层,7天,7天,28天,28天,28天,28天,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,民用晨,航海晨,天文晨,蓝调晨,黄金晨,黄金昏,蓝调昏,民用昏,航海昏,天文昏,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,气压,紫外线,能见度,露点,日程,训练,5K,10K,半马,全马,5K配速,10K配速,半马配速,全马配速,高尔夫,跑步,骑行,App1,App2,App3,App4,App5,气温,高低温,体感,最高,最低,天气,天气,湿度,气压,能见度,风速,风向,风向,阵风,降雨,降雪,云量,降水,露点,明日,明日,后日,后日,空气,空气,PM2.5,PM10,CO,城市,更新,,,</string>
<string id="MetricSteps">步数</string>
<string id="MetricCalories">卡路里</string>
<string id="MetricDistance">距离</string>
<string id="MetricFloors">楼层</string>
<string id="MetricMoveBar">动动条</string>
<string id="MetricActiveMin">活动分钟</string>
<string id="MetricBodyBattery">身体电量</string>
</strings>

View File

@@ -0,0 +1,45 @@
<strings>
<string id="AppName">Fenix 8 V3</string>
<string id="SettingTheme">主题</string>
<string id="SettingRingTR">进度环 · 右上</string>
<string id="SettingRingTL">进度环 · 左上</string>
<string id="SettingRingBR">进度环 · 右下</string>
<string id="SettingRingBL">进度环 · 左下</string>
<string id="SettingLeftTop">左上数据</string>
<string id="SettingRightTop">右上数据</string>
<string id="SettingBottomLeft">左下数据</string>
<string id="SettingBottomRight">右下数据</string>
<string id="SettingTempUnit">温度单位</string>
<string id="SettingAltOffset1">第二时区 1相对本地分钟数</string>
<string id="SettingAltOffset2">第二时区 2相对本地分钟数</string>
<string id="SettingCountdownDate">倒计时目标日期YYYY-MM-DD</string>
<string id="SettingTimeFmt">自定义时间格式h m a</string>
<string id="SettingDateFmt1">日期格式 1D M Y w</string>
<string id="SettingDateFmt2">日期格式 2D M Y w</string>
<string id="SettingOwmKey">OpenWeatherMap API 密钥</string>
<string id="SettingCustom1">自定义文字 1</string>
<string id="SettingCustom2">自定义文字 2</string>
<string id="SettingCustom3">自定义文字 3</string>
<string id="SettingShowTop">显示顶部一行</string>
<string id="SettingShowBand">显示日期带</string>
<string id="SettingShowBottom">显示底部一行</string>
<string id="TempUnitAuto">跟随手表</string>
<string id="TempUnitC">摄氏度 (°C)</string>
<string id="TempUnitF">华氏度 (°F)</string>
<string id="ThemeEmber">1 · 余烬</string>
<string id="ThemeAurora">2 · 极光</string>
<string id="ThemeBrass">3 · 黄铜</string>
<string id="ThemeVoltage">4 · 电压</string>
<string id="ThemeReef">5 · 珊瑚礁</string>
<string id="ThemeAcid">6 · 酸绿</string>
<string id="ThemeKelp">7 · 海藻</string>
<!-- 表盘上的文案。逗号分隔的列表在运行时拆分。 -->
<string id="DowNames">周日,周一,周二,周三,周四,周五,周六</string>
<string id="MonNames">1月,2月,3月,4月,5月,6月,7月,8月,9月,10月,11月,12月</string>
<string id="DateFormat">$1$$2$日</string>
<string id="Meridiem">上午,下午</string>
</strings>

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

View File

@@ -0,0 +1,5 @@
<drawables>
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,3 @@
<drawables>
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
</drawables>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 B

View File

@@ -4,8 +4,23 @@
<property id="RingTL" type="number">1</property> <property id="RingTL" type="number">1</property>
<property id="RingBR" type="number">4</property> <property id="RingBR" type="number">4</property>
<property id="RingBL" type="number">3</property> <property id="RingBL" type="number">3</property>
<property id="LeftTop" type="number">1</property> <!-- Field ids follow https://watchface.io/docs/datafields; the defaults are
<property id="RightTop" type="number">0</property> the comps: sunrise, forecast high/low, distance, steps. -->
<property id="LeftTop" type="number">712</property>
<property id="RightTop" type="number">639</property>
<property id="BottomLeft" type="number">3</property>
<property id="BottomRight" type="number">1</property>
<property id="TempUnit" type="number">0</property>
<property id="AltOffset1" type="number">0</property>
<property id="AltOffset2" type="number">0</property>
<property id="CountdownDate" type="string"></property>
<property id="TimeFmt" type="string">h:m a</property>
<property id="DateFmt1" type="string">D/M</property>
<property id="DateFmt2" type="string">w D M</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>
<property id="ShowTop" type="boolean">true</property> <property id="ShowTop" type="boolean">true</property>
<property id="ShowBand" type="boolean">true</property> <property id="ShowBand" type="boolean">true</property>
<property id="ShowBottom" type="boolean">true</property> <property id="ShowBottom" type="boolean">true</property>

View File

@@ -1,4 +1,5 @@
<settings> <settings>
<setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme"> <setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme">
<settingConfig type="list"> <settingConfig type="list">
<listEntry value="1">@Strings.ThemeEmber</listEntry> <listEntry value="1">@Strings.ThemeEmber</listEntry>
@@ -11,6 +12,650 @@
</settingConfig> </settingConfig>
</setting> </setting>
<setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop">
<settingConfig type="list">
<listEntry value="0">@Strings.FldOff</listEntry>
<listEntry value="860">@Strings.FldTime</listEntry>
<listEntry value="880">@Strings.FldUtcTime</listEntry>
<listEntry value="864">@Strings.FldAltTime1</listEntry>
<listEntry value="865">@Strings.FldAltTime2</listEntry>
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
<listEntry value="872">@Strings.FldDayDate</listEntry>
<listEntry value="852">@Strings.FldMonthDay</listEntry>
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
<listEntry value="870">@Strings.FldDayShort</listEntry>
<listEntry value="856">@Strings.FldDayFull</listEntry>
<listEntry value="853">@Strings.FldMonthName</listEntry>
<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="861">@Strings.FldCustomTime</listEntry>
<listEntry value="862">@Strings.FldDateFmt1</listEntry>
<listEntry value="863">@Strings.FldDateFmt2</listEntry>
<listEntry value="735">@Strings.FldCountdown</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry>
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
<listEntry value="2">@Strings.FldCalories</listEntry>
<listEntry value="603">@Strings.FldActiveCal</listEntry>
<listEntry value="150">@Strings.FldActiveMin</listEntry>
<listEntry value="152">@Strings.FldActiveMod</listEntry>
<listEntry value="154">@Strings.FldActiveVig</listEntry>
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
<listEntry value="3">@Strings.FldDistance</listEntry>
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
<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="828">@Strings.FldSteps28d</listEntry>
<listEntry value="832">@Strings.FldCal28d</listEntry>
<listEntry value="836">@Strings.FldDist28d</listEntry>
<listEntry value="840">@Strings.FldFloors28d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry>
<listEntry value="635">@Strings.FldPulseOx</listEntry>
<listEntry value="11">@Strings.FldRespiration</listEntry>
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
<listEntry value="8">@Strings.FldMoveBar</listEntry>
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
<listEntry value="281">@Strings.FldWeight</listEntry>
<listEntry value="742">@Strings.FldBmi</listEntry>
<listEntry value="13">@Strings.FldRecovery</listEntry>
<listEntry value="278">@Strings.FldVo2Run</listEntry>
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
<listEntry value="250">@Strings.FldBattery</listEntry>
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
<listEntry value="253">@Strings.FldAlarms</listEntry>
<listEntry value="255">@Strings.FldNotifications</listEntry>
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
<listEntry value="256">@Strings.FldBluetooth</listEntry>
<listEntry value="252">@Strings.FldSolar</listEntry>
<listEntry value="615">@Strings.FldAltitude</listEntry>
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
<listEntry value="712">@Strings.FldSunrise</listEntry>
<listEntry value="717">@Strings.FldSunset</listEntry>
<listEntry value="700">@Strings.FldNextSun</listEntry>
<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>
<listEntry value="301">@Strings.FldTempHigh</listEntry>
<listEntry value="302">@Strings.FldTempLow</listEntry>
<listEntry value="305">@Strings.FldHumidity</listEntry>
<listEntry value="304">@Strings.FldPrecip</listEntry>
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
<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="612">@Strings.FldNextCal</listEntry>
<listEntry value="626">@Strings.FldTrainStatus</listEntry>
<listEntry value="627">@Strings.FldRace5k</listEntry>
<listEntry value="628">@Strings.FldRace10k</listEntry>
<listEntry value="629">@Strings.FldRaceHalf</listEntry>
<listEntry value="630">@Strings.FldRaceFull</listEntry>
<listEntry value="631">@Strings.FldPace5k</listEntry>
<listEntry value="632">@Strings.FldPace10k</listEntry>
<listEntry value="633">@Strings.FldPaceHalf</listEntry>
<listEntry value="634">@Strings.FldPaceFull</listEntry>
<listEntry value="641">@Strings.FldGolfScore</listEntry>
<listEntry value="619">@Strings.FldRunWeek</listEntry>
<listEntry value="620">@Strings.FldBikeWeek</listEntry>
<listEntry value="695">@Strings.FldApp1</listEntry>
<listEntry value="696">@Strings.FldApp2</listEntry>
<listEntry value="697">@Strings.FldApp3</listEntry>
<listEntry value="698">@Strings.FldApp4</listEntry>
<listEntry value="699">@Strings.FldApp5</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="538">@Strings.FldOwmAqi</listEntry>
<listEntry value="539">@Strings.FldOwmAqiText</listEntry>
<listEntry value="540">@Strings.FldOwmPm25</listEntry>
<listEntry value="541">@Strings.FldOwmPm10</listEntry>
<listEntry value="542">@Strings.FldOwmCo</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>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop">
<settingConfig type="list">
<listEntry value="0">@Strings.FldOff</listEntry>
<listEntry value="860">@Strings.FldTime</listEntry>
<listEntry value="880">@Strings.FldUtcTime</listEntry>
<listEntry value="864">@Strings.FldAltTime1</listEntry>
<listEntry value="865">@Strings.FldAltTime2</listEntry>
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
<listEntry value="872">@Strings.FldDayDate</listEntry>
<listEntry value="852">@Strings.FldMonthDay</listEntry>
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
<listEntry value="870">@Strings.FldDayShort</listEntry>
<listEntry value="856">@Strings.FldDayFull</listEntry>
<listEntry value="853">@Strings.FldMonthName</listEntry>
<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="861">@Strings.FldCustomTime</listEntry>
<listEntry value="862">@Strings.FldDateFmt1</listEntry>
<listEntry value="863">@Strings.FldDateFmt2</listEntry>
<listEntry value="735">@Strings.FldCountdown</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry>
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
<listEntry value="2">@Strings.FldCalories</listEntry>
<listEntry value="603">@Strings.FldActiveCal</listEntry>
<listEntry value="150">@Strings.FldActiveMin</listEntry>
<listEntry value="152">@Strings.FldActiveMod</listEntry>
<listEntry value="154">@Strings.FldActiveVig</listEntry>
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
<listEntry value="3">@Strings.FldDistance</listEntry>
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
<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="828">@Strings.FldSteps28d</listEntry>
<listEntry value="832">@Strings.FldCal28d</listEntry>
<listEntry value="836">@Strings.FldDist28d</listEntry>
<listEntry value="840">@Strings.FldFloors28d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry>
<listEntry value="635">@Strings.FldPulseOx</listEntry>
<listEntry value="11">@Strings.FldRespiration</listEntry>
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
<listEntry value="8">@Strings.FldMoveBar</listEntry>
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
<listEntry value="281">@Strings.FldWeight</listEntry>
<listEntry value="742">@Strings.FldBmi</listEntry>
<listEntry value="13">@Strings.FldRecovery</listEntry>
<listEntry value="278">@Strings.FldVo2Run</listEntry>
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
<listEntry value="250">@Strings.FldBattery</listEntry>
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
<listEntry value="253">@Strings.FldAlarms</listEntry>
<listEntry value="255">@Strings.FldNotifications</listEntry>
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
<listEntry value="256">@Strings.FldBluetooth</listEntry>
<listEntry value="252">@Strings.FldSolar</listEntry>
<listEntry value="615">@Strings.FldAltitude</listEntry>
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
<listEntry value="712">@Strings.FldSunrise</listEntry>
<listEntry value="717">@Strings.FldSunset</listEntry>
<listEntry value="700">@Strings.FldNextSun</listEntry>
<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>
<listEntry value="301">@Strings.FldTempHigh</listEntry>
<listEntry value="302">@Strings.FldTempLow</listEntry>
<listEntry value="305">@Strings.FldHumidity</listEntry>
<listEntry value="304">@Strings.FldPrecip</listEntry>
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
<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="612">@Strings.FldNextCal</listEntry>
<listEntry value="626">@Strings.FldTrainStatus</listEntry>
<listEntry value="627">@Strings.FldRace5k</listEntry>
<listEntry value="628">@Strings.FldRace10k</listEntry>
<listEntry value="629">@Strings.FldRaceHalf</listEntry>
<listEntry value="630">@Strings.FldRaceFull</listEntry>
<listEntry value="631">@Strings.FldPace5k</listEntry>
<listEntry value="632">@Strings.FldPace10k</listEntry>
<listEntry value="633">@Strings.FldPaceHalf</listEntry>
<listEntry value="634">@Strings.FldPaceFull</listEntry>
<listEntry value="641">@Strings.FldGolfScore</listEntry>
<listEntry value="619">@Strings.FldRunWeek</listEntry>
<listEntry value="620">@Strings.FldBikeWeek</listEntry>
<listEntry value="695">@Strings.FldApp1</listEntry>
<listEntry value="696">@Strings.FldApp2</listEntry>
<listEntry value="697">@Strings.FldApp3</listEntry>
<listEntry value="698">@Strings.FldApp4</listEntry>
<listEntry value="699">@Strings.FldApp5</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="538">@Strings.FldOwmAqi</listEntry>
<listEntry value="539">@Strings.FldOwmAqiText</listEntry>
<listEntry value="540">@Strings.FldOwmPm25</listEntry>
<listEntry value="541">@Strings.FldOwmPm10</listEntry>
<listEntry value="542">@Strings.FldOwmCo</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>
</settingConfig>
</setting>
<setting propertyKey="@Properties.BottomLeft" title="@Strings.SettingBottomLeft">
<settingConfig type="list">
<listEntry value="0">@Strings.FldOff</listEntry>
<listEntry value="860">@Strings.FldTime</listEntry>
<listEntry value="880">@Strings.FldUtcTime</listEntry>
<listEntry value="864">@Strings.FldAltTime1</listEntry>
<listEntry value="865">@Strings.FldAltTime2</listEntry>
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
<listEntry value="872">@Strings.FldDayDate</listEntry>
<listEntry value="852">@Strings.FldMonthDay</listEntry>
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
<listEntry value="870">@Strings.FldDayShort</listEntry>
<listEntry value="856">@Strings.FldDayFull</listEntry>
<listEntry value="853">@Strings.FldMonthName</listEntry>
<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="861">@Strings.FldCustomTime</listEntry>
<listEntry value="862">@Strings.FldDateFmt1</listEntry>
<listEntry value="863">@Strings.FldDateFmt2</listEntry>
<listEntry value="735">@Strings.FldCountdown</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry>
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
<listEntry value="2">@Strings.FldCalories</listEntry>
<listEntry value="603">@Strings.FldActiveCal</listEntry>
<listEntry value="150">@Strings.FldActiveMin</listEntry>
<listEntry value="152">@Strings.FldActiveMod</listEntry>
<listEntry value="154">@Strings.FldActiveVig</listEntry>
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
<listEntry value="3">@Strings.FldDistance</listEntry>
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
<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="828">@Strings.FldSteps28d</listEntry>
<listEntry value="832">@Strings.FldCal28d</listEntry>
<listEntry value="836">@Strings.FldDist28d</listEntry>
<listEntry value="840">@Strings.FldFloors28d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry>
<listEntry value="635">@Strings.FldPulseOx</listEntry>
<listEntry value="11">@Strings.FldRespiration</listEntry>
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
<listEntry value="8">@Strings.FldMoveBar</listEntry>
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
<listEntry value="281">@Strings.FldWeight</listEntry>
<listEntry value="742">@Strings.FldBmi</listEntry>
<listEntry value="13">@Strings.FldRecovery</listEntry>
<listEntry value="278">@Strings.FldVo2Run</listEntry>
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
<listEntry value="250">@Strings.FldBattery</listEntry>
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
<listEntry value="253">@Strings.FldAlarms</listEntry>
<listEntry value="255">@Strings.FldNotifications</listEntry>
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
<listEntry value="256">@Strings.FldBluetooth</listEntry>
<listEntry value="252">@Strings.FldSolar</listEntry>
<listEntry value="615">@Strings.FldAltitude</listEntry>
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
<listEntry value="712">@Strings.FldSunrise</listEntry>
<listEntry value="717">@Strings.FldSunset</listEntry>
<listEntry value="700">@Strings.FldNextSun</listEntry>
<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>
<listEntry value="301">@Strings.FldTempHigh</listEntry>
<listEntry value="302">@Strings.FldTempLow</listEntry>
<listEntry value="305">@Strings.FldHumidity</listEntry>
<listEntry value="304">@Strings.FldPrecip</listEntry>
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
<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="612">@Strings.FldNextCal</listEntry>
<listEntry value="626">@Strings.FldTrainStatus</listEntry>
<listEntry value="627">@Strings.FldRace5k</listEntry>
<listEntry value="628">@Strings.FldRace10k</listEntry>
<listEntry value="629">@Strings.FldRaceHalf</listEntry>
<listEntry value="630">@Strings.FldRaceFull</listEntry>
<listEntry value="631">@Strings.FldPace5k</listEntry>
<listEntry value="632">@Strings.FldPace10k</listEntry>
<listEntry value="633">@Strings.FldPaceHalf</listEntry>
<listEntry value="634">@Strings.FldPaceFull</listEntry>
<listEntry value="641">@Strings.FldGolfScore</listEntry>
<listEntry value="619">@Strings.FldRunWeek</listEntry>
<listEntry value="620">@Strings.FldBikeWeek</listEntry>
<listEntry value="695">@Strings.FldApp1</listEntry>
<listEntry value="696">@Strings.FldApp2</listEntry>
<listEntry value="697">@Strings.FldApp3</listEntry>
<listEntry value="698">@Strings.FldApp4</listEntry>
<listEntry value="699">@Strings.FldApp5</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="538">@Strings.FldOwmAqi</listEntry>
<listEntry value="539">@Strings.FldOwmAqiText</listEntry>
<listEntry value="540">@Strings.FldOwmPm25</listEntry>
<listEntry value="541">@Strings.FldOwmPm10</listEntry>
<listEntry value="542">@Strings.FldOwmCo</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>
</settingConfig>
</setting>
<setting propertyKey="@Properties.BottomRight" title="@Strings.SettingBottomRight">
<settingConfig type="list">
<listEntry value="0">@Strings.FldOff</listEntry>
<listEntry value="860">@Strings.FldTime</listEntry>
<listEntry value="880">@Strings.FldUtcTime</listEntry>
<listEntry value="864">@Strings.FldAltTime1</listEntry>
<listEntry value="865">@Strings.FldAltTime2</listEntry>
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
<listEntry value="872">@Strings.FldDayDate</listEntry>
<listEntry value="852">@Strings.FldMonthDay</listEntry>
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
<listEntry value="870">@Strings.FldDayShort</listEntry>
<listEntry value="856">@Strings.FldDayFull</listEntry>
<listEntry value="853">@Strings.FldMonthName</listEntry>
<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="861">@Strings.FldCustomTime</listEntry>
<listEntry value="862">@Strings.FldDateFmt1</listEntry>
<listEntry value="863">@Strings.FldDateFmt2</listEntry>
<listEntry value="735">@Strings.FldCountdown</listEntry>
<listEntry value="1">@Strings.FldSteps</listEntry>
<listEntry value="14">@Strings.FldStepsRound</listEntry>
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
<listEntry value="2">@Strings.FldCalories</listEntry>
<listEntry value="603">@Strings.FldActiveCal</listEntry>
<listEntry value="150">@Strings.FldActiveMin</listEntry>
<listEntry value="152">@Strings.FldActiveMod</listEntry>
<listEntry value="154">@Strings.FldActiveVig</listEntry>
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
<listEntry value="3">@Strings.FldDistance</listEntry>
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
<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="828">@Strings.FldSteps28d</listEntry>
<listEntry value="832">@Strings.FldCal28d</listEntry>
<listEntry value="836">@Strings.FldDist28d</listEntry>
<listEntry value="840">@Strings.FldFloors28d</listEntry>
<listEntry value="618">@Strings.FldHeartRate</listEntry>
<listEntry value="276">@Strings.FldRestingHr</listEntry>
<listEntry value="12">@Strings.FldStress</listEntry>
<listEntry value="635">@Strings.FldPulseOx</listEntry>
<listEntry value="11">@Strings.FldRespiration</listEntry>
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
<listEntry value="8">@Strings.FldMoveBar</listEntry>
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
<listEntry value="281">@Strings.FldWeight</listEntry>
<listEntry value="742">@Strings.FldBmi</listEntry>
<listEntry value="13">@Strings.FldRecovery</listEntry>
<listEntry value="278">@Strings.FldVo2Run</listEntry>
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
<listEntry value="250">@Strings.FldBattery</listEntry>
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
<listEntry value="253">@Strings.FldAlarms</listEntry>
<listEntry value="255">@Strings.FldNotifications</listEntry>
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
<listEntry value="256">@Strings.FldBluetooth</listEntry>
<listEntry value="252">@Strings.FldSolar</listEntry>
<listEntry value="615">@Strings.FldAltitude</listEntry>
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
<listEntry value="712">@Strings.FldSunrise</listEntry>
<listEntry value="717">@Strings.FldSunset</listEntry>
<listEntry value="700">@Strings.FldNextSun</listEntry>
<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>
<listEntry value="301">@Strings.FldTempHigh</listEntry>
<listEntry value="302">@Strings.FldTempLow</listEntry>
<listEntry value="305">@Strings.FldHumidity</listEntry>
<listEntry value="304">@Strings.FldPrecip</listEntry>
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
<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="612">@Strings.FldNextCal</listEntry>
<listEntry value="626">@Strings.FldTrainStatus</listEntry>
<listEntry value="627">@Strings.FldRace5k</listEntry>
<listEntry value="628">@Strings.FldRace10k</listEntry>
<listEntry value="629">@Strings.FldRaceHalf</listEntry>
<listEntry value="630">@Strings.FldRaceFull</listEntry>
<listEntry value="631">@Strings.FldPace5k</listEntry>
<listEntry value="632">@Strings.FldPace10k</listEntry>
<listEntry value="633">@Strings.FldPaceHalf</listEntry>
<listEntry value="634">@Strings.FldPaceFull</listEntry>
<listEntry value="641">@Strings.FldGolfScore</listEntry>
<listEntry value="619">@Strings.FldRunWeek</listEntry>
<listEntry value="620">@Strings.FldBikeWeek</listEntry>
<listEntry value="695">@Strings.FldApp1</listEntry>
<listEntry value="696">@Strings.FldApp2</listEntry>
<listEntry value="697">@Strings.FldApp3</listEntry>
<listEntry value="698">@Strings.FldApp4</listEntry>
<listEntry value="699">@Strings.FldApp5</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="538">@Strings.FldOwmAqi</listEntry>
<listEntry value="539">@Strings.FldOwmAqiText</listEntry>
<listEntry value="540">@Strings.FldOwmPm25</listEntry>
<listEntry value="541">@Strings.FldOwmPm10</listEntry>
<listEntry value="542">@Strings.FldOwmCo</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>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RingTR" title="@Strings.SettingRingTR"> <setting propertyKey="@Properties.RingTR" title="@Strings.SettingRingTR">
<settingConfig type="list"> <settingConfig type="list">
<listEntry value="0">@Strings.MetricSteps</listEntry> <listEntry value="0">@Strings.MetricSteps</listEntry>
@@ -18,6 +663,8 @@
<listEntry value="2">@Strings.MetricDistance</listEntry> <listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry> <listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry> <listEntry value="4">@Strings.MetricMoveBar</listEntry>
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
</settingConfig> </settingConfig>
</setting> </setting>
@@ -28,6 +675,8 @@
<listEntry value="2">@Strings.MetricDistance</listEntry> <listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry> <listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry> <listEntry value="4">@Strings.MetricMoveBar</listEntry>
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
</settingConfig> </settingConfig>
</setting> </setting>
@@ -38,6 +687,8 @@
<listEntry value="2">@Strings.MetricDistance</listEntry> <listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry> <listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry> <listEntry value="4">@Strings.MetricMoveBar</listEntry>
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
</settingConfig> </settingConfig>
</setting> </setting>
@@ -48,29 +699,58 @@
<listEntry value="2">@Strings.MetricDistance</listEntry> <listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry> <listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry> <listEntry value="4">@Strings.MetricMoveBar</listEntry>
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
</settingConfig> </settingConfig>
</setting> </setting>
<setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop">
<setting propertyKey="@Properties.TempUnit" title="@Strings.SettingTempUnit">
<settingConfig type="list"> <settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry> <listEntry value="0">@Strings.TempUnitAuto</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry> <listEntry value="1">@Strings.TempUnitC</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry> <listEntry value="2">@Strings.TempUnitF</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry>
</settingConfig> </settingConfig>
</setting> </setting>
<setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop"> <setting propertyKey="@Properties.AltOffset1" title="@Strings.SettingAltOffset1">
<settingConfig type="list"> <settingConfig type="numeric" min="-720" max="840"/>
<listEntry value="0">@Strings.FieldWeather</listEntry> </setting>
<listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry> <setting propertyKey="@Properties.AltOffset2" title="@Strings.SettingAltOffset2">
<listEntry value="3">@Strings.FieldDistance</listEntry> <settingConfig type="numeric" min="-720" max="840"/>
<listEntry value="4">@Strings.FieldFloors</listEntry> </setting>
<listEntry value="5">@Strings.FieldBattery</listEntry>
</settingConfig> <setting propertyKey="@Properties.CountdownDate" title="@Strings.SettingCountdownDate">
<settingConfig type="alphaNumeric" maxLength="10"/>
</setting>
<setting propertyKey="@Properties.TimeFmt" title="@Strings.SettingTimeFmt">
<settingConfig type="alphaNumeric" maxLength="16"/>
</setting>
<setting propertyKey="@Properties.DateFmt1" title="@Strings.SettingDateFmt1">
<settingConfig type="alphaNumeric" maxLength="16"/>
</setting>
<setting propertyKey="@Properties.DateFmt2" title="@Strings.SettingDateFmt2">
<settingConfig type="alphaNumeric" maxLength="16"/>
</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>
<setting propertyKey="@Properties.Custom2" title="@Strings.SettingCustom2">
<settingConfig type="alphaNumeric" maxLength="12"/>
</setting>
<setting propertyKey="@Properties.Custom3" title="@Strings.SettingCustom3">
<settingConfig type="alphaNumeric" maxLength="12"/>
</setting> </setting>
<setting propertyKey="@Properties.ShowTop" title="@Strings.SettingShowTop"> <setting propertyKey="@Properties.ShowTop" title="@Strings.SettingShowTop">

View File

@@ -0,0 +1,170 @@
<strings>
<!-- Field names shown in the settings picker. -->
<string id="FldOff">Off</string>
<string id="FldTime">Time</string>
<string id="FldUtcTime">UTC Time</string>
<string id="FldAltTime1">Alternate Time 1</string>
<string id="FldAltTime2">Alternate Time 2</string>
<string id="FldDayMonthDay">Day Month Day</string>
<string id="FldDayDate">Day Date</string>
<string id="FldMonthDay">Month Day</string>
<string id="FldDayOfMonth">Day of Month</string>
<string id="FldDayShort">Day (Tue)</string>
<string id="FldDayFull">Full Day (Tuesday)</string>
<string id="FldMonthName">Month (Jan)</string>
<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="FldCustomTime">Custom Time Format</string>
<string id="FldDateFmt1">Date Format 1</string>
<string id="FldDateFmt2">Date Format 2</string>
<string id="FldCountdown">Event Countdown</string>
<string id="FldSteps">Steps</string>
<string id="FldStepsRound">Steps Rounded</string>
<string id="FldStepsToGo">Steps to Go</string>
<string id="FldCalories">Calories</string>
<string id="FldActiveCal">Active Calories</string>
<string id="FldActiveMin">Active Minutes</string>
<string id="FldActiveMod">Active Min (Moderate)</string>
<string id="FldActiveVig">Active Min (Vigorous)</string>
<string id="FldActiveWeek">Active Minutes (Week)</string>
<string id="FldDistance">Distance</string>
<string id="FldFloorsUp">Floors Climbed</string>
<string id="FldFloorsDown">Floors Descended</string>
<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="FldSteps28d">Steps (28 Days)</string>
<string id="FldCal28d">Calories (28 Days)</string>
<string id="FldDist28d">Distance (28 Days)</string>
<string id="FldFloors28d">Floors (28 Days)</string>
<string id="FldHeartRate">Heart Rate</string>
<string id="FldRestingHr">Resting Heart Rate</string>
<string id="FldStress">Stress Score</string>
<string id="FldPulseOx">Pulse Ox</string>
<string id="FldRespiration">Respiration Rate</string>
<string id="FldBodyBattery">Body Battery</string>
<string id="FldMoveBar">Move Bar</string>
<string id="FldMoveBarRev">Move Bar Reversed</string>
<string id="FldWeight">Weight</string>
<string id="FldBmi">BMI</string>
<string id="FldRecovery">Time to Recovery</string>
<string id="FldVo2Run">VO2 Max (Run)</string>
<string id="FldVo2Bike">VO2 Max (Bike)</string>
<string id="FldBattery">Battery</string>
<string id="FldBatteryDays">Battery in Days</string>
<string id="FldBatteryBoth">Battery / Days</string>
<string id="FldAlarms">Alarms</string>
<string id="FldNotifications">Notifications</string>
<string id="FldDoNotDisturb">Do Not Disturb</string>
<string id="FldBluetooth">Bluetooth</string>
<string id="FldSolar">Solar Intensity</string>
<string id="FldAltitude">Altitude</string>
<string id="FldSeaPressure">Sea Level Pressure</string>
<string id="FldAmbPressure">Ambient Pressure</string>
<string id="FldSunrise">Sunrise</string>
<string id="FldSunset">Sunset</string>
<string id="FldNextSun">Next Sun Event</string>
<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>
<string id="FldTempHigh">High Temperature</string>
<string id="FldTempLow">Low Temperature</string>
<string id="FldHumidity">Humidity</string>
<string id="FldPrecip">Precipitation %</string>
<string id="FldWindSpeed">Wind Speed</string>
<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="FldNextCal">Next Calendar Event</string>
<string id="FldTrainStatus">Training Status</string>
<string id="FldRace5k">Race Time (5k)</string>
<string id="FldRace10k">Race Time (10k)</string>
<string id="FldRaceHalf">Race Time (Half)</string>
<string id="FldRaceFull">Race Time (Marathon)</string>
<string id="FldPace5k">Pace (5k)</string>
<string id="FldPace10k">Pace (10k)</string>
<string id="FldPaceHalf">Pace (Half)</string>
<string id="FldPaceFull">Pace (Marathon)</string>
<string id="FldGolfScore">Last Golf Round Score</string>
<string id="FldRunWeek">Run Distance (Week)</string>
<string id="FldBikeWeek">Bike Distance (Week)</string>
<string id="FldApp1">App 1 (on watch)</string>
<string id="FldApp2">App 2 (on watch)</string>
<string id="FldApp3">App 3 (on watch)</string>
<string id="FldApp4">App 4 (on watch)</string>
<string id="FldApp5">App 5 (on watch)</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="FldOwmAqi">OWM Air Quality (AQI)</string>
<string id="FldOwmAqiText">OWM AQI Description</string>
<string id="FldOwmPm25">OWM PM2.5</string>
<string id="FldOwmPm10">OWM PM10</string>
<string id="FldOwmCo">OWM CO</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,SEC,SEC,WEEK,TIME,DATE,DATE,DAYS,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,28D,28D,28D,28D,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,CAL,TRN,5K,10K,HM,FM,5K,10K,HM,FM,GOLF,RUN,BIKE,APP1,APP2,APP3,APP4,APP5,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,AQI,AQI,PM2.5,PM10,CO,CITY,OWM,,,</string>
<string id="MetricSteps">Steps</string>
<string id="MetricCalories">Calories</string>
<string id="MetricDistance">Distance</string>
<string id="MetricFloors">Floors</string>
<string id="MetricMoveBar">Move Bar</string>
<string id="MetricActiveMin">Active Minutes</string>
<string id="MetricBodyBattery">Body Battery</string>
</strings>

View File

@@ -6,12 +6,29 @@
<string id="SettingRingTL">Ring · Top-Left</string> <string id="SettingRingTL">Ring · Top-Left</string>
<string id="SettingRingBR">Ring · Bottom-Right</string> <string id="SettingRingBR">Ring · Bottom-Right</string>
<string id="SettingRingBL">Ring · Bottom-Left</string> <string id="SettingRingBL">Ring · Bottom-Left</string>
<string id="SettingLeftTop">Top-Left Complication</string> <string id="SettingLeftTop">Top-Left Field</string>
<string id="SettingRightTop">Top-Right Complication</string> <string id="SettingRightTop">Top-Right Field</string>
<string id="SettingBottomLeft">Bottom-Left Field</string>
<string id="SettingBottomRight">Bottom-Right Field</string>
<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="SettingCountdownDate">Countdown Target (YYYY-MM-DD)</string>
<string id="SettingTimeFmt">Custom Time Format (h m a)</string>
<string id="SettingDateFmt1">Date Format 1 (D M Y w)</string>
<string id="SettingDateFmt2">Date Format 2 (D M Y w)</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>
<string id="SettingShowTop">Show Top Row</string> <string id="SettingShowTop">Show Top Row</string>
<string id="SettingShowBand">Show Date Band</string> <string id="SettingShowBand">Show Date Band</string>
<string id="SettingShowBottom">Show Bottom Row</string> <string id="SettingShowBottom">Show Bottom Row</string>
<string id="TempUnitAuto">Follow watch</string>
<string id="TempUnitC">Celsius (°C)</string>
<string id="TempUnitF">Fahrenheit (°F)</string>
<string id="ThemeEmber">1 · Ember</string> <string id="ThemeEmber">1 · Ember</string>
<string id="ThemeAurora">2 · Aurora</string> <string id="ThemeAurora">2 · Aurora</string>
<string id="ThemeBrass">3 · Brass</string> <string id="ThemeBrass">3 · Brass</string>
@@ -20,16 +37,9 @@
<string id="ThemeAcid">6 · Acid</string> <string id="ThemeAcid">6 · Acid</string>
<string id="ThemeKelp">7 · Kelp</string> <string id="ThemeKelp">7 · Kelp</string>
<string id="MetricSteps">Steps</string> <!-- On-face wording. Comma-separated lists are split at runtime. -->
<string id="MetricCalories">Calories</string> <string id="DowNames">SUN,MON,TUE,WED,THU,FRI,SAT</string>
<string id="MetricDistance">Distance</string> <string id="MonNames">JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC</string>
<string id="MetricFloors">Floors</string> <string id="DateFormat">$1$ $2$</string>
<string id="MetricMoveBar">Move Bar</string> <string id="Meridiem">AM,PM</string>
<string id="FieldWeather">Weather</string>
<string id="FieldCalories">Calories</string>
<string id="FieldSteps">Steps</string>
<string id="FieldDistance">Distance</string>
<string id="FieldFloors">Floors</string>
<string id="FieldBattery">Battery</string>
</strings> </strings>

184
source/Comp.mc Normal file
View File

@@ -0,0 +1,184 @@
import Toybox.Lang;
import Toybox.Complications;
import Toybox.System;
// ============================================================================
// 系统 Complication 桥接
// ----------------------------------------------------------------------------
// Toybox.ComplicationsCIQ 4.2+,本项目支持的 17 款设备全都有)让表盘可以订阅
// 系统和**其它 App 发布**的复杂功能数据。
//
// 这个模块解锁了一批我原本判断「表盘做不到」的字段 —— 当时只查了
// ActivityMonitor / UserProfile / Weather漏掉了这条路
//
// 日历事件 CIQ 确实没有日历 API但系统把「下一个日程」作为
// complication 发布出来了
// 训练状态 ActivityMonitor.Info 上没有,这里有
// 完赛/配速预测 同上5k/10k/半马/全马各两套
// 本周跑步/骑行距离 这是**按运动分类**的ActivityMonitor 的按天历史做不出来
// 高尔夫成绩 无其它来源
// 第三方 App CGM、补水、行情这类 App 发布的复杂功能,
// 通过迭代器按序号取(对应目录里的 App 1..App 5
//
// 取值策略每次绘制现查getComplication 很轻量),不做订阅回调 ——
// 表盘每分钟才重绘一次,订阅的实时性没有意义,反而要维护状态。
// ============================================================================
module Comp {
// 字段编号 → complication 类型。
// 返回 null 表示这个编号不归本模块管。
function typeFor(id as Number) {
if (id == 612) { return Complications.COMPLICATION_TYPE_CALENDAR_EVENTS; }
if (id == 626) { return Complications.COMPLICATION_TYPE_TRAINING_STATUS; }
if (id == 627) { return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_5K; }
if (id == 628) { return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_10K; }
if (id == 629) {
return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_HALF_MARATHON;
}
if (id == 630) {
return Complications.COMPLICATION_TYPE_RACE_PREDICTOR_MARATHON;
}
if (id == 631) { return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_5K; }
if (id == 632) { return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_10K; }
if (id == 633) {
return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_HALF_MARATHON;
}
if (id == 634) {
return Complications.COMPLICATION_TYPE_RACE_PACE_PREDICTOR_MARATHON;
}
if (id == 641) {
return Complications.COMPLICATION_TYPE_LAST_GOLF_ROUND_SCORE;
}
if (id == 619) {
return Complications.COMPLICATION_TYPE_WEEKLY_RUN_DISTANCE;
}
if (id == 620) {
return Complications.COMPLICATION_TYPE_WEEKLY_BIKE_DISTANCE;
}
return null;
}
// 第三方 App 列表的每帧缓存。
//
// 枚举一次要走完整个迭代器,四个数据位如果都选了 App 槽就会走四遍。
// 表盘每分钟才画一次,本来不算大开销,但 onUpdate 有看门狗时限,
// 能省则省 —— 所以每帧只枚举一次,由 view 在绘制开始时调 beginFrame() 失效。
var mApps as Array<Complications.Complication>?;
function beginFrame() as Void {
mApps = null;
}
// 695..699 是「第三方 App 1..5」,不对应固定类型,靠迭代器按序号取。
function isAppSlot(id as Number) as Boolean {
return id >= 695 && id <= 699;
}
function owns(id as Number) as Boolean {
return isAppSlot(id) || typeFor(id) != null;
}
// 按类型取一个 complication 对象;取不到返回 null。
// 用 try 包住是因为设备不支持某个类型时 getComplication 会抛,而不是返回
// null —— 和本项目里其它 CIQ API 的老毛病一样。
function byType(type) as Complications.Complication? {
try {
return Complications.getComplication(new Complications.Id(type));
} catch (e) {
return null;
}
}
// 取第 n 个**第三方发布**的 complicationn 从 0 起)。
//
// 迭代器里既有系统自带的,也有别的 App 发布的。这里只保留「有短标签且有值」
// 的项 —— 没装对应 App 时那些是空壳,全留着的话序号会随手表上装了什么 App
// 而漂移,用户设好的第 3 个明天可能就变成别的东西了。
//
// Complication 的成员是 complicationId / value / unit / shortLabel /
// longLabel / ranges没有 label别写错
function nthApp(n as Number) as Complications.Complication? {
if (mApps == null) {
var list = [] as Array<Complications.Complication>;
try {
var it = Complications.getComplications();
if (it != null) {
var c = it.next();
while (c != null) {
if (c.value != null && c.shortLabel != null
&& c.shortLabel instanceof Lang.String) {
list.add(c);
}
c = it.next();
}
}
} catch (e) {
// 设备不支持枚举时就当作一个都没有。
}
mApps = list;
}
return (n >= 0 && n < mApps.size()) ? mApps[n] : null;
}
// 把 complication 的值转成可显示的字符串。
//
// value 的类型随 complication 而变数字、字符串都有unit 有时带单位词。
// 完赛/配速预测的 value 是**秒数**,直接印出来是一串没意义的数字,
// 所以单独格式化成 h:mm:ss / mm:ss。
function render(c as Complications.Complication?, id as Number) as String? {
if (c == null || c.value == null) { return null; }
var v = c.value;
if (isTimeSeconds(id) && !(v instanceof Lang.String)) {
return hms(v.toNumber());
}
// 浮点直接 toString 会印成 "0.000000",统一保留一位小数。
if (v instanceof Lang.Float || v instanceof Lang.Double) {
return trimUnit(v.toFloat().format("%.1f"), c);
}
var s = v.toString();
// 单位词能明显帮上忙时才拼(比如距离的 km纯数字类就不拼了。
//
// ⚠️ unit 的类型不保证是 String —— 周跑步距离那种返回的不是字符串,
// 直接调 .length() 会抛 "Could not find symbol 'length'"。
// 所以先用 instanceof 判一次再说。
if (c.unit != null && c.unit instanceof Lang.String) {
var u = c.unit as String;
if (u.length() > 0 && u.length() <= 3) { s = s + u; }
}
return s;
}
// 把单位词拼到已格式化好的数值后面(如果 unit 确实是个短字符串)。
function trimUnit(s as String, c as Complications.Complication) as String {
if (c.unit != null && c.unit instanceof Lang.String) {
var u = c.unit as String;
if (u.length() > 0 && u.length() <= 3) { return s + u; }
}
return s;
}
// 完赛预测627630与配速预测631634都是秒。
function isTimeSeconds(id as Number) as Boolean {
return id >= 627 && id <= 634;
}
// 秒 → h:mm:ss不足一小时则 m:ss。配速通常是后者。
function hms(total as Number) as String {
if (total <= 0) { return "--"; }
var h = total / 3600;
var m = (total % 3600) / 60;
var s = total % 60;
if (h > 0) {
return h.format("%d") + ":" + m.format("%02d") + ":" + s.format("%02d");
}
return m.format("%d") + ":" + s.format("%02d");
}
// 字段取值总入口。不属于本模块的编号返回 null。
function value(id as Number) as String? {
if (!owns(id)) { return null; }
var c = isAppSlot(id) ? nthApp(id - 695) : byType(typeFor(id));
var out = render(c, id);
return out == null ? "--" : out;
}
}

View File

@@ -1,9 +1,25 @@
import Toybox.Application; import Toybox.Application;
import Toybox.Background;
import Toybox.Lang; import Toybox.Lang;
import Toybox.System;
import Toybox.Time;
import Toybox.WatchUi; import Toybox.WatchUi;
import Owm;
// ============================================================================
// 应用入口
// ----------------------------------------------------------------------------
// 表盘没有菜单也拿不到输入,所以这个壳子只做三件事:
// 1. 交出 view
// 2. 设置变更时请求重绘
// 3. 管理拉取 OpenWeatherMap 的后台服务
// ============================================================================
class Fenix8V3App extends Application.AppBase { class Fenix8V3App extends Application.AppBase {
// OWM 免费档的调用额度很宽裕,但表盘每分钟才重绘一次、数据本身也是小时级的,
// 拉太勤只是白白唤醒射频费电。半小时是个合适的折中。
const OWM_PERIOD_MIN = 30;
function initialize() { function initialize() {
AppBase.initialize(); AppBase.initialize();
} }
@@ -14,13 +30,55 @@ class Fenix8V3App extends Application.AppBase {
function onStop(state as Dictionary?) as Void { function onStop(state as Dictionary?) as Void {
} }
// ⚠️ 定时器的注册必须放在 getInitialView 里,不能放 onStart。
// 因为 onStart **在后台服务进程里也会被调用一次**,而在后台进程里调
// registerForTemporalEvent 会抛 Out of Bounds。
// getInitialView 只在前台进程运行,是安全的位置。
function getInitialView() as [Views] or [Views, InputDelegates] { function getInitialView() as [Views] or [Views, InputDelegates] {
scheduleWeather();
return [new Fenix8V3View()]; return [new Fenix8V3View()];
} }
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 { function onSettingsChanged() as Void {
scheduleWeather();
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
// 后台服务把结果通过这里交回前台。存进 Storage 供表盘读取,然后重绘。
function onBackgroundData(data as Application.PersistableType) as Void {
if (data != null) {
Application.Storage.setValue(Owm.STORE, data);
WatchUi.requestUpdate();
}
}
} }
function getApp() as Fenix8V3App { function getApp() as Fenix8V3App {

View File

@@ -0,0 +1,193 @@
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 小时,够算明日/后日高低温)
// 3. 空气质量 /data/2.5/air_pollutionAQI / PM2.5 / PM10 / CO
//
// ⚠️ 预报接口必须带 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 = 3; // 当前天气 + 短预报 + 空气质量
// 一律用 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));
// 空气质量走独立接口。它在免费档里是开放的One Call 才要订阅),
// 所以 AQI / PM2.5 / PM10 / CO 这几项不用付费也能有。
Communications.makeWebRequest(
"https://api.openweathermap.org/data/2.5/air_pollution",
{"lat" => lat, "lon" => lon, "appid" => Owm.key()},
opts, method(:onAir));
}
// 当前天气的回调。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();
}
// 空气质量的回调。响应形如 { list: [ { main: {aqi}, components: {...} } ] }。
function onAir(code as Number, body as Dictionary?) as Void {
if (code == 200 && body != null) {
var list = body.get("list") as Array?;
if (list != null && list.size() > 0) {
var e = list[0] as Dictionary;
var main = e.get("main") as Dictionary?;
if (main != null) { put("aqi", main.get("aqi")); }
var comp = e.get("components") as Dictionary?;
if (comp != null) {
put("pm2_5", comp.get("pm2_5"));
put("pm10", comp.get("pm10"));
put("co", comp.get("co"));
}
}
}
done();
}
// 距 Unix 纪元的整天数,用来给预报条目分桶。
function dayNumber(unix as Number) as Number {
return unix / 86400;
}
// 只写入非空值,免得用 null 覆盖字典里已有的内容。
function put(k as String, v) as Void {
if (v != null) { mResult.put(k, v); }
}
// 两个请求都回来了才算一轮完整数据,谁最后完成谁负责退出服务。
// 一条都没拿到就传 null避免用空字典覆盖掉上一次的好数据。
function done() as Void {
mPending--;
if (mPending > 0) { return; }
if (mResult.size() == 0) {
Background.exit(null);
} else {
Background.exit(mResult);
}
}
}

View File

@@ -1,387 +1,453 @@
import Toybox.Application;
import Toybox.Graphics; import Toybox.Graphics;
import Toybox.Lang; import Toybox.Lang;
import Toybox.Math; import Toybox.Math;
import Toybox.ActivityMonitor; import Toybox.ActivityMonitor;
import Toybox.Weather; import Toybox.SensorHistory;
import Toybox.System; import Toybox.System;
import Toybox.Time; import Toybox.Time;
import Toybox.Time.Gregorian; import Toybox.Time.Gregorian;
import Toybox.WatchUi; import Toybox.WatchUi;
import Themes; import Themes;
import Fields;
import Layout;
import Icons;
import Settings;
import Comp;
// ============================================================================
// 表盘主体
// ----------------------------------------------------------------------------
// 负责:进度环、盖在环上的各个元素、以及 AMOLED 常亮时的简化版画面。
//
// 分工约定(改代码前先看清楚该动哪个文件):
// 位置尺寸 → Layout.mc
// 数据取值 → Fields.mc
// 配色 → Themes.mc自动生成
// 设置读取 → Settings.mc
// 图标 → Icons.mc
// 留在本文件里的只有「按什么顺序画」。
// ============================================================================
class Fenix8V3View extends WatchUi.WatchFace { class Fenix8V3View extends WatchUi.WatchFace {
// ----------------------------------------------------------------- utils // 常亮显示状态。
function getSetting(name as String, def as Number) as Number { // AMOLED 屏在息屏常亮时必须大幅减少点亮像素Garmin 上架审核会卡这一条,
var v = Application.Properties.getValue(name); // 而且不这么做既费电又有烧屏风险MIP 屏不受影响,照常全量绘制。
if (v == null) { return def; } private var mSleeping = false;
return v as Number; private var mBurnIn = false;
function initialize() {
WatchFace.initialize();
} }
function getBool(name as String, def as Boolean) as Boolean { // 进入低功耗息屏。AMOLED 机型从这一刻起改画简化版面。
var v = Application.Properties.getValue(name); function onEnterSleep() as Void {
if (v == null) { return def; } mSleeping = true;
return v as Boolean; WatchUi.requestUpdate();
} }
function currentTheme() as Dictionary { // 抬腕唤醒,恢复完整版面。
var idx = getSetting("Theme", 1); // 1..7 function onExitSleep() as Void {
var i = idx - 1; mSleeping = false;
if (i < 0) { i = 0; } WatchUi.requestUpdate();
if (i > 6) { i = 0; }
var id = Themes.ORDER[i];
return Themes.THEMES[id] as Dictionary;
} }
function lerpColor(c1 as Number, c2 as Number, f as Float) as Number { // 表盘加载时调一次;如果 onUpdate 发现缓存失效(分辨率变了,或资源还没
var r1 = (c1 & 0xFF0000) >> 16; // 加载好)会再调一次。所有「一次性、开销大」的准备工作都放这里。
var g1 = (c1 & 0x00FF00) >> 8; function onLayout(dc as Dc) as Void {
var b1 = (c1 & 0x0000FF); Layout.init(dc);
var r2 = (c2 & 0xFF0000) >> 16; Fields.loadStrings();
var g2 = (c2 & 0x00FF00) >> 8; Icons.load();
var b2 = (c2 & 0x0000FF); var ds = System.getDeviceSettings();
var r = (r1 + ((r2 - r1) * f).toNumber()).toNumber(); mBurnIn = (ds != null) && (ds has :requiresBurnInProtection)
var g = (g1 + ((g2 - g1) * f).toNumber()).toNumber(); && ds.requiresBurnInProtection;
var b = (b1 + ((b2 - b1) * f).toNumber()).toNumber();
return (r << 16) | (g << 8) | b;
} }
// interpolate a gradient stop array [[angle,color],...] (angles ascending) // ------------------------------- 进度环 -------------------------------
function lerpStops(stops as Array<Array<Number>>, t as Number) as Number { // 计算四段弧各自的进度0..1。
var n = stops.size(); // ⚠️ 这里的 idx 是**进度环自己的一张短表**(见 Settings.ringTR 的注释),
if (n == 0) { return 0x888888; } // 和数据位那套 125 项的字段编号完全是两回事,别混。
var first = stops[0]; function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float {
var last = stops[n - 1];
if (t <= first[0]) { return first[1]; }
if (t >= last[0]) { return last[1]; }
for (var i = 0; i < n - 1; i++) {
var a = stops[i];
var b = stops[i + 1];
if (t >= a[0] && t <= b[0]) {
var f = ((t - a[0]) / (b[0] - a[0])).toFloat();
return lerpColor(a[1], b[1], f);
}
}
return last[1];
}
// progress 0..1 for a metric index
function metricProgress(idx as Number, info as ActivityMonitor.Info) as Float {
if (info == null) { return 0.0f; } if (info == null) { return 0.0f; }
var p = 0.0f; var p = 0.0f;
if (idx == 0) { // Steps if (idx == 0) { // 步数:对每日目标
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) { if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
p = info.steps.toFloat() / info.stepGoal.toFloat(); p = info.steps.toFloat() / info.stepGoal.toFloat();
} }
} else if (idx == 1) { // Calories } else if (idx == 1) { // 卡路里:固定 2000 kcal 为满
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; } if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
} else if (idx == 2) { // Distance (cm -> 10 km cap) } else if (idx == 2) { // 距离10 km 为满distance 单位是厘米)
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; } if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
} else if (idx == 3) { // Floors } else if (idx == 3) { // 楼层:对每日目标,取不到目标就按 10 层
if (info.floorsClimbed != null) { if (info.floorsClimbed != null) {
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0) var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
? info.floorsClimbedGoal.toFloat() : 10.0f; ? info.floorsClimbedGoal.toFloat() : 10.0f;
p = info.floorsClimbed.toFloat() / goal; p = info.floorsClimbed.toFloat() / goal;
} }
} else if (idx == 4) { // Move Bar } else if (idx == 5) { // 活动分钟:对**每周**目标
if (info.moveBarLevel != null) { p = info.moveBarLevel.toFloat(); } if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null
&& (info has :activeMinutesWeekGoal)
&& info.activeMinutesWeekGoal != null
&& info.activeMinutesWeekGoal > 0) {
p = info.activeMinutesWeek.total.toFloat()
/ info.activeMinutesWeekGoal.toFloat();
}
} 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) {
// ⚠️ 动动条是**整数等级**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();
if (span > 0) {
p = (info.moveBarLevel - ActivityMonitor.MOVE_BAR_LEVEL_MIN) / span;
}
}
} }
if (p > 1.0f) { p = 1.0f; } if (p > 1.0f) { p = 1.0f; }
if (p < 0.0f) { p = 0.0f; } if (p < 0.0f) { p = 0.0f; }
return p; return p;
} }
// value string for a top complication type // 画一根刻度:环形扇区,用四点多边形填充。
function complicationValue(typeIdx as Number, info as ActivityMonitor.Info, // 扇区两条弧边其实是曲线,但 3° 弧的弓高不到 0.1 像素,用直边完全够精确,
weather as Weather.CurrentConditions, stats as System.Stats) as String { // 而 fillPolygon 比 drawArc 行为更确定(各机型的 drawArc 端点样式不一致)。
if (typeIdx == 0) { // Weather temp function tick(dc as Dc, ang as Float, hw as Float) as Void {
if (weather != null && weather.temperature != null) { var a0 = ang - hw;
return weather.temperature.format("%d") + "\u00B0"; var a1 = ang + hw;
} var ro = Layout.R_OUT * Layout.scale;
return "--"; var ri = Layout.R_IN * Layout.scale;
} else if (typeIdx == 1) { // Calories dc.fillPolygon([[Layout.polarX(a0, ro), Layout.polarY(a0, ro)],
return info != null && info.calories != null ? info.calories.format("%d") : "0"; [Layout.polarX(a1, ro), Layout.polarY(a1, ro)],
} else if (typeIdx == 2) { // Steps [Layout.polarX(a1, ri), Layout.polarY(a1, ri)],
return info != null && info.steps != null ? info.steps.format("%d") : "0"; [Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]);
} else if (typeIdx == 3) { // Distance km
if (info != null && info.distance != null) {
return (info.distance.toFloat() / 100000.0).format("%.1f");
}
return "0.0";
} else if (typeIdx == 4) { // Floors
return info != null && info.floorsClimbed != null ? info.floorsClimbed.format("%d") : "0";
} else { // Battery
var pct = 0;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
return pct.format("%d") + "%";
}
} }
// ----------------------------------------------------------------- ring // 通用画弧。点亮方向与设计稿一致:
function drawTick(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number, // 两条顶弧:从 9 点 / 3 点那一端朝 12 点方向填充fromEnd=true
w as Number, ang as Number, col as Number, mirror as Boolean) as Void { // 两条底弧:从 6 点朝两侧填充fromEnd=false
var eff = mirror ? -ang : ang; // mirror 用来把同一组角度镜像到左半边,这样左右弧共用一份角度表和配色表。
var rad = Math.toRadians(eff); function arc(dc as Dc, ramp as Array<Number>, off as Number, lit as Number,
var sin = Math.sin(rad); phase as Float, pitch as Float, hw as Float, n as Number,
var cos = Math.cos(rad); base as Float, mirror as Boolean, fromEnd as Boolean) as Void {
var ix = (cx + sin * rIn).toNumber();
var iy = (cy - cos * rIn).toNumber();
var ox = (cx + sin * rOut).toNumber();
var oy = (cy - cos * rOut).toNumber();
dc.setPenWidth(w);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawLine(ix, iy, ox, oy);
}
function drawArcTop(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number,
w as Number, angles as Array<Number>, stops as Array<Array<Number>>,
off as Number, mirror as Boolean, lit as Number, anchorOuter as Boolean) as Void {
var n = angles.size();
for (var i = 0; i < n; i++) { for (var i = 0; i < n; i++) {
var a = angles[i]; var a = phase + pitch * i;
var on = anchorOuter ? (i >= n - lit) : (i < lit); var on = fromEnd ? (i >= n - lit) : (i < lit);
var col = on ? lerpStops(stops, a) : off; dc.setColor(on ? ramp[i] : off, Graphics.COLOR_TRANSPARENT);
drawTick(dc, cx, cy, rIn, rOut, w, a, col, mirror); tick(dc, base + (mirror ? -a : a), hw);
} }
} }
function drawArcBottom(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number, function drawRing(dc as Dc, ti as Number, pTR as Float, pTL as Float,
w as Number, botAngles as Array<Number>, stops as Array<Array<Number>>, pBR as Float, pBL as Float) as Void {
off as Number, mirror as Boolean, lit as Number) as Void { // 四段弧共用两份配色表:两条顶弧用 RINGTOP两条底弧用 RINGBOTTOM。
var n = botAngles.size(); // 左右弧靠 mirror 参数镜像,颜色按刻度下标取,所以左右对称位置同色。
for (var i = 0; i < n; i++) { var top = Themes.RINGTOP[ti] as Array<Number>;
var a = botAngles[i]; var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var on = (i < lit); var off = Themes.TICKOFF[ti] as Number;
var ang = (180.0 - a).toNumber();
var col = on ? lerpStops(stops, a) : off; arc(dc, top, off, (pTR * Layout.TOP_N).toNumber(), Layout.TOP_PH,
drawTick(dc, cx, cy, rIn, rOut, w, ang, col, mirror); Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, false, true);
arc(dc, top, off, (pTL * Layout.TOP_N).toNumber(), Layout.TOP_PH,
Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, true, true);
arc(dc, bot, off, (pBL * Layout.BOT_N).toNumber(), Layout.BOT_PH,
Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, false, false);
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;
for (var i = 0; i < Layout.DOT_ANG.size(); i++) {
var a = Layout.DOT_ANG[i];
dc.fillCircle(Layout.polarX(a, rad), Layout.polarY(a, rad), r);
} }
} }
function drawRing(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary, // ------------------------------- 各元素 -------------------------------
// 顶部中央:电池图标 + 百分比。
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);
Layout.text(dc, Layout.BATT_VX, Layout.BATT_CY, Layout.fontBand,
(level * 100.0).toNumber().format("%d") + "%",
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;
Icons.forField(dc, id, x, Themes.ACCENT[ti] as Number, level);
Layout.fittedText(dc, x, Layout.VALUE_CY, Layout.fontComp,
Fields.value(id, ctx), Graphics.TEXT_JUSTIFY_CENTER,
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;
// 手机断连时把蓝牙符号**压暗**而不是隐藏 —— 否则手机一走远日期带就
// 少一块,整条带子的视觉重心跟着变,很干扰。
Icons.bluetooth(dc, (ds != null && !ds.phoneConnected)
? blend(col, fill, 0.6) : col);
// ⚠️ 必须用 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);
Layout.text(dc, Layout.MD_CX, Layout.BAND_CY, Layout.fontBand,
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, col);
if (ds == null || !ds.is24Hour) {
Layout.text(dc, Layout.AMPM_R, Layout.BAND_CY, Layout.fontBand,
Fields.meridiem(g), Graphics.TEXT_JUSTIFY_RIGHT, col);
}
}
// 中央时间。小时和分钟各自带竖向渐变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;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
Layout.gradText(dc, Layout.HOURS_R, Layout.TIME_CY, Layout.fontTime,
h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
Themes.HOURS[ti] as Array<Number>);
Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime,
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
Themes.MINUTES[ti] as Array<Number>);
dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y1),
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y2),
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;
var left = Settings.bottomLeft();
var right = Settings.bottomRight();
Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_VAL_CY, Layout.fontComp,
Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT,
value, Layout.BOT_MAX_W);
Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_LAB_CY, Layout.fontComp,
Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT,
label, Layout.BOT_MAX_W);
Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_VAL_CY, Layout.fontComp,
Fields.value(right, ctx), Graphics.TEXT_JUSTIFY_LEFT,
value, Layout.BOT_MAX_W);
Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_LAB_CY, Layout.fontComp,
Fields.label(right), Graphics.TEXT_JUSTIFY_LEFT,
label, Layout.BOT_MAX_W);
}
// ------------------------------- 颜色工具 ------------------------------
// 两色按比例混合f=0 取 c1f=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;
var b1 = c1 & 0xFF;
var r = r1 + (((((c2 >> 16) & 0xFF) - r1) * f)).toNumber();
var g = g1 + (((((c2 >> 8) & 0xFF) - g1) * f)).toNumber();
var b = b1 + ((((c2 & 0xFF) - b1) * f)).toNumber();
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();
var b = ((col & 0xFF) * f).toNumber();
return (r << 16) | (g << 8) | b;
}
// ------------------------------- 常亮模式 ------------------------------
// 低功耗画面。相比正常画面砍掉的东西:
// - 日期带底色(整条亮橙色横幅是最费电的元素,直接不画)
// - **整个进度环**
// - 时间、冒号、日期压暗到 38%
//
// 度量口径要注意:「点亮像素数」把每个抗锯齿边缘像素都算成亮的,和实际
// 功耗对不上AMOLED 功耗更接近各子像素亮度之和(平均亮度)。
// 本实现两个口径都留了余量,实测数字见 README 的常亮小节。
// - 四个数据位、电池(全部隐藏)
// 保留的时间和日期压暗到 55%。
//
// 另外整个画面按分钟在 ±4 像素内游走,避免同一个像素长期点亮导致烧屏。
// pTR/pTL/pBR/pBL 现在用不上(常亮下不画环),保留形参是为了以后要把环
// 加回来时不必改调用点。
function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?,
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
var rOut = (249.5 * s).toNumber(); var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var rIn = (231.5 * s).toNumber(); var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // ±4 设备像素,换算回设计像素
var tickOff = theme["tickOff"] as Number; var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
var topStops = theme["ringTop"] as Array<Array<Number>>;
var botStops = theme["ringBottom"] as Array<Array<Number>>;
var topW = (13.0 * s).toNumber();
var botW = (9.0 * s).toNumber();
var TOP = [2,6,10,14,18,22,26,30,34,38,42,46,50,54,58,62,66,70,74,78,82,86]; // 进度环在常亮下不画 —— 它是点亮像素数的大头。
var BOT = [6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66];
var litTR = (pTR * 22.0).toNumber(); var h = g.hour;
var litTL = (pTL * 22.0).toNumber(); if (ds == null || !ds.is24Hour) {
var litBR = (pBR * 21.0).toNumber(); if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
var litBL = (pBL * 21.0).toNumber(); }
var hours = Themes.HOURS[ti] as Array<Number>;
var mins = Themes.MINUTES[ti] as Array<Number>;
Layout.text(dc, Layout.HOURS_R + ox, Layout.TIME_CY + oy, Layout.fontTime,
h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
dim(hours[hours.size() / 2], 0.38));
Layout.text(dc, Layout.MINUTES_L + ox, Layout.TIME_CY + oy, Layout.fontTime,
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
dim(mins[mins.size() / 2], 0.38));
dc.setColor(dim(Themes.COLON[ti] as Number, 0.38), Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y1 + oy),
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y2 + oy),
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, false, litTR, true); var band = dim(Themes.ACCENT[ti] as Number, 0.38);
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, true, litTL, true); Layout.text(dc, Layout.DOW_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, true, litBL); Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, band);
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, false, litBR); Layout.text(dc, Layout.MD_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band);
}
// anchor dots // ------------------------------- 秒针刷新 ------------------------------
var dotR = (9.5 * s).toNumber(); // 表盘正常只有每分钟一次的 onUpdate秒数根本走不动。
var dotRad = (240.5 * s).toNumber(); // 这里实现 onPartialUpdate系统每秒回调一次但要非常克制
var dotCol = theme["anchorDot"] as Number; // - 只有当真的有数据位选了「秒」时才做事,否则直接返回
var dotAngles = [0.0, 93.3, 180.0, 266.7]; // - 只重绘那一格,用 setClip 把绘制限制在格子的包围盒内
dc.setColor(dotCol, Graphics.COLOR_TRANSPARENT); // (每秒重绘整个表盘的功耗预算是不够的)
for (var i = 0; i < dotAngles.size(); i++) { // - 常亮的 AMOLED 机型直接跳过:那时数据位本来就不显示
var rad = Math.toRadians(dotAngles[i]); function isSeconds(id as Number) as Boolean {
var dx = (cx + Math.sin(rad) * dotRad).toNumber(); return id == 850 || id == 869;
var dy = (cy - Math.cos(rad) * dotRad).toNumber(); }
dc.fillCircle(dx, dy, dotR);
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);
}
} }
} }
// ------------------------------------------------------------- complications // ------------------------------- 主绘制 --------------------------------
function drawBattery(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary, // 每分钟调用一次(以及唤醒、设置变更时)。
stats as System.Stats) as Void {
var y = (53.5 * s).toNumber();
var pct = 0;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
var txt = pct.format("%d") + "%";
var gw = (30.0 * s).toNumber();
var gh = (16.0 * s).toNumber();
var gx = (cx - 52.0 * s).toNumber();
var gy = (y - gh / 2).toNumber();
dc.setPenWidth(2);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawRectangle(gx, gy, gw, gh);
dc.fillRectangle((gx + gw).toNumber(), (gy + gh * 0.28).toNumber(),
(3.0 * s).toNumber(), (gh * 0.44).toNumber());
var fillW = ((gw - 4) * (pct.toFloat() / 100.0)).toNumber();
dc.fillRectangle((gx + 2).toNumber(), (gy + 2).toNumber(), fillW, (gh - 4).toNumber());
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText((cx + 16.0 * s).toNumber(), y, Graphics.FONT_SMALL, txt,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
}
function drawComplicationIcon(dc as Dc, x as Number, y as Number, r as Number, col as Number,
typeIdx as Number) as Void {
dc.setPenWidth(2);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
if (typeIdx == 0) { // sun
dc.fillCircle(x, y, (r * 0.45).toNumber());
for (var i = 0; i < 8; i++) {
var rad = Math.toRadians(i * 45.0);
var x1 = (x + Math.sin(rad) * r * 0.6).toNumber();
var y1 = (y - Math.cos(rad) * r * 0.6).toNumber();
var x2 = (x + Math.sin(rad) * r).toNumber();
var y2 = (y - Math.cos(rad) * r).toNumber();
dc.drawLine(x1, y1, x2, y2);
}
} else if (typeIdx == 1) { // flame
dc.fillPolygon([[x, (y - r).toNumber()],
[(x + r * 0.8).toNumber(), (y + r * 0.6).toNumber()],
[(x - r * 0.8).toNumber(), (y + r * 0.6).toNumber()]]);
} else if (typeIdx == 2) { // steps
dc.drawLine((x - r * 0.5).toNumber(), (y - r * 0.3).toNumber(),
(x + r * 0.5).toNumber(), (y - r * 0.3).toNumber());
dc.drawLine((x - r * 0.3).toNumber(), y, (x + r * 0.6).toNumber(), y);
} else if (typeIdx == 3) { // distance dot
dc.fillCircle(x, y, (r * 0.5).toNumber());
} else if (typeIdx == 4) { // floors chevron
dc.fillPolygon([[x, (y - r).toNumber()],
[(x + r * 0.7).toNumber(), (y + r * 0.2).toNumber()],
[(x - r * 0.7).toNumber(), (y + r * 0.2).toNumber()]]);
} else { // battery
dc.drawRectangle((x - r * 0.6).toNumber(), (y - r * 0.4).toNumber(),
(r * 1.2).toNumber(), (r * 0.8).toNumber());
}
}
function drawTopComplication(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
typeIdx as Number, isLeft as Boolean, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions, stats as System.Stats) as Void {
var sideX = isLeft ? (156.0 * s).toNumber() : (343.0 * s).toNumber();
var iconY = (90.0 * s).toNumber();
var valY = (130.5 * s).toNumber();
var valStr = complicationValue(typeIdx, info, weather, stats);
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(sideX, valY, Graphics.FONT_MEDIUM, valStr,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
drawComplicationIcon(dc, sideX, iconY, (13.0 * s).toNumber(), theme["accent"] as Number, typeIdx);
}
function drawBluetooth(dc as Dc, cx as Number, cy as Number, r as Number, col as Number) as Void {
dc.setPenWidth(3);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawLine(cx, (cy - r).toNumber(), cx, (cy + r).toNumber());
dc.drawLine(cx, (cy - r).toNumber(), (cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber());
dc.drawLine((cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber(), cx, cy);
dc.drawLine(cx, cy, (cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber());
dc.drawLine((cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber(), cx, (cy + r).toNumber());
}
function drawDateBand(dc as Dc, w as Number, cx as Number, s as Float, theme as Dictionary,
ds as System.DeviceSettings) as Void {
var by = (286.5 * s).toNumber();
var bh = (44.0 * s).toNumber();
dc.setColor(theme["bandFill"] as Number, theme["bandFill"] as Number);
dc.fillRectangle(0, by, w, bh);
var txtCol = theme["bandText"] as Number;
var yc = (308.5 * s).toNumber();
drawBluetooth(dc, (36.5 * s).toNumber(), yc, (10.0 * s).toNumber(), txtCol);
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
var DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
var MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
var dow = DOW[gi.day_of_week - 1];
var md = MON[gi.month - 1] + " " + gi.day.format("%d");
dc.setColor(txtCol, Graphics.COLOR_TRANSPARENT);
dc.drawText((177.5 * s).toNumber(), yc, Graphics.FONT_SMALL, dow,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
dc.drawText((297.5 * s).toNumber(), yc, Graphics.FONT_SMALL, md,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
var ampm = gi.hour >= 12 ? "PM" : "AM";
dc.drawText((465.0 * s).toNumber(), yc, Graphics.FONT_SMALL, ampm,
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
}
function drawTime(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary) as Void {
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
var hh = gi.hour;
if (hh == 0) { hh = 12; } else if (hh > 12) { hh = hh - 12; }
var hStr = hh.format("%d");
var mStr = gi.min.format("%02d");
var ty = (220.0 * s).toNumber();
dc.setColor(theme["hours"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText((211.0 * s).toNumber(), ty, Graphics.FONT_NUMBER_HOT, hStr,
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["minutes"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText((272.0 * s).toNumber(), ty, Graphics.FONT_NUMBER_HOT, mStr,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
// colon: two blocks centred on x=250
dc.setColor(theme["colon"] as Number, Graphics.COLOR_TRANSPARENT);
var cw = (24.0 * s).toNumber();
var ch = (20.0 * s).toNumber();
var cxc = (238.0 * s).toNumber();
dc.fillRectangle(cxc, (184.0 * s).toNumber(), cw, ch);
dc.fillRectangle(cxc, (235.0 * s).toNumber(), cw, ch);
}
function drawBottom(dc as Dc, cx as Number, s as Float, theme as Dictionary,
info as ActivityMonitor.Info) as Void {
var valY = (366.0 * s).toNumber();
var labY = (410.5 * s).toNumber();
var leftX = (234.0 * s).toNumber();
var rightX = (266.0 * s).toNumber();
var dist = info != null && info.distance != null
? (info.distance.toFloat() / 100000.0).format("%.1f") : "0.0";
var steps = info != null && info.steps != null ? info.steps.format("%d") : "0";
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(leftX, valY, Graphics.FONT_MEDIUM, dist,
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(leftX, labY, Graphics.FONT_SMALL, "DST",
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(rightX, valY, Graphics.FONT_MEDIUM, steps,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(rightX, labY, Graphics.FONT_SMALL, "STEP",
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
}
// ----------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void { function onUpdate(dc as Dc) as Void {
var w = dc.getWidth(); if (!Layout.ready() || Layout.width != dc.getWidth()
var h = dc.getHeight(); || !Fields.ready() || !Icons.ready()) {
var s = w / 500.0; onLayout(dc);
var cx = w / 2; }
var cy = h / 2; if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
var theme = currentTheme(); // 每次绘制只构造一个 Ctx四个数据位共用 —— 天气、用户档案这些
var info = ActivityMonitor.getInfo(); // 开销大的查询因此每帧最多做一次,且只在真有格子要用时才做。
var stats = System.getSystemStats(); var ti = Settings.themeIndex(Themes.NAMES.size());
var ds = System.getDeviceSettings(); var ds = System.getDeviceSettings();
var weather = Weather.getCurrentConditions(); var ctx = new Fields.Ctx(ds, Settings.tempUnit());
Comp.beginFrame(); // 让第三方 App 列表这一帧只枚举一次
var info = ctx.info();
var stats = ctx.stats();
var level = 0.0f;
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
if (level > 1.0f) { level = 1.0f; }
if (level < 0.0f) { level = 0.0f; }
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
dc.clear(); dc.clear();
var pTR = metricProgress(getSetting("RingTR", 0), info); var pTR = ringProgress(Settings.ringTR(), info);
var pTL = metricProgress(getSetting("RingTL", 1), info); var pTL = ringProgress(Settings.ringTL(), info);
var pBR = metricProgress(getSetting("RingBR", 4), info); var pBR = ringProgress(Settings.ringBR(), info);
var pBL = metricProgress(getSetting("RingBL", 3), info); var pBL = ringProgress(Settings.ringBL(), info);
drawRing(dc, cx, cy, s, theme, pTR, pTL, pBR, pBL);
if (getBool("ShowTop", true)) { // 常亮模式走另一条绘制路径,画完直接返回。
drawBattery(dc, cx, cy, s, theme, stats); if (mSleeping && mBurnIn) {
drawTopComplication(dc, cx, cy, s, theme, getSetting("LeftTop", 1), true, info, weather, stats); drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL);
drawTopComplication(dc, cx, cy, s, theme, getSetting("RightTop", 0), false, info, weather, stats); return;
} }
drawTime(dc, cx, cy, s, theme); drawRing(dc, ti, pTR, pTL, pBR, pBL);
if (getBool("ShowBand", true)) { if (Settings.showTop()) {
drawDateBand(dc, w, cx, s, theme, ds); drawBattery(dc, ti, level);
drawTopSlot(dc, ti, Settings.leftTop(), true, ctx, level);
drawTopSlot(dc, ti, Settings.rightTop(), false, ctx, level);
} }
if (getBool("ShowBottom", true)) { drawTime(dc, ti, ds);
drawBottom(dc, cx, s, theme, info);
if (Settings.showBand()) {
drawDateBand(dc, ti, ds);
}
if (Settings.showBottom()) {
drawBottom(dc, ti, ctx);
} }
} }
} }

31
source/FieldTable.mc Normal file
View File

@@ -0,0 +1,31 @@
// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。
// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,
// 再到 source/Fields.mc 对应分类的函数里补一个分支。
//
// 字段编号沿用 https://watchface.io/docs/datafields只收录 Connect IQ
// 表盘在本机能算出来的那些。
import Toybox.Lang;
module FieldTable {
// 全部字段 id顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——
// Fields.label() 就是靠这个下标去取对应标签的。
const IDS = [
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,861,862,863,
735,1,14,707,2,603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,
828,832,836,840,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,612,626,627,628,629,
630,631,632,633,634,641,619,620,695,696,697,698,699,500,515,503,504,505,507,502,501,
506,509,510,511,518,512,513,532,536,537,530,534,522,535,523,538,539,540,541,542,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; }
}
return 0;
}
}

933
source/Fields.mc Normal file
View File

@@ -0,0 +1,933 @@
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;
import Settings;
import Solar;
import Owm;
import Comp;
// ============================================================================
// 数据字段
// ----------------------------------------------------------------------------
// 四个数据位能显示的全部内容,共 125 项。
//
// 字段编号沿用 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 = "--";
var mDow as Array<String>?;
var mMon as Array<String>?;
var mLabels as Array<String>?;
var mMeridiem as Array<String>?;
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 没有 String.split只能自己按逗号切。
function splitCsv(str as String) as Array<String> {
var out = [] as Array<String>;
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")]);
}
// 按用户填的格式串拼时间/日期。
//
// 用单字母占位符,因为设置项里能填的长度有限,而且这样中英文都好写:
// h 小时12/24 跟随手表) H 小时补零
// m 分钟补零 s 秒补零
// a 上午/下午24 小时制下为空)
// D 日 M 月(数字) N 月(名称)
// Y 四位年 y 两位年 w 星期
// 其它字符原样输出,所以 "D/M" → "10/9""w N D" → "周四 9月 10"。
function applyFormat(fmt as String, g as Gregorian.Info, c as Ctx) as String {
var out = "";
var chars = fmt.toCharArray();
for (var i = 0; i < chars.size(); i++) {
var ch = chars[i];
if (ch == 'h') {
var hh = g.hour;
if (!c.is24) {
if (hh == 0) { hh = 12; } else if (hh > 12) { hh = hh - 12; }
}
out += hh.format("%d");
} else if (ch == 'H') {
out += g.hour.format("%02d");
} else if (ch == 'm') {
out += g.min.format("%02d");
} else if (ch == 's') {
out += g.sec.format("%02d");
} else if (ch == 'a') {
out += c.is24 ? "" : meridiem(g);
} else if (ch == 'D') {
out += g.day.format("%d");
} else if (ch == 'M') {
out += g.month.format("%d");
} else if (ch == 'N') {
out += month(g);
} else if (ch == 'Y') {
out += g.year.format("%d");
} else if (ch == 'y') {
out += (g.year % 100).format("%02d");
} else if (ch == 'w') {
out += dow(g);
} else {
out += ch.toString();
}
}
return out;
}
// 距目标日期还有几天。目标日期是设置里填的 YYYY-MM-DD 字符串。
// 解析失败(没填/格式不对)返回 null字段显示 "--"。
function daysUntil(target as String, g as Gregorian.Info) as Number? {
if (target.length() < 8) { return null; }
var parts = splitOn(target, '-');
if (parts.size() < 3) { return null; }
var y = parts[0].toNumber();
var mo = parts[1].toNumber();
var d = parts[2].toNumber();
if (y == null || mo == null || d == null) { return null; }
// 都换算成「儒略日式」的绝对天数再相减,省得处理跨月跨年。
return absDay(y, mo, d) - absDay(g.year, g.month, g.day);
}
function absDay(y as Number, m as Number, d as Number) as Number {
var yy = y;
var mm = m;
if (mm <= 2) { yy = yy - 1; mm = mm + 12; }
var a = yy / 100;
var b = 2 - a + a / 4;
return (365.25 * (yy + 4716)).toNumber() + (30.6001 * (mm + 1)).toNumber()
+ d + b - 1524;
}
function splitOn(str as String, sep as Char) as Array<String> {
var out = [] as Array<String>;
var cur = "";
var chars = str.toCharArray();
for (var i = 0; i < chars.size(); i++) {
if (chars[i] == sep) { out.add(cur); cur = ""; }
else { cur += chars[i].toString(); }
}
out.add(cur);
return out;
}
// 英文出 "10",中文出 "10日"。
// 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
function dayOnly(g as Gregorian.Info) as String {
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
}
function meridiem(g as Gregorian.Info) as String {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
}
// 底部格子数值下方的小标签。按字段编号查 FieldTable 的下标,
// 再到本地化的标签表里取词,所以中英文会自动跟随系统语言。
function label(id as Number) as String {
if (mLabels == null) { return ""; }
var i = FieldTable.indexOf(id);
return i < mLabels.size() ? mLabels[i] : "";
}
// 每次绘制构造一个,传给所有数据位共用。
// 目的是让开销大的查询(天气预报、传感器历史、用户档案)**每次绘制最多做
// 一次,且只在真的有格子要用时才做** —— 四个格子可能都要天气,但只查一次;
// 没有格子用到 UserProfile 时就完全不去碰它。
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<Weather.DailyForecast>?;
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;
}
// 本项目已经被这个 API 家族坑过三次Properties.getValue、
// deleteTemporalEvent、Complication.unit一律按「声明了权限也可能抛」
// 来防。取不到就当作没有用户档案,相关字段显示 "--"。
function profile() as UserProfile.Profile? {
if (!mGotProf) {
try {
mProf = UserProfile.getProfile();
} catch (e) {
mProf = null;
}
mGotProf = true;
}
return mProf;
}
// 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空:
// - 天气在同步之前没有观测点坐标
// - currentLocation 要有东西刚定过位才有值
// 所以每次拿到位置就缓存进 Storage两个来源都空时用缓存兜底
// 免得这些字段一会儿有一会儿变 "--"。
function where() as Position.Location? {
var w = wx();
if (w != null && (w has :observationLocationPosition)
&& w.observationLocationPosition != null) {
return remember(w.observationLocationPosition);
}
var a = act();
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;
}
}
// ------------------------------- 格式化工具 ----------------------------
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 的距离单位是**厘米**,先换算成公里再按单位制转换。
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";
}
// 从 SensorHistory 迭代器里取最新一条读数;没有则 null。
function latest(iter) as Numeric? {
if (iter == null) { return null; }
var s = iter.next();
if (s == null) { return null; }
return s.data;
}
// 历史累计。
// ActivityMonitor.getHistory() 返回过去若干天(最新在前),**不含今天**。
// 把它加起来是本机唯一能做出「本周累计 / 近 7 天累计」的路子;
// 按运动项目细分的那些仍然做不到,那需要手机侧与 Garmin Connect 的集成。
//
// which0 步数 / 1 卡路里 / 2 距离(厘米) / 3 楼层 / 4 活动分钟
// days :往前折算几天,今天总是算在内
function sumHistory(c as Ctx, which as Number, days as Number) as Numeric? {
var today = todayValue(c, which);
if (today == null) { return null; }
var total = today;
if (days <= 0) { return total; }
var hist = ActivityMonitor.getHistory();
if (hist == null) { return total; }
var n = days < hist.size() ? days : hist.size();
for (var k = 0; k < n; k++) {
var v = sampleValue(hist[k], which);
if (v != null) { total = total + v; }
}
return total;
}
function todayValue(c as Ctx, which as Number) as Numeric? {
var i = c.info();
if (i == null) { return null; }
return sampleValue(i, which);
}
function sampleValue(d, which as Number) as Numeric? {
if (which == 0) { return (d has :steps) ? d.steps : null; }
if (which == 1) { return (d has :calories) ? d.calories : null; }
if (which == 2) { return (d has :distance) ? d.distance : null; }
if (which == 3) { return (d has :floorsClimbed) ? d.floorsClimbed : null; }
if (which == 4) {
if (!(d has :activeMinutesDay) || d.activeMinutesDay == null) { return null; }
return d.activeMinutesDay.total;
}
return null;
}
// 距本周一过了几天,本周累计据此决定往前取多少天。
// day_of_week 是 1=周日,所以周日要特判成 6。
function daysIntoWeek(g as Gregorian.Info) as Number {
var dow = g.day_of_week; // 1 = Sunday
return dow == 1 ? 6 : dow - 2;
}
function dayOfYear(g as Gregorian.Info) as Number {
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 周数:周一为一周之始,含当年第一个周四的那周为第 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
var w = (dayOfYear(g) - iso + 10) / 7;
if (w < 1) { return 52; }
if (w > 52) { return 1; }
return w;
}
// 月龄:距 2000-01-06 那次新月的天数对朔望月29.530588853 天)取余。
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 的 % 不支持浮点,只能手写取余。
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];
}
// ------------------------------- 分类取值 ------------------------------
// ---- 日期时间类 ----
// 不属于本类的编号返回 null交给 value() 试下一个分类。
function dateTime(id as Number, c as Ctx) as String? {
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"); } // 周数 (ISO)
// 周数
if (id == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); }
// 秒数要动起来依赖设备支持 onPartialUpdate
// view 只在有格子选了秒时才去请求那个回调。
if (id == 850) { return c.greg.sec.format("%d"); } // 秒
if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零)
// 自定义格式:格式串来自设置,占位符说明见 applyFormat
if (id == 861) { return applyFormat(Settings.text("TimeFmt"), c.greg, c); }
if (id == 862) { return applyFormat(Settings.text("DateFmt1"), c.greg, c); }
if (id == 863) { return applyFormat(Settings.text("DateFmt2"), c.greg, c); }
if (id == 735) { // 事件倒计时
var left = daysUntil(Settings.text("CountdownDate"), c.greg);
if (left == null) { return DASH; }
return left.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); } // 上午/下午
return null;
}
// ---- 活动类 ----
// 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 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 (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 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); } // 高强度分钟
return num(am.total);
}
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); }
// ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有
// 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛
// "Symbol Not Found",用户一选这个字段表盘就崩。
// 所以凡是非通用成员,用之前一律先 has 检查。
if (id == 5) { // 下楼层数
return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended);
}
if (id == 6) { // 爬升高度
return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c);
}
if (id == 9) { // 推行距离
return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c);
}
if (id == 10) { // 推行次数
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
}
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 == 828 || id == 832 || id == 836 || id == 840) {
// 近 28 天。getHistory 能返回多少天由固件决定sumHistory 内部会按
// 实际条数截断,所以拿不到 28 天时是「有多少算多少」而不是报错。
var w28 = (id == 828) ? 0 : ((id == 832) ? 1 : ((id == 836) ? 2 : 3));
var v28 = sumHistory(c, w28, 27);
if (v28 == null) { return DASH; }
return (id == 836) ? dist(v28, c) : rounded(v28);
}
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) { // 恢复时间
return (i == null || !(i has :timeToRecovery)) ? DASH
: num(i.timeToRecovery);
}
if (id == 11) { // 呼吸频率
return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate);
}
if (id == 12) { // 压力指数
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
}
return null;
}
// ---- 身体类 ----
// 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。
function body(id as Number, c as Ctx) as String? {
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 || !(p has :restingHeartRate)) ? 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) { // 体重 / 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) { // 体重
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) { // 最大摄氧量 (骑) / 最大摄氧量 (跑)
var p = c.profile();
if (p == null) { return DASH; }
if (id == 278) { // 最大摄氧量 (跑)
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
}
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
}
return null;
}
// ---- 系统类 ----
// 电量、闹钟、通知、勿扰、蓝牙、太阳能强度。
function system(id as Number, c as Ctx) as String? {
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");
}
return null;
}
// ---- 环境类 ----
// 海拔、气压、各类太阳事件(含晨昏蒙影与黄金/蓝调时刻)、月相。
function environment(id as Number, c as Ctx) as String? {
if (id == 615) { // 海拔
var a = c.act();
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
}
if (id == 616 || id == 733) { // 海平面气压 / 环境气压
var a = c.act();
if (a == null) { return DASH; }
var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压
pa = a.meanSeaLevelPressure;
} 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) { // 日出 / 日落 / 下一太阳事件 / 距太阳事件
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 (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") + "%";
}
return null;
}
// ---- 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) { // 体感温度
return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c);
}
if (id == 305) { // 湿度
return (w == null || !(w has :relativeHumidity)) ? 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 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) { // 风向角
return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
}
if (id == 310) { // 风向
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
}
if (id == 309) { // 天气更新时间
if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH;
}
return clock(w.observationTime, c);
}
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); } // 最低气温
return temp(hi, c) + "/" + temp(lo, c);
}
return null;
}
// ---- 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 == 538 || id == 539) { // 空气质量指数 / 描述
var aqi = Owm.get("aqi");
if (aqi == null) { return DASH; }
if (id == 538) { return num(aqi); }
// OWM 的 AQI 是 1..5 的等级,不是欧美常见的 0..500 数值。
var names = ["", "优", "良", "中", "差", "很差"];
var k = aqi.toNumber();
return (k >= 1 && k <= 5) ? names[k] : DASH;
}
if (id == 540) { return oneDp(Owm.get("pm2_5")); } // PM2.5
if (id == 541) { return oneDp(Owm.get("pm10")); } // PM10
if (id == 542) { return oneDp(Owm.get("co")); } // 一氧化碳
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 oneDp(v) as String {
return v == null ? DASH : v.toFloat().format("%.1f");
}
function str(v) as String {
return v == null ? DASH : v.toString();
}
// ---- 系统 Complication ----
// 日历、训练状态、完赛/配速预测、按运动的周距离、以及第三方 App 发布的
// 复杂功能。全部由 Comp.mc 经 Toybox.Complications 取得。
function complication(id as Number, c as Ctx) as String? {
return Comp.value(id);
}
// ---- 自定义文字 ----
// 用户在设置里自己填的三段文字。
function custom(id as Number, c as Ctx) as String? {
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;
}
// 取某个字段的显示文本。
// 按分类依次尝试,而不是按编号区间派发 —— 编号是交错的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); }
if (v == null) { v = body(id, c); }
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 = complication(id, c); }
if (v == null) { v = owm(id, c); }
if (v == null) { v = custom(id, c); }
return v == null ? "" : v;
}
}

155
source/Icons.mc Normal file
View File

@@ -0,0 +1,155 @@
import Toybox.Lang;
import Toybox.Graphics;
import Toybox.WatchUi;
import Layout;
// ============================================================================
// 图标
// ----------------------------------------------------------------------------
// 顶部两个数据位上方的图标,加上电池和蓝牙符号。
//
// 设计稿里只有三个图标:日出、天气、电池。
//
// 前两个是位图描出来的矢量轮廓,最细的特征只有 23 个设计像素宽。一开始我用
// 圆、线段、多边形去「重画」它们,在 454px 上看着还行,一到 280pxEnduro 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);
mWeather = WatchUi.loadResource(Rez.Drawables.IconWeather);
}
function ready() as Boolean { return mSunrise != null; }
// ⚠️ 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,
Layout.px(Layout.ICON_CY) - bmp.getHeight() / 2,
bmp, {:tintColor => col});
}
// 电池:外框 + 内部电量条 + 右侧触点。
// 电量条长度跟随真实电量(设计稿画的是 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);
var y0 = Layout.px(cyDesign - Layout.BATT_H * k / 2);
var w = Layout.px(Layout.BATT_W * k);
var h = Layout.px(Layout.BATT_H * k);
var wall = Layout.px(Layout.BATT_WALL * k);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRoundedRectangle(x0, y0, w, h, Layout.px(3.4 * k));
dc.fillRectangle(x0 + w, Layout.px(cyDesign - Layout.BATT_TH * k / 2),
Layout.px(Layout.BATT_TW * k), Layout.px(Layout.BATT_TH * k));
var iw = w - 2 * wall;
var ih = h - 2 * wall;
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(x0 + wall, y0 + wall, iw, ih);
var fill = (iw * level).toNumber();
if (fill > 0) {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(x0 + wall, y0 + wall, fill, ih);
}
}
// 标准蓝牙符号,用一条折线一笔画成(六段,中间的竖线会被走两次)。
function bluetooth(dc as Graphics.Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.2));
var l = Layout.BT_CX - Layout.BT_HW;
var r = Layout.BT_CX + Layout.BT_HW;
var t = Layout.BT_CY - Layout.BT_HH;
var b = Layout.BT_CY + Layout.BT_HH;
var qt = Layout.BT_CY - Layout.BT_HH / 2;
var qb = Layout.BT_CY + Layout.BT_HH / 2;
Layout.line(dc, l, qt, r, qb);
Layout.line(dc, r, qb, Layout.BT_CX, b);
Layout.line(dc, Layout.BT_CX, b, Layout.BT_CX, t);
Layout.line(dc, Layout.BT_CX, t, r, qt);
Layout.line(dc, r, qt, l, qb);
}
// 按字段编号决定上方画哪个图标。
// 日期、周数、自定义文字这类字段没有合适的图标,就不画 —— 光有数值也读得懂,
// 硬配一个反而干扰。
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) {
blit(dc, mSunrise, xDesign, col);
return;
}
if (id == 717 || id == 731) {
blit(dc, mSunset, xDesign, col);
return;
}
if (id == 306 || id == 639 || id == 300 || id == 301 || id == 302
|| id == 304 || id == 305 || id == 307 || id == 308
|| id == 309 || id == 310) {
blit(dc, mWeather, xDesign, col);
return;
}
if (id == 250 || id == 251 || id == 257) {
battery(dc, xDesign - 14.4, Layout.ICON_CY, 1.0, col, level);
return;
}
var r = 13.0;
var cyd = Layout.ICON_CY;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.0));
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) { // 步数:两个脚印
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) { // 距离/海拔:箭头
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) { // 心率/静息/体能:心形
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),
Layout.px(r * 0.44));
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) { // 楼层:台阶
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),
Layout.px(r * 0.62), Layout.px(r * 0.4));
}
}
}
}

247
source/Layout.mc Normal file
View File

@@ -0,0 +1,247 @@
import Toybox.Lang;
import Toybox.Graphics;
import Toybox.Math;
// ============================================================================
// 布局与文字绘制
// ----------------------------------------------------------------------------
// 本文件里所有的数字都是**设计稿 500×500 画布上的坐标**,全部来自
// design/watchface-*.svg 的实测:
// - 进度环的几何参数取自 design/rebuild.py原作者从位图逆向出的常量
// - 各元素位置取自 SVG 描边路径的墨迹包围盒,不是目测的
//
// 绘制时统一乘以 s = 屏宽 / 500所以同一套数字可以覆盖
// 240pxfenix 7S到 454pxvenu 3全部 17 款设备。
//
// ⚠️ 使用前必须先由 view 的 onLayout 调用一次 init()。
// ============================================================================
module Layout {
// ------------------------------- 进度环 -------------------------------
// 刻度是**等角宽的环形扇区**,不是从圆心发射的线段 —— 所以画的时候用四点
// 多边形填充,而不是 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; // 刻度角宽 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; // 四个锚点圆点所在半径(正好是刻度带中线)
const DOT_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------- 各元素 -------------------------------
const BATT_X = 192.70; // 电池外框左边缘
const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算
const BATT_H = 17.81;
const BATT_WALL = 2.20; // 外框线宽
const BATT_TW = 2.53; // 右侧触点宽
const BATT_TH = 9.20;
const BATT_VX = 232.00; // 百分比文字左边缘
const BATT_CY = 53.20;
const LEFT_X = 157.00; // 顶部两个数据位的中心 x居中对齐
const RIGHT_X = 343.70;
const ICON_CY = 89.50; // 与图标位图自身的中心对齐,别随意改
const VALUE_CY = 131.06;
const TIME_CY = 221.10;
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;
const COLON_Y1 = 183.68;
const COLON_Y2 = 234.57;
const BAND_Y = 286.30;
const BAND_H = 44.19;
const BAND_CY = 308.10;
const BT_CX = 36.88; // 蓝牙符号中心
const BT_CY = 306.99;
const BT_HW = 8.89;
const BT_HH = 12.92;
const DOW_CX = 178.05;
const MD_CX = 298.28;
const AMPM_R = 465.89;
const BOT_VAL_CY = 366.76;
const BOT_LAB_CY = 410.88;
const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准
const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准
// 设计稿里三档文字的**字面高度**cap height大写字母/数字的高度,
// 不含上下留白)。字号选择就是按这个来匹配的。
const CAP_BAND = 25.34; // 日期带、电量百分比
const CAP_COMP = 31.50; // 四个数据位、底部行
const CAP_TIME = 98.70; // 时间大数字
// 数值最多占多宽,超了就降一档字号。
// 顶部两格是居中对齐,这个值是它能横跨的总宽度;
// 底部两格是边对齐,各占表盘中间往外的一半。
// 有了这个,用户就算把 6 个汉字的长日期放进底部格也不会压到进度环上。
const TOP_MAX_W = 185.0;
const BOT_MAX_W = 150.0;
// ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight**没有字面高度接口**。
// 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent
// 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有
// 设计稿的 57%)。
//
// 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。
// fenix847mmFONT_XTINY ascent 29 → 大写实际高 22px → 0.76
// FONT_NUMBER_MILD ascent 82 → 数字实际高 58px → 0.71
// 各机型内置字体不完全相同Roboto / RobotoCondensed / Yantramanav /
// 各机型位图字体),比例会有几个百分点误差,可以接受。
const CAP_OF_TEXT = 0.76;
const CAP_OF_NUM = 0.71;
// ------------------------------- 运行时状态 ----------------------------
var scale = 1.0; // 设备像素 / 设计像素
var width = 0;
var height = 0;
var cx = 0;
var cy = 0;
var fontBand = Graphics.FONT_SMALL;
var fontComp = Graphics.FONT_MEDIUM;
var fontTime = Graphics.FONT_NUMBER_HOT;
// 文字字号按从小到大排列,这样放不下的值可以沿着这张表往下逐档缩。
var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
function init(dc as Graphics.Dc) as Void {
width = dc.getWidth();
height = dc.getHeight();
scale = width / 500.0;
cx = width / 2;
cy = height / 2;
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
fontBand = pickFont(textFonts, CAP_BAND * scale, CAP_OF_TEXT);
fontComp = pickFont(textFonts, CAP_COMP * scale, CAP_OF_TEXT);
fontTime = pickFont(nums, CAP_TIME * scale, CAP_OF_NUM);
}
function ready() as Boolean { return width > 0; }
// 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。
function px(v as Numeric) as Number {
return Math.round(v * scale).toNumber();
}
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
return Graphics.getFontAscent(font) * ratio;
}
// 选**字面高度最接近**目标的那一档,而不是「不超过目标的最大档」。
// 后者在设计尺寸恰好卡在两档字体之间时会白白小一号。
function pickFont(cands as Array<Graphics.FontDefinition>, targetCap as Float,
ratio as Float) as Graphics.FontDefinition {
var best = cands[0];
var bestErr = -1.0;
for (var i = 0; i < cands.size(); i++) {
var err = capHeight(cands[i], ratio) - targetCap;
if (err < 0) { err = -err; }
if (bestErr < 0 || err < bestErr) { bestErr = err; best = cands[i]; }
}
return best;
}
// 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会
// 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。
function fitFont(dc as Graphics.Dc, str as String,
font as Graphics.FontDefinition, maxDesignW as Float)
as Graphics.FontDefinition {
var limit = px(maxDesignW);
var f = font;
var i = textFonts.size() - 1;
while (i >= 0) {
if (textFonts[i] == f) { break; }
i--;
}
if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它
while (i >= 0) {
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
return textFonts[i];
}
i--;
}
return textFonts[0];
}
// 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);
var asc = Graphics.getFontAscent(font);
return px(cyDesign) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
}
function text(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String,
just as Number, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), inkY(dc, font, cyDesign, CAP_OF_TEXT), font, str,
just | Graphics.TEXT_JUSTIFY_VCENTER);
}
// 同上,但会在字符串超出槽位宽度时自动降字号。
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 没法给文字填渐变。这里的做法是:**同一串数字画 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 {
var n = ramp.size();
var y = inkY(dc, font, cyDesign, CAP_OF_NUM);
var ink = capHeight(font, CAP_OF_NUM).toNumber();
var top = px(cyDesign) - ink / 2;
for (var i = 0; i < n; i++) {
var y0 = top + (ink * i) / n;
var y1 = top + (ink * (i + 1)) / n;
if (i == 0) { y0 = 0; }
if (i == n - 1) { y1 = height; }
if (y1 <= y0) { continue; }
dc.setClip(0, y0, width, y1 - y0);
dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER);
}
dc.clearClip();
}
// 设计稿的角度自 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();
}
function polarY(ang as Float, r as Float) as Number {
return Math.round(cy - Math.cos(Math.toRadians(ang)) * r).toNumber();
}
function line(dc as Graphics.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));
}
// 线宽在小屏上会被 round 成 0 直接消失,这里兜一个最小值 2。
function pen(v as Numeric) as Number {
var w = px(v);
return w < 2 ? 2 : w;
}
}

Some files were not shown because too many files have changed in this diff Show More