Compare commits

...

4 Commits

Author SHA1 Message Date
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
71 changed files with 6386 additions and 1104 deletions

447
README.md
View File

@@ -1,168 +1,341 @@
# 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`,支持圆形 AMOLEDFenix 8 / Epix 2 / Venu 3 等)与圆屏 MIPFenix 7 / Enduro 3 / FR255 等)共 17 款设备。
- **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_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 缓存读取
│ ├── Fenix8V3Background.mc # 后台服务:拉取 OWM
│ ├── Settings.mc # 所有设置项的读取与默认值
│ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表
│ └── Themes.mc # ⚠️ 自动生成:七套主题配色
├── resources/ # 英文文案 + 设置项定义(部分自动生成)
├── resources-chn/ # 简体中文文案
├── resources-icons-<宽度>/ # ⚠️ 自动生成:按屏幕尺寸光栅化的图标
├── resources-launcher-<尺寸>/ # ⚠️ 自动生成:各尺寸启动图标
└── 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_themes.py && python3 tools/gen_fields.py \
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 && python3 tools/gen_icons.py && python3 tools/gen_preview_svg.py
```
# 导出调试资源(含 -settings.json便于模拟器载入设置 三份渲染实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**`Themes.mc`。预览脚本直接解析它,不会与固件漂移。字段表、设置项、两种语言的文案同样只有 `tools/gen_fields.py` 里那一张表。
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 -x
# 多设备批量(已验证可编译) ---
for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm \
fenix8solar51mm epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x \ ## 1.5 代码结构
fenix7s fr965 fr265 fr255 venu3 vivoactive5; do
一句话:**位置归 `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 |
进度环指标:步数、卡路里、距离、楼层、动动条、活动分钟(对周目标)、身体电量。
### 数据字段
四个数据位共用一张 **125 项** 的字段表,编号沿用 [watchface.io/docs/datafields](https://watchface.io/docs/datafields),两边可以直接对照。底部两格的小标签跟随所选字段自动变化,不需要单独配置。
| 分类 | 字段 |
|------|------|
| 日期时间 | 秒前导零两种、时间、UTC 时间、第二时区 1/2、星期月日、星期日、月日、日、星期简写/全称)、月份(名称/数字、上午下午、周数ISO / 日历) |
| 活动 | 步数、步数取整、剩余步数、卡路里、活动卡路里、活动分钟(总/中强度/高强度/本周)、距离、上楼层数、下楼层数、爬升高度、推行距离、推行次数、**本周与近 7 天累计**(步数/卡路里/距离/楼层/活动分钟) |
| 身体 | 心率、静息心率、压力指数、血氧、呼吸频率、身体电量、活动条(正/反向、体重、BMI、恢复时间、最大摄氧量跑/骑) |
| 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 |
| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明、民用/航海/天文晨昏蒙影(各 2 项)、黄金时刻(晨/昏)、蓝调时刻(晨/昏) |
| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间、海平面气压、紫外线、能见度、露点 |
| OpenWeatherMap | 气温、体感、最高/最低、天气描述(简/详、湿度、气压、能见度、风速、风向角、风向、阵风、1h 降雨、1h 降雪、云量、降水概率、露点、明日与后日气温及高低温、城市名、更新时间(共 25 项) |
| 自定义 | 自定义文字 1 / 2 / 3 |
加字段:在 `tools/gen_fields.py``FIELDS` 表里加一行,跑一遍生成器,再到 `source/Fields.mc``value()` 里补一个分支。设置项、两种语言的文案、表盘标签会自动跟上。
### 秒针与后台天气
**秒**850/869通过 `onPartialUpdate` 每秒重绘:只有当某个数据位真的选了秒时才请求这个回调,并且只在该格的裁剪区内重绘,不动整个表盘。常亮的 AMOLED 机型不参与——那时数据位本来就不显示。
**OpenWeatherMap** 需要用户在设置里填自己的 API key免费档即可。没填就完全不联网25 个 OWM 字段都显示 `--`。填了之后由后台服务每 30 分钟拉一次(当前天气 + `cnt=16` 的短预报,够算出明日/后日高低温),结果压平成一个小字典存进 `Application.Storage`,表盘只读不联网。
位置来自天气观测点或最近一次 GPS 定位,并缓存在 Storage 里——两个来源都会间歇性为空,缓存让日出和晨昏蒙影不会一会儿有一会儿没有。
### ⚠️ 仍然没有实现的,以及原因
| 没做的 | 原因 |
|--------|------|
| 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 只有 `ActivityMonitor.getHistory()` 的按天总计(步数/卡路里/距离/楼层/活动分钟),**没有**按运动分类的历史。所以通用的本周/近 7 天累计已经做了,按跑步/骑行/游泳分开的做不了——那些在 GreenBlack 那边是手机侧与 Garmin Connect 集成同步来的 |
| StormGlass.io 潮汐5 项) | 需要额外的付费 API key且与本表盘的定位无关 |
| 第三方 App 复杂功能CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App并通过 Complications API 订阅 |
| 日历事件、计时器、倒计时 | 日历不对 CIQ 开放;表盘拿不到触摸输入,没法做启停 |
| 睡眠得分、训练状态、完赛/配速预测 | `ActivityMonitor.Info``UserProfile` 上没有这些字段SDK 9.1.0 实测) |
| 高尔夫成绩 | 无对应 API |
| 标记类字段Marker约 35 项) | 那是给刻度型表盘做环形标记用的,本表盘的进度环用的是自己的指标体系 |
| OWM 的 AQI 与 UVI | 免费档的 `/data/2.5/weather` 不返回;要 One Call 3.0 订阅。露点已用 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。用户安装时会看到授权提示。
> `Sensor` 权限**不能**用于 `type="watchface"`(除非同时申请 `Background`),编译器会直接拒绝。所以海拔与气压走 `Activity.Info` 而不是 `Sensor.getInfo()`。
单位与制式跟随手表:`is24Hour``distanceUnits``temperatureUnits`(温度可用 `TempUnit` 覆盖)。天气与日出需手表已配对并同步过数据,否则显示 `--`
---
## 8.2 常亮显示AOD
AMOLED 机型在常亮模式下必须压低点亮像素否则既费电又有烧屏风险Garmin 上架审核也会卡。`onEnterSleep` / `onExitSleep` 切换到 `drawAmbient()`
- 不画日期带底色(整条亮橙色横幅是最费电的元素)
- 进度环只画**点亮的**刻度,且整体压暗到 45%
- 时间与日期压暗到 55%,其余数据位全部隐藏
- 整个画面按分钟在 ±4px 内游走,避免固定像素长期点亮
只在 `DeviceSettings.requiresBurnInProtection` 为真时启用MIP 机型Fenix 7 / Enduro 3 等)不受影响,照常全量绘制。
模拟器可用 `Settings → Display Mode` 切换验证。
---
## 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`(调试版含资源镜像) 屏幕分辨率跨度较大fenix7s 240×240 至 fenix847mm / venu3 454×454全部按 `s = width/500` 缩放
--- ---
## 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. 二次开发与扩展 真机侧载:用 Garmin Express 或 Connect 手机 App 导入对应机型的 `bin/Fenix8V3_<device>.prg`,或把 `.prg` 拷入手表 `/GARMIN/Apps/`
- **改主题配色**:编辑 `watchface-tokens.json`(设计稿目录下),运行 `gen_themes.py` 重新生成 `source/Themes.mc`。新增主题时同步在 `Themes.mc``ORDER` 数组、`settings.xml``Theme` 列表、`strings.xml``ThemeXX` 文案中登记。
- **加指标**:在 `metricProgress`(进度换算)与 `complicationValue`(复杂功能文本)两个方法里按 `idx` 分支扩展,并在 `settings.xml` / `properties.xml` 的列表项补充。
- **调布局**:所有坐标集中在 `Fenix8V3View.mc``draw*` 方法里,均为 “设计像素 × `s`” 形式,改一处即可全局缩放生效。
--- ---
## 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
---
## 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"
}
}

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

@@ -34,5 +34,14 @@
<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="SensorHistory"/>
</iq:permissions>
</iq:application> </iq:application>
</iq:manifest> </iq:manifest>

View File

@@ -1,9 +1,32 @@
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 holds the Simplified Chinese variant of every string; the
# fenix8.resourcePath = resources;resources-round-amoled # -chn suffix is what tells monkeyc which language it belongs to.
# fenix7.resourcePath = resources;resources-round-mip base.resourcePath = resources;resources-chn
# Two more resource trees per device, both generated by tools/gen_icons.py:
# resources-icons-<screen width> sunrise/sunset/weather rasterised from the
# design paths for that exact pixel grid
# resources-launcher-<size> launcher icon at the size the device asks for
enduro3.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
epix2.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
epix2pro47mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
epix2pro51mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-60
fenix7.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fenix7s.resourcePath = $(base.resourcePath);resources-icons-240;resources-launcher-40
fenix7x.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
fenix843mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
fenix847mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
fenix8pro47mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
fenix8solar47mm.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fenix8solar51mm.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
fr255.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
fr265.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
fr965.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
venu3.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-70
vivoactive5.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-56
base.excludeAnnotations = release

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,139 @@
<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="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="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="FldOwmTemp">OWM 气温</string>
<string id="FldOwmMinMax">OWM 最高/最低</string>
<string id="FldOwmFeels">OWM 体感温度</string>
<string id="FldOwmMax">OWM 最高温</string>
<string id="FldOwmMin">OWM 最低温</string>
<string id="FldOwmDescShort">OWM 天气</string>
<string id="FldOwmDescLong">OWM 天气详述</string>
<string id="FldOwmHumidity">OWM 湿度</string>
<string id="FldOwmPressure">OWM 气压</string>
<string id="FldOwmVisibility">OWM 能见度</string>
<string id="FldOwmWind">OWM 风速</string>
<string id="FldOwmWindDeg">OWM 风向角</string>
<string id="FldOwmWindDir">OWM 风向</string>
<string id="FldOwmGust">OWM 阵风</string>
<string id="FldOwmRain">OWM 1h 降雨</string>
<string id="FldOwmSnow">OWM 1h 降雪</string>
<string id="FldOwmClouds">OWM 云量</string>
<string id="FldOwmPrecip">OWM 降水概率</string>
<string id="FldOwmDew">OWM 露点</string>
<string id="FldOwmTomorrow">OWM 明日气温</string>
<string id="FldOwmMinMaxTmrw">OWM 明日高低温</string>
<string id="FldOwmDayAfter">OWM 后日气温</string>
<string id="FldOwmMinMaxD2">OWM 后日高低温</string>
<string id="FldOwmCity">OWM 城市</string>
<string id="FldOwmUpdated">OWM 更新时间</string>
<string id="FldCustom1">自定义文字 1</string>
<string id="FldCustom2">自定义文字 2</string>
<string id="FldCustom3">自定义文字 3</string>
<!-- On-face captions, in FieldTable.IDS order. -->
<string id="FieldLabels">,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,秒,秒,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,周步数,7天,周千卡,7天,周距离,7天,周楼层,7天,7天,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,民用晨,航海晨,天文晨,蓝调晨,黄金晨,黄金昏,蓝调昏,民用昏,航海昏,天文昏,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,气压,紫外线,能见度,露点,气温,高低温,体感,最高,最低,天气,天气,湿度,气压,能见度,风速,风向,风向,阵风,降雨,降雪,云量,降水,露点,明日,明日,后日,后日,城市,更新,,,</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,41 @@
<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="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: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 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: 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.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.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.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,19 @@
<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="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,526 @@
</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="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="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="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry>
</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="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="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="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry>
</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="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="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="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry>
</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="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="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="500">@Strings.FldOwmTemp</listEntry>
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
<listEntry value="504">@Strings.FldOwmMax</listEntry>
<listEntry value="505">@Strings.FldOwmMin</listEntry>
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
<listEntry value="510">@Strings.FldOwmWind</listEntry>
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
<listEntry value="512">@Strings.FldOwmGust</listEntry>
<listEntry value="513">@Strings.FldOwmRain</listEntry>
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
<listEntry value="530">@Strings.FldOwmDew</listEntry>
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
<listEntry value="516">@Strings.FldOwmCity</listEntry>
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
<listEntry value="703">@Strings.FldCustom1</listEntry>
<listEntry value="704">@Strings.FldCustom2</listEntry>
<listEntry value="705">@Strings.FldCustom3</listEntry>
</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 +539,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 +551,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 +563,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 +575,42 @@
<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.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,139 @@
<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="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="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="FldOwmTemp">OWM Temperature</string>
<string id="FldOwmMinMax">OWM Max/Min Temp</string>
<string id="FldOwmFeels">OWM Feels Like</string>
<string id="FldOwmMax">OWM Temp Max</string>
<string id="FldOwmMin">OWM Temp Min</string>
<string id="FldOwmDescShort">OWM Description</string>
<string id="FldOwmDescLong">OWM Description (Long)</string>
<string id="FldOwmHumidity">OWM Humidity</string>
<string id="FldOwmPressure">OWM Pressure</string>
<string id="FldOwmVisibility">OWM Visibility</string>
<string id="FldOwmWind">OWM Wind Speed</string>
<string id="FldOwmWindDeg">OWM Wind Degrees</string>
<string id="FldOwmWindDir">OWM Wind Direction</string>
<string id="FldOwmGust">OWM Wind Gust</string>
<string id="FldOwmRain">OWM Rain 1h</string>
<string id="FldOwmSnow">OWM Snow 1h</string>
<string id="FldOwmClouds">OWM Cloud Cover</string>
<string id="FldOwmPrecip">OWM Precipitation %</string>
<string id="FldOwmDew">OWM Dew Point</string>
<string id="FldOwmTomorrow">OWM Temp Tomorrow</string>
<string id="FldOwmMinMaxTmrw">OWM Min/Max Tomorrow</string>
<string id="FldOwmDayAfter">OWM Temp Day After</string>
<string id="FldOwmMinMaxD2">OWM Min/Max Day After</string>
<string id="FldOwmCity">OWM City Name</string>
<string id="FldOwmUpdated">OWM Update Time</string>
<string id="FldCustom1">Custom Text 1</string>
<string id="FldCustom2">Custom Text 2</string>
<string id="FldCustom3">Custom Text 3</string>
<!-- On-face captions, in FieldTable.IDS order. -->
<string id="FieldLabels">,TIME,UTC,TZ1,TZ2,DATE,DATE,DATE,DAY,DAY,DAY,MON,MON,,WEEK,SEC,SEC,WEEK,STEP,STEP,TO GO,KCAL,ACAL,MIN,MOD,VIG,WEEK,DST,FLR,DESC,ASC,PUSH,PUSH,WEEK,7D,WEEK,7D,WEEK,7D,WEEK,7D,7D,HR,RHR,STRS,SPO2,RESP,BODY,MOVE,MOVE,WT,BMI,RCVR,VO2,VO2,BATT,DAYS,BATT,ALRM,MSG,DND,BT,SOLR,ALT,hPa,hPa,RISE,SET,SUN,SUN,MOON,MOON,CIVIL,NAUT,ASTR,BLUE,GOLD,GOLD,BLUE,CIVIL,NAUT,ASTR,TEMP,HI/LO,FEEL,HIGH,LOW,HUM,RAIN,WIND,WIND,WIND,WX,hPa,UVI,VIS,DEW,TEMP,HI/LO,FEEL,HIGH,LOW,WX,WX,HUM,hPa,VIS,WIND,WIND,WIND,GUST,RAIN,SNOW,CLD,RAIN,DEW,TMRW,TMRW,D+2,D+2,CITY,OWM,,,</string>
<string id="MetricSteps">Steps</string>
<string id="MetricCalories">Calories</string>
<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,25 @@
<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="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 +33,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>

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,167 @@
import Toybox.Lang;
import Toybox.Application;
import Toybox.Background;
import Toybox.Communications;
import Toybox.System;
import Toybox.Time;
import Toybox.Position;
// ============================================================================
// OpenWeatherMap —— 后台拉取端
// ----------------------------------------------------------------------------
// 表盘进程绝大多数时间没在运行onUpdate 又必须够快,不可能在绘制路径里发
// HTTP 请求。Connect IQ 的方案是**后台服务**:系统按定时器唤醒一个独立进程,
// 在里面发请求,把结果通过 Background.exit() 交回前台,前台再写进 Storage。
//
// 每次唤醒发两个请求:
// 1. 当前天气 /data/2.5/weather
// 2. 短期预报 /data/2.5/forecast?cnt=16约 48 小时,够算明日/后日高低温)
//
// ⚠️ 预报接口必须带 cnt 限制条数。后台进程的内存预算非常小(约 32KB
// 完整的 5 天 / 3 小时预报解析出来能有几十 KB会直接爆掉。
//
// 响应会被**压平成一个扁平字典**再交回去,键名是短名("temp"/"hi1"…),
// 与 Owm.get() 的取值一一对应 —— 这样表盘侧永远不用扛一棵 JSON 树。
// ============================================================================
(:background)
class Fenix8V3Background extends System.ServiceDelegate {
private var mResult as Dictionary = {};
private var mPending = 0;
function initialize() {
ServiceDelegate.initialize();
}
// 系统按定时器唤醒后台进程时调用。
// 注意这里是**独立进程**,内存预算比前台小得多,而且不能碰 UI。
function onTemporalEvent() as Void {
if (!Owm.enabled()) {
Background.exit(null);
return;
}
var lat = Application.Storage.getValue("lastLat");
var lon = Application.Storage.getValue("lastLon");
if (lat == null || lon == null) {
// 表盘一拿到位置就会存进 Storage见 Fields.Ctx.where
// 在那之前没有坐标可问,直接退出。
Background.exit(null);
return;
}
mResult = {};
mPending = 2;
// 一律用 units=metric 请求,单位换算统一放到 Fields 里按用户设置做,
// 这样切换单位不用重新联网。
var common = {"lat" => lat, "lon" => lon,
"appid" => Owm.key(), "units" => "metric"};
var opts = {
:method => Communications.HTTP_REQUEST_METHOD_GET,
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
};
Communications.makeWebRequest(
"https://api.openweathermap.org/data/2.5/weather",
common, opts, method(:onCurrent));
var fc = {"lat" => lat, "lon" => lon, "appid" => Owm.key(),
"units" => "metric", "cnt" => 16};
Communications.makeWebRequest(
"https://api.openweathermap.org/data/2.5/forecast",
fc, opts, method(:onForecast));
}
// 当前天气的回调。OWM 的 JSON 是嵌套的,逐层取值时每层都要判空 ——
// 缺字段是常态(比如没下雨就没有 rain 节点)。
function onCurrent(code as Number, body as Dictionary?) as Void {
if (code == 200 && body != null) {
var main = body.get("main") as Dictionary?;
if (main != null) {
put("temp", main.get("temp"));
put("feels", main.get("feels_like"));
put("tmin", main.get("temp_min"));
put("tmax", main.get("temp_max"));
put("pressure", main.get("pressure"));
put("humidity", main.get("humidity"));
}
put("visibility", body.get("visibility"));
var wind = body.get("wind") as Dictionary?;
if (wind != null) {
put("wind", wind.get("speed"));
put("winddeg", wind.get("deg"));
put("gust", wind.get("gust"));
}
var clouds = body.get("clouds") as Dictionary?;
if (clouds != null) { put("clouds", clouds.get("all")); }
var rain = body.get("rain") as Dictionary?;
if (rain != null) { put("rain", rain.get("1h")); }
var snow = body.get("snow") as Dictionary?;
if (snow != null) { put("snow", snow.get("1h")); }
var wx = body.get("weather") as Array?;
if (wx != null && wx.size() > 0) {
var w0 = wx[0] as Dictionary;
put("short", w0.get("main"));
put("long", w0.get("description"));
}
put("city", body.get("name"));
put("dt", Time.now().value());
}
done();
}
// 预报返回的是 3 小时粒度的列表。这里按「距今天几天」把条目分到
// 明天 / 后天两个桶里,各自取最高最低温;降水概率取明天的最大值。
function onForecast(code as Number, body as Dictionary?) as Void {
if (code == 200 && body != null) {
var list = body.get("list") as Array?;
if (list != null) {
var today = dayNumber(Time.now().value());
var lo1 = null; var hi1 = null; var lo2 = null; var hi2 = null;
var pop1 = null;
var arr = list;
for (var i = 0; i < arr.size(); i++) {
var e = arr[i] as Dictionary;
var dt = e.get("dt");
if (dt == null) { continue; }
var day = dayNumber(dt as Number) - today;
var m = e.get("main") as Dictionary?;
if (m == null) { continue; }
var tmin = m.get("temp_min");
var tmax = m.get("temp_max");
if (day == 1) {
if (lo1 == null || tmin < lo1) { lo1 = tmin; }
if (hi1 == null || tmax > hi1) { hi1 = tmax; }
var pop = e.get("pop");
if (pop != null && (pop1 == null || pop > pop1)) { pop1 = pop; }
} else if (day == 2) {
if (lo2 == null || tmin < lo2) { lo2 = tmin; }
if (hi2 == null || tmax > hi2) { hi2 = tmax; }
}
}
put("lo1", lo1); put("hi1", hi1);
put("lo2", lo2); put("hi2", hi2);
put("pop", pop1);
}
}
done();
}
// 距 Unix 纪元的整天数,用来给预报条目分桶。
function dayNumber(unix as Number) as Number {
return unix / 86400;
}
// 只写入非空值,免得用 null 覆盖字典里已有的内容。
function put(k as String, v) as Void {
if (v != null) { mResult.put(k, v); }
}
// 两个请求都回来了才算一轮完整数据,谁最后完成谁负责退出服务。
// 一条都没拿到就传 null避免用空字典覆盖掉上一次的好数据。
function done() as Void {
mPending--;
if (mPending > 0) { return; }
if (mResult.size() == 0) {
Background.exit(null);
} else {
Background.exit(mResult);
}
}
}

View File

@@ -1,387 +1,471 @@
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;
// ============================================================================
// 表盘主体
// ----------------------------------------------------------------------------
// 负责:进度环、盖在环上的各个元素、以及 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, // ------------------------------- 各元素 -------------------------------
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { // 顶部中央:电池图标 + 百分比。
var rOut = (249.5 * s).toNumber(); function drawBattery(dc as Dc, ti as Number, level as Float) as Void {
var rIn = (231.5 * s).toNumber(); Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0,
var tickOff = theme["tickOff"] as Number; Themes.ACCENT[ti] as Number, level);
var topStops = theme["ringTop"] as Array<Array<Number>>; Layout.text(dc, Layout.BATT_VX, Layout.BATT_CY, Layout.fontBand,
var botStops = theme["ringBottom"] as Array<Array<Number>>; (level * 100.0).toNumber().format("%d") + "%",
var topW = (13.0 * s).toNumber(); Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
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]; // 数值走 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);
}
var litTR = (pTR * 22.0).toNumber(); // 日期带:整条横幅底色 + 蓝牙符号 + 星期 + 月日 + 上午/下午。
var litTL = (pTL * 22.0).toNumber(); // 24 小时制下不显示上午/下午(跟随手表设置)。
var litBR = (pBR * 21.0).toNumber(); function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var litBL = (pBL * 21.0).toNumber(); var fill = Themes.BANDFILL[ti] as Number;
dc.setColor(fill, fill);
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, false, litTR, true); dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width,
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, true, litTL, true); Layout.px(Layout.BAND_H));
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, true, litBL); var col = Themes.BANDTEXT[ti] as Number;
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, false, litBR); // 手机断连时把蓝牙符号**压暗**而不是隐藏 —— 否则手机一走远日期带就
// 少一块,整条带子的视觉重心跟着变,很干扰。
// anchor dots Icons.bluetooth(dc, (ds != null && !ds.phoneConnected)
var dotR = (9.5 * s).toNumber(); ? blend(col, fill, 0.6) : col);
var dotRad = (240.5 * s).toNumber(); // ⚠️ 必须用 FORMAT_SHORT不能用 FORMAT_MEDIUM。
var dotCol = theme["anchorDot"] as Number; // 只有短格式的 day_of_week / month 返回的是**数字**;中格式返回的是
var dotAngles = [0.0, 93.3, 180.0, 266.7]; // 本地化字符串,拿去做数组下标会直接抛类型异常 —— 初版就是这么崩的,
dc.setColor(dotCol, Graphics.COLOR_TRANSPARENT); // 而且是每次更新都崩。
for (var i = 0; i < dotAngles.size(); i++) { var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var rad = Math.toRadians(dotAngles[i]); Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand,
var dx = (cx + Math.sin(rad) * dotRad).toNumber(); Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col);
var dy = (cy - Math.cos(rad) * dotRad).toNumber(); Layout.text(dc, Layout.MD_CX, Layout.BAND_CY, Layout.fontBand,
dc.fillCircle(dx, dy, dotR); 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);
} }
} }
// ------------------------------------------------------------- complications // 中央时间。小时和分钟各自带竖向渐变gradText 内部会分带多次绘制),
function drawBattery(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary, // 中间的冒号是两个实心方块,位置写死在设计稿坐标上。
stats as System.Stats) as Void { function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var y = (53.5 * s).toNumber(); var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var pct = 0; var h = g.hour;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); } if (ds == null || !ds.is24Hour) {
var txt = pct.format("%d") + "%"; if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
var gw = (30.0 * s).toNumber(); }
var gh = (16.0 * s).toNumber(); Layout.gradText(dc, Layout.HOURS_R, Layout.TIME_CY, Layout.fontTime,
var gx = (cx - 52.0 * s).toNumber(); h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
var gy = (y - gh / 2).toNumber(); Themes.HOURS[ti] as Array<Number>);
dc.setPenWidth(2); Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime,
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT); g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
dc.drawRectangle(gx, gy, gw, gh); Themes.MINUTES[ti] as Array<Number>);
dc.fillRectangle((gx + gw).toNumber(), (gy + gh * 0.28).toNumber(), dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT);
(3.0 * s).toNumber(), (gh * 0.44).toNumber()); dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y1),
var fillW = ((gw - 4) * (pct.toFloat() / 100.0)).toNumber(); Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
dc.fillRectangle((gx + 2).toNumber(), (gy + 2).toNumber(), fillW, (gh - 4).toNumber()); dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y2),
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT); Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
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); function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT); var value = Themes.TEXTPRIMARY[ti] as Number;
if (typeIdx == 0) { // sun var label = Themes.ACCENT[ti] as Number;
dc.fillCircle(x, y, (r * 0.45).toNumber()); var left = Settings.bottomLeft();
for (var i = 0; i < 8; i++) { var right = Settings.bottomRight();
var rad = Math.toRadians(i * 45.0); Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_VAL_CY, Layout.fontComp,
var x1 = (x + Math.sin(rad) * r * 0.6).toNumber(); Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT,
var y1 = (y - Math.cos(rad) * r * 0.6).toNumber(); value, Layout.BOT_MAX_W);
var x2 = (x + Math.sin(rad) * r).toNumber(); Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_LAB_CY, Layout.fontComp,
var y2 = (y - Math.cos(rad) * r).toNumber(); Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT,
dc.drawLine(x1, y1, x2, y2); 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;
}
// ------------------------------- 常亮模式 ------------------------------
// 低功耗画面。相比正常画面砍掉的东西:
// - 日期带底色(整条亮橙色横幅是最费电的元素,直接不画)
// - 未点亮的刻度(只画有数据的那部分,且压暗到 45%
// - 四个数据位、电池(全部隐藏)
// 保留的时间和日期压暗到 55%。
//
// 另外整个画面按分钟在 ±4 像素内游走,避免同一个像素长期点亮导致烧屏。
function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?,
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // ±4 设备像素,换算回设计像素
var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
var top = Themes.RINGTOP[ti] as Array<Number>;
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var litTR = (pTR * Layout.TOP_N).toNumber();
var litTL = (pTL * Layout.TOP_N).toNumber();
var litBR = (pBR * Layout.BOT_N).toNumber();
var litBL = (pBL * Layout.BOT_N).toNumber();
for (var i = 0; i < Layout.TOP_N; i++) {
var a = Layout.TOP_PH + Layout.TOP_PITCH * i;
if (i >= Layout.TOP_N - litTR) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, a, Layout.TOP_HW);
}
if (i >= Layout.TOP_N - litTL) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, -a, Layout.TOP_HW);
}
}
for (var i = 0; i < Layout.BOT_N; i++) {
var a = Layout.BOT_PH + Layout.BOT_PITCH * i;
if (i < litBL) {
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, 180.0 + a, Layout.BOT_HW);
}
if (i < litBR) {
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, 180.0 - a, Layout.BOT_HW);
}
}
var h = g.hour;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
var hours = Themes.HOURS[ti] as Array<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.55));
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.55));
dc.setColor(dim(Themes.COLON[ti] as Number, 0.55), 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));
var band = dim(Themes.ACCENT[ti] as Number, 0.55);
Layout.text(dc, Layout.DOW_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, band);
Layout.text(dc, Layout.MD_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band);
}
// ------------------------------- 秒针刷新 ------------------------------
// 表盘正常只有每分钟一次的 onUpdate秒数根本走不动。
// 这里实现 onPartialUpdate系统每秒回调一次但要非常克制
// - 只有当真的有数据位选了「秒」时才做事,否则直接返回
// - 只重绘那一格,用 setClip 把绘制限制在格子的包围盒内
// (每秒重绘整个表盘的功耗预算是不够的)
// - 常亮的 AMOLED 机型直接跳过:那时数据位本来就不显示
function isSeconds(id as Number) as Boolean {
return id == 850 || id == 869;
}
function wantsSeconds() as Boolean {
if (Settings.showTop()
&& (isSeconds(Settings.leftTop()) || isSeconds(Settings.rightTop()))) {
return true;
}
return Settings.showBottom()
&& (isSeconds(Settings.bottomLeft()) || isSeconds(Settings.bottomRight()));
}
// 重绘单个格子:先把它的包围盒涂黑,再把新值画回去。
function repaintSlot(dc as Dc, ti as Number, ctx as Fields.Ctx, id as Number,
x as Numeric, cyDesign as Numeric, just as Number,
maxW as Float, col as Number) as Void {
var half = maxW / 2.0;
var x0 = x - half;
if (just == Graphics.TEXT_JUSTIFY_RIGHT) { x0 = x - maxW; }
else if (just == Graphics.TEXT_JUSTIFY_LEFT) { x0 = x; }
var h = Layout.CAP_COMP * 1.6;
dc.setClip(Layout.px(x0), Layout.px(cyDesign - h / 2),
Layout.px(maxW), Layout.px(h));
// 全屏涂黑。设计稿的底色就是纯黑AMOLED 上黑像素也最省电。
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
dc.clear();
Layout.fittedText(dc, x, cyDesign, Layout.fontComp, Fields.value(id, ctx),
just, col, maxW);
dc.clearClip();
}
function onPartialUpdate(dc as Dc) as Void {
if (mBurnIn || !Layout.ready() || !Fields.ready()) { return; }
if (!wantsSeconds()) { return; }
var ti = Settings.themeIndex(Themes.NAMES.size());
var ctx = new Fields.Ctx(System.getDeviceSettings(), Settings.tempUnit());
var col = Themes.TEXTPRIMARY[ti] as Number;
if (Settings.showTop()) {
if (isSeconds(Settings.leftTop())) {
repaintSlot(dc, ti, ctx, Settings.leftTop(), Layout.LEFT_X,
Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER,
Layout.TOP_MAX_W, col);
}
if (isSeconds(Settings.rightTop())) {
repaintSlot(dc, ti, ctx, Settings.rightTop(), Layout.RIGHT_X,
Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER,
Layout.TOP_MAX_W, col);
}
}
if (Settings.showBottom()) {
if (isSeconds(Settings.bottomLeft())) {
repaintSlot(dc, ti, ctx, Settings.bottomLeft(), Layout.DIST_R,
Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_RIGHT,
Layout.BOT_MAX_W, col);
}
if (isSeconds(Settings.bottomRight())) {
repaintSlot(dc, ti, ctx, Settings.bottomRight(), Layout.STEP_L,
Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_LEFT,
Layout.BOT_MAX_W, col);
} }
} 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());
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);
} }
} }
} }

29
source/FieldTable.mc Normal file
View File

@@ -0,0 +1,29 @@
// ⚠️ 本文件由 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,1,14,707,2,
603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,618,276,12,635,
11,623,8,15,281,742,13,278,277,250,251,257,253,255,254,256,252,615,616,733,712,717,
700,731,706,732,710,709,708,711,714,715,718,719,720,721,306,639,300,301,302,305,304,
308,307,310,309,312,313,314,315,500,515,503,504,505,507,502,501,506,509,510,511,518,
512,513,532,536,537,530,534,522,535,523,516,517,703,704,705
];
// 由字段 id 反查它在 IDS 里的下标;找不到返回 0对应「关闭」
function indexOf(id as Number) as Number {
for (var i = 0; i < IDS.size(); i++) {
if (IDS[i] == id) { return i; }
}
return 0;
}
}

798
source/Fields.mc Normal file
View File

@@ -0,0 +1,798 @@
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;
// ============================================================================
// 数据字段
// ----------------------------------------------------------------------------
// 四个数据位能显示的全部内容,共 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")]);
}
// 英文出 "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;
}
function profile() as UserProfile.Profile? {
if (!mGotProf) { mProf = UserProfile.getProfile(); 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"); } // 秒 (前导零)
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 == 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 == 516) { return str(Owm.get("city")); } // OWM 城市
if (id == 517) { // OWM 更新时间
var dt = Owm.get("dt");
return dt == null ? DASH : clock(new Time.Moment(dt as Number), c);
}
return DASH;
}
function str(v) as String {
return v == null ? DASH : v.toString();
}
// ---- 自定义文字 ----
// 用户在设置里自己填的三段文字。
function custom(id as Number, c as Ctx) as String? {
if (id == 703) { return Settings.text("Custom1"); } // 自定义文字 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 = 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;
}
}

68
source/Owm.mc Normal file
View File

@@ -0,0 +1,68 @@
import Toybox.Lang;
import Toybox.Application;
import Toybox.Math;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Settings;
// ============================================================================
// OpenWeatherMap —— 读取端
// ----------------------------------------------------------------------------
// 本模块**不联网**,只负责把后台服务上次存下来的数据读出来。
// 真正的网络请求在 Fenix8V3Background.mc 里,由系统按定时器唤醒执行。
//
// 为什么要拆成两半表盘进程绝大多数时间根本没在运行onUpdate 又必须够快,
// 不可能在绘制路径里发 HTTP 请求。Connect IQ 给的方案就是后台服务 —— 定时唤醒、
// 请求、把结果通过 Background.exit() 交回前台,前台再写进 Application.Storage。
//
// API key 由用户自己在设置里填(免费档就够)。没填 key 时:
// - enabled() 返回 false后台定时器根本不会注册一次网络都不会发
// - 25 个 OWM 字段全部显示 "--"
//
// 后台服务会把两个接口的响应**压平成一个扁平字典**再存,这样表盘侧永远不用在
// 内存里扛一棵解析好的 JSON 树(手表内存很紧张)。
// ============================================================================
module Owm {
const STORE = "owm"; // 在 Application.Storage 里的键名
const MAX_AGE = 7200; // 超过两小时的数据视为过期(秒)
// 用户填的 API key没填返回空串。
function key() as String {
return Settings.text("OwmKey");
}
// 只有填了 key 才算启用。这个判断同时决定了后台定时器要不要注册,
// 见 Fenix8V3App.scheduleWeather()。
function enabled() as Boolean {
return key().length() > 0;
}
// 取出后台存的整个扁平字典;从没成功拉取过则为 null。
function data() as Dictionary? {
var d = Application.Storage.getValue(STORE);
return (d == null) ? null : (d as Dictionary);
}
// 取单个字段。键名是后台服务定义的短名("temp"/"feels"/"hi1"…),
// 与 Fenix8V3Background.mc 里的 put() 调用一一对应。
function get(field as String) {
var d = data();
if (d == null) { return null; }
return d.get(field);
}
// 数据是否还新鲜。目前 Fields 里没有强制用它 —— 手表离线一段时间后
// 显示稍旧的天气,比直接显示 "--" 更有用;留着给需要的地方调用。
function fresh() as Boolean {
var t = get("dt");
if (t == null) { return false; }
return (Time.now().value() - (t as Number)) < MAX_AGE;
}
// 该字段编号是否属于 OWM 分类。500537 这一段全是 OWM 的编号,
// 与 Garmin 原生天气300315完全分开。
function owns(id as Number) as Boolean {
return (id >= 500 && id <= 537) || id == 502 || id == 503;
}
}

93
source/Settings.mc Normal file
View File

@@ -0,0 +1,93 @@
import Toybox.Lang;
import Toybox.Application;
// ============================================================================
// 设置读取
// ----------------------------------------------------------------------------
// 所有存储的偏好设置集中在这里,默认值就是设计稿的那套配置。
// 键名与 resources/settings/properties.xml 一一对应。
//
// 为什么要包一层而不是各处直接调 Properties.getValue
// Connect IQ 的 Properties.getValue 遇到「手表还没存过的键」时**不是返回 null
// 而是抛 Out of Bounds 异常**。每次版本更新新增设置项,在手表把新的
// properties.xml 合并进去之前都会撞上这个坑,轻则某个字段读不到,重则整个表盘
// 崩掉。所以全部读取都走本模块,异常一律降级成默认值。
// ============================================================================
module Settings {
// ---- 四个数据位的默认字段 ----
// 字段编号沿用 https://watchface.io/docs/datafields与 Fields.mc 中一致。
// 这四个默认值合起来就是设计稿上的样子。
const DEF_LEFT_TOP = 712; // 左上:日出
const DEF_RIGHT_TOP = 639; // 右上:预报最高/最低气温
const DEF_BOTTOM_LEFT = 3; // 左下:距离
const DEF_BOTTOM_RIGHT = 1; // 右下:步数
// ------------------------------------------------------------------------
// 底层读取:吞掉「键不存在」异常,返回 null 交给上层兜底。
// 注意 catch 在这里是有效的Properties 抛的是普通异常);而 Background
// 模块的 Out of Bounds 属于 VM 级错误catch 不住,那边只能用别的办法绕开,
// 详见 Fenix8V3App.scheduleWeather()。
// ------------------------------------------------------------------------
function raw(key as String) {
try {
return Application.Properties.getValue(key);
} catch (e) {
return null;
}
}
// 读数字型设置,取不到就用 def。
function number(key as String, def as Number) as Number {
var v = raw(key);
if (v == null) { return def; }
return v as Number;
}
// 读布尔型设置,取不到就用 def。
function bool(key as String, def as Boolean) as Boolean {
var v = raw(key);
if (v == null) { return def; }
return v as Boolean;
}
// 读字符串型设置,取不到返回空串(不是 null省得每个调用点再判一次
function text(key as String) as String {
var v = raw(key);
if (v == null) { return ""; }
return v.toString();
}
// ------------------------------------------------------------------------
// 主题序号0 基)。做范围钳制是为了防止这种情况:用户存的是「主题 7」
// 之后某个版本删掉了一套主题,存量值就会越界 —— 直接崩数组下标。
// ------------------------------------------------------------------------
function themeIndex(count as Number) as Number {
var i = number("Theme", 1) - 1;
if (i < 0 || i >= count) { return 0; }
return i;
}
// ---- 四个数据位选中的字段编号 ----
function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); }
function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); }
function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); }
function bottomRight() as Number { return number("BottomRight", DEF_BOTTOM_RIGHT); }
// ---- 进度环四段各自绑定的指标 ----
// 注意这里的编号是进度环自己的一张短表0 步数 / 1 卡路里 / 2 距离 /
// 3 楼层 / 4 动动条 / 5 活动分钟 / 6 身体电量),与上面数据位的字段编号
// 是两套体系,不要混用。取值逻辑在 Fenix8V3View.ringProgress()。
function ringTR() as Number { return number("RingTR", 0); } // 右上:步数
function ringTL() as Number { return number("RingTL", 1); } // 左上:卡路里
function ringBR() as Number { return number("RingBR", 4); } // 右下:动动条
function ringBL() as Number { return number("RingBL", 3); } // 左下:楼层
// 温度单位0 跟随手表 / 1 强制摄氏 / 2 强制华氏。
function tempUnit() as Number { return number("TempUnit", 0); }
// ---- 三个显隐开关 ----
function showTop() as Boolean { return bool("ShowTop", true); }
function showBand() as Boolean { return bool("ShowBand", true); }
function showBottom() as Boolean { return bool("ShowBottom", true); }
}

120
source/Solar.mc Normal file
View File

@@ -0,0 +1,120 @@
import Toybox.Lang;
import Toybox.Math;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.Position;
// ============================================================================
// 太阳高度角事件
// ----------------------------------------------------------------------------
// Connect IQ 只给了 Weather.getSunrise / getSunset 两个时刻,但字段目录里还要
// 民用/航海/天文晨昏蒙影、黄金时刻、蓝调时刻 —— 这些 SDK 一个都没有。
//
// 好在它们本质是同一个问题换个参数:**太阳中心到达高度角 h 的时刻是几点**。
// 所以只写一个 event() 就够了,各事件只是 h 不同:
// 日出日落 -0.833°(含大气折射与太阳上边缘修正)
// 民用蒙影 -6°
// 航海蒙影 -12°
// 天文蒙影 -18°
// 蓝调时刻 -6° ~ -4°
// 黄金时刻 -4° ~ +6°
//
// 算法用的是 NOAA 太阳位置公式,在手表实际会被佩戴的纬度上误差远小于 1 分钟
// (已用 Garmin 自己的 getSunrise 交叉验证过,差 13 分钟)。
// 极圈内某天太阳压根到不了要求的高度时返回 null上层显示 "--"。
// ============================================================================
module Solar {
// 各事件对应的太阳高度角(度)。
const ALT_SUNRISE = -0.833d; // upper limb, refraction included
const ALT_CIVIL = -6.0d;
const ALT_NAUTICAL = -12.0d;
const ALT_ASTRO = -18.0d;
const ALT_BLUE = -4.0d; // blue hour runs -6 to -4
const ALT_GOLDEN = 6.0d; // golden hour runs -4 to +6
const RAD = Math.PI / 180.0d;
// 从 2000-01-01 到当天的**整数天数**,再按经度往东/西挪一点,
// 让后面算出的中天时刻落在当地正午而不是 UT 正午。
//
// ⚠️ 这里必须取整数天。第一版忘了取整,儒略日末尾的 .5 被带了进去,
// 结果中天算到了午夜,日出日落直接对调、整体偏了 1.5 天。
// 是靠与 Garmin 的 getSunrise 对比才发现的。
function daysFromEpoch(g as Gregorian.Info, lonDeg as Double) as Double {
var y = g.year;
var m = g.month;
if (m <= 2) { y = y - 1; m = m + 12; }
var a = y / 100;
var b = 2 - a + a / 4;
var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber()
+ g.day + b - 1524.5;
var whole = Math.round(jd.toDouble() - 2451545.0d + 0.5d);
return whole + 0.0008d - lonDeg / 360.0d;
}
// 太阳位于高度角 h 时的时角(度)。
// acos 的参数超出 [-1,1] 说明这一天太阳始终在该高度之上或之下
// (极昼极夜),此时返回 null。
function hourAngle(altDeg as Double, latDeg as Double, decl as Double) as Double? {
var latR = latDeg * RAD;
var c = (Math.sin(altDeg * RAD) - Math.sin(latR) * Math.sin(decl))
/ (Math.cos(latR) * Math.cos(decl));
if (c > 1.0 || c < -1.0) { return null; }
return Math.acos(c) / RAD;
}
// 太阳到达 altDeg 的时刻。morning=true 取东边那次穿越(上午),
// false 取西边那次(下午)。
function event(altDeg as Double, loc as Position.Location?,
morning as Boolean) as Time.Moment? {
if (loc == null) { return null; }
var deg = loc.toDegrees();
var lat = deg[0].toDouble();
var lon = deg[1].toDouble();
var now = Time.now();
var g = Gregorian.utcInfo(now, Time.FORMAT_SHORT);
var n = daysFromEpoch(g, lon);
// 平近点角 M、中心差 C、黄经 lambda —— NOAA 公式的三步。
var M = (357.5291d + 0.98560028d * n) * RAD;
var C = 1.9148d * Math.sin(M) + 0.0200d * Math.sin(2 * M)
+ 0.0003d * Math.sin(3 * M);
var lambda = (M / RAD + C + 180.0d + 102.9372d) * RAD;
// 中天时刻(当地真正午),仍以「距历元的天数」表示。
var transit = 2451545.0d + n + 0.0053d * Math.sin(M)
- 0.0069d * Math.sin(2 * lambda);
var decl = Math.asin(Math.sin(lambda) * Math.sin(23.4397d * RAD));
var w = hourAngle(altDeg, lat, decl);
if (w == null) { return null; }
var jd = morning ? (transit - w / 360.0d) : (transit + w / 360.0d);
// 儒略日换算回 Unix 时间戳。Time.Moment 收的是 UTC 秒数,
// 之后 Gregorian.info() 会自动转成本地时间显示。
var unix = (jd - 2440587.5d) * 86400.0d;
return new Time.Moment(unix.toNumber());
}
// 按字段编号返回对应事件的时刻;不属于本模块的编号返回 null。
// 蓝调/黄金时刻取的是「该时段开始的那一刻」。
function byId(id as Number, loc as Position.Location?) as Time.Moment? {
if (id == 710) { return event(ALT_CIVIL, loc, true); }
if (id == 709) { return event(ALT_NAUTICAL, loc, true); }
if (id == 708) { return event(ALT_ASTRO, loc, true); }
if (id == 711) { return event(ALT_CIVIL, loc, true); } // blue hour opens
if (id == 714) { return event(ALT_BLUE, loc, true); } // golden hour opens
if (id == 715) { return event(ALT_GOLDEN, loc, false); } // golden hour opens
if (id == 718) { return event(ALT_BLUE, loc, false); } // blue hour opens
if (id == 719) { return event(ALT_CIVIL, loc, false); }
if (id == 720) { return event(ALT_NAUTICAL, loc, false); }
if (id == 721) { return event(ALT_ASTRO, loc, false); }
return null;
}
function owns(id as Number) as Boolean {
return id == 708 || id == 709 || id == 710 || id == 711 || id == 714
|| id == 715 || id == 718 || id == 719 || id == 720 || id == 721;
}
}

View File

@@ -1,110 +1,118 @@
// AUTO-GENERATED from watchface-tokens.json — do not edit by hand. // ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。
module Themes { // 要改配色就改设计稿里的 SVG然后重新跑一遍生成器。
// THEMES: id -> theme dictionary. //
// ringTop / ringBottom are gradient stop arrays: [angle, 0xRRGGBB] sorted ascending. // 七套主题的全部颜色数据。各数组的下标就是主题序号0=Ember … 6=Kelp
var THEMES = { //
"01" => { // RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。
"name" => "Ember", // 下标 0 是最靠近 12 点 / 6 点的那根。
"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]], // HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。
"tickOff" => 0x545655, // TICKOFF 未点亮刻度的颜色
"anchorDot" => 0x525551, // ANCHORDOT 四个锚点圆点
"bandFill" => 0xFEAA00, // BANDFILL/BANDTEXT 日期带的底色 / 文字色
"bandText" => 0x000002, // ACCENT 图标与底部标签
"accent" => 0xFCA901, // TEXTPRIMARY 各项数值
"textPrimary" => 0xFAFAFA, import Toybox.Lang;
"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,
}
};
// theme id list for settings enum (1-based index -> id) module Themes {
var ORDER = [
"01", "02", "03", "04", "05", "06", "07" const NAMES = [
"Ember",
"Aurora",
"Brass",
"Voltage",
"Reef",
"Acid",
"Kelp",
]; ];
const RINGTOP = [
// 01 Ember
[0xFFF200,0xFFE601,0xFFDB01,0xFFCE00,0xFFC200,0xFEB600,0xFEAA00,0xFF9D01,0xFF9100,0xFE8600,0xFE7900,
0xFE6E00,0xFF6100,0xFF5500,0xFF4801,0xFF3C01,0xFF3002,0xFF2600,0xFF2600,0xFF2600,0xFF2600],
// 02 Aurora
[0xA4F909,0xA4F918,0x9DF91E,0x9EFA31,0x95FA39,0x94F94A,0x8AFA52,0x8BFA61,0x84FA6A,0x85FA7C,0x7BFA82,
0x7BFA94,0x73FB9B,0x74FBAB,0x6BFBB4,0x6AFCC5,0x63FCCD,0x62FCDE,0x5AFCE6,0x59FDF6,0x52FDFF],
// 03 Brass
[0xB3814A,0xB5854B,0xB48951,0xBD8E52,0xBD915B,0xC5955B,0xC59962,0xC59D62,0xCD9D6B,0xCCA169,0xCCA573,
0xD6AA73,0xD5AE7B,0xD5B37A,0xDEB682,0xDEBA83,0xDEBD8B,0xE5C28B,0xE6C693,0xE6CA94,0xEFCE95],
// 04 Voltage
[0x5AAEEE,0x62B2E5,0x6AB6DF,0x72BACC,0x7CBEC5,0x83C1B4,0x8CC6AC,0x94C99C,0x9DCE94,0xA3D283,0xACD67A,
0xB5DA6A,0xBEDD62,0xC5E252,0xCDE64B,0xD5EA3A,0xDEED30,0xE7F221,0xEDF617,0xF6FA08,0xFFFB01],
// 05 Reef
[0xF57D07,0xED8618,0xE68D20,0xDE9130,0xD49939,0xCC9D49,0xC5A552,0xBCAA61,0xB3B26A,0xADB67C,0xA4BE83,
0x9CC694,0x94CA9C,0x8CD2AB,0x83D6B5,0x7BDEC5,0x74E6CE,0x6AEADF,0x63F2E7,0x5BF6F6,0x52FDFF],
// 06 Acid
[0x9CCA0B,0x9CCF08,0xA4CE08,0xA4D208,0xA4D60A,0xA4D60A,0xABDA06,0xADDA00,0xADDE02,0xADDE02,0xB4E200,
0xB4E600,0xB4E600,0xB4EA00,0xB4EA00,0xBDEE00,0xBDF101,0xBDF101,0xBEF600,0xC6F600,0xC5FA00],
// 07 Kelp
[0xEE8910,0xDD8D17,0xD49117,0xCD911F,0xC59528,0xBE9931,0xB49D3A,0xAB9D39,0xA3A141,0x94A54A,0x8BA552,
0x83AA5A,0x7BAA5A,0x73AE63,0x69B269,0x63B273,0x52B67C,0x4BBA82,0x42B983,0x39BD8B,0x31C293],
];
const RINGBOTTOM = [
// 01 Ember
[0xAD1602,0xAD1602,0xB21902,0xB32301,0xB43102,0xBC3C01,0xC54801,0xC45501,0xCE6100,0xCC6D00,0xD57900,
0xD58401,0xDD9201,0xDE9D02,0xE3A901,0xE7B501,0xEBC202,0xEECE02,0xF2DA02,0xF6E602,0xFAF202],
// 02 Aurora
[0x52FDFF,0x59FCF6,0x5AFCE6,0x62FCDE,0x62FCCD,0x6AFCC5,0x6BFBB4,0x74FBAB,0x73FB9B,0x7BFA94,0x7BFA83,
0x85FA7C,0x84FA6A,0x8BFA61,0x8FFA55,0x93F948,0x97F93C,0x9BF930,0xA0F924,0xA4F918,0xA8F80C],
// 03 Brass
[0xB47D4A,0xB3814A,0xB4844A,0xB48951,0xBD8E52,0xBD915B,0xC5955B,0xC49962,0xC59962,0xCD9D6A,0xCDA169,
0xCCA572,0xD6AA73,0xD5AE7B,0xD8B07D,0xDAB482,0xDDB786,0xE0BB8A,0xE2BF8E,0xE5C293,0xE8C697],
// 04 Voltage
[0xFFFB01,0xF6FA08,0xEDF617,0xE7F221,0xDEED30,0xD5EA38,0xCDE64B,0xC5E252,0xBEDE62,0xB5DA6A,0xACD67A,
0xA3D182,0x9CCE94,0x94C99C,0x8BC5A9,0x83C1B5,0x7BBDC1,0x73B9CD,0x6AB5D9,0x62B1E5,0x5AACF1],
// 05 Reef
[0xFE7900,0xF57C07,0xED8618,0xE68920,0xDE9130,0xD49939,0xCD9D49,0xC5A552,0xBCAA61,0xB3B26A,0xADB67C,
0xA4BE83,0x9CC694,0x94C99C,0x8CD1AA,0x84D7B6,0x7BDEC2,0x73E4CF,0x6BEADB,0x63F1E7,0x5BF7F4],
// 06 Acid
[0xC5FA00,0xC6F600,0xBEF600,0xBDF101,0xBDF101,0xBDEE00,0xB4EA00,0xB4EA00,0xB4E600,0xB4E600,0xB4E100,
0xACDE01,0xADDE02,0xADD900,0xABD801,0xAAD501,0xA9D301,0xA8D002,0xA6CE02,0xA5CB02,0xA4C902],
// 07 Kelp
[0xCD5008,0xC5550E,0xBD5A1A,0xB46118,0xAC651F,0xA46A29,0x9B712F,0x9C763A,0x94793A,0x8B8241,0x83854A,
0x7A8952,0x74915A,0x6A955A,0x649A64,0x5D9F6A,0x55A570,0x4EAA77,0x46AF7D,0x3FB484,0x38BA8A],
];
const HOURS = [
// 01 Ember
[0xFFFFFF,0xF6F8F5,0xEEF0EE,0xE5E4E6,0xDEDDDE,0xD5D6D5,0xCFCFCF,0xC5C7C4,0xBCBBBD,0xB4B4B4,0xACADAB],
// 02 Aurora
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
// 03 Brass
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
// 04 Voltage
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
// 05 Reef
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
// 06 Acid
[0xDEFA32,0xDDFA2B,0xD8FA29,0xD5F921,0xD5F91E,0xD2FA17,0xCDFB10,0xCDFB10,0xCDFA08,0xC5FA05,0xC5FA00],
// 07 Kelp
[0xF58909,0xF68509,0xF18209,0xED8108,0xED8008,0xED7D08,0xEE7A08,0xEE7908,0xE67508,0xE67408,0xE57106],
];
const MINUTES = [
// 01 Ember
[0xFEAA00,0xFE9E01,0xFF8A00,0xFF7C00,0xFE6A00,0xFE5B01,0xFF4B02,0xFE3B02,0xFF2B01,0xFF2600,0xFF2600],
// 02 Aurora
[0xACFA00,0x98F914,0x89FA2A,0x79FA44,0x6BFA59,0x5DF971,0x4DFA91,0x3DFAA6,0x28FBC1,0x15FCD9,0x0CFCF5],
// 03 Brass
[0xEFCE95,0xE6C48F,0xE3BC88,0xDEB581,0xD6AE7B,0xCEA673,0xCD9E6B,0xC49762,0xBD8F5A,0xB88752,0xB3804A],
// 04 Voltage
[0x53AAFF,0x5FB0E9,0x70B7CF,0x85C0B9,0x96CBA0,0xA4D283,0xB3D86D,0xC4E154,0xD2E93C,0xEAF420,0xFBFB09],
// 05 Reef
[0xFE7900,0xF18314,0xE3902C,0xD19E44,0xBBAC5E,0xACB675,0x9DC490,0x8CD0A9,0x7EDFC2,0x6FECDA,0x59FAF7],
// 06 Acid
[0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00],
// 07 Kelp
[0xF58909,0xF18308,0xED8007,0xEC7808,0xE67508,0xDF6E08,0xDE6907,0xDE6308,0xD45E09,0xD25808,0xCD5408],
];
const TICKOFF = [0x525551, 0x525551, 0x392810, 0x525551, 0x525551, 0x525551, 0x525551];
const ANCHORDOT = [0x525551, 0x0F3C82, 0x62441E, 0x53AAFF, 0xFE7900, 0xF58909, 0xF58909];
const BANDFILL = [0xFEAA00, 0x525551, 0x392810, 0x53AAFF, 0x52FDFF, 0x525551, 0x525551];
const BANDTEXT = [0x000001, 0xFFFFFF, 0xFFFFFE, 0x010000, 0x010001, 0x000000, 0x000000];
const COLON = [0x525551, 0x525551, 0x525551, 0x525551, 0x525551, 0x525551, 0x525551];
const ACCENT = [0xFEAA05, 0xADABAE, 0xB57E4B, 0xADABAE, 0xFEAA05, 0xC6FA03, 0x34C294];
const TEXTPRIMARY = [0xFFFFFF, 0xFFFFFF, 0xF0CF97, 0x55ABFF, 0xFFFFFF, 0xFFFFFF, 0xFFFFFF];
} }

318
tools/gen_fields.py Normal file
View File

@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""一张表 → 字段 id 表、两种语言的文案、以及设置项 XML。
字段编号沿用 https://watchface.io/docs/datafields方便两边对照但这里只收录
Connect IQ 在手表本机能算出来的字段。
需要联网服务的OpenWeatherMap 的部分、StormGlass、需要手机侧与 Garmin
Connect 集成的(按运动分类的周/月累计)、需要用户装了别的 App 的CGM、
补水、行情),都不在这里 —— 具体原因见 README 第 7 节。
python3 tools/gen_fields.py
产出:
source/FieldTable.mc 字段 id 表 + 下标反查
resources/settings/settings.xml 四个数据位的选择器
resources/strings/fields.xml 英文的字段名与表盘标签
resources-chn/strings/fields.xml 中文的字段名与表盘标签
加字段的流程:在下面的 FIELDS 表里加一行 → 跑一遍本脚本 →
到 source/Fields.mc 对应分类的函数里补一个分支。设置项、两种语言的文案、
表盘上的标签都会自动跟上,不会漏。
"""
import os
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
# id, key, English name, Chinese name, on-face label (en), on-face label (cn)
FIELDS = [
(0, "Off", "Off", "关闭", "", ""),
# ---- date / time ----------------------------------------------------
(860, "Time", "Time", "时间", "TIME", "时间"),
(880, "UtcTime", "UTC Time", "UTC 时间", "UTC", "UTC"),
(864, "AltTime1", "Alternate Time 1", "第二时区 1", "TZ1", "时区1"),
(865, "AltTime2", "Alternate Time 2", "第二时区 2", "TZ2", "时区2"),
(851, "DayMonthDay", "Day Month Day", "星期 月 日", "DATE", "日期"),
(872, "DayDate", "Day Date", "星期 日", "DATE", "日期"),
(852, "MonthDay", "Month Day", "月 日", "DATE", "日期"),
(855, "DayOfMonth", "Day of Month", "", "DAY", ""),
(870, "DayShort", "Day (Tue)", "星期简写", "DAY", "星期"),
(856, "DayFull", "Full Day (Tuesday)", "星期全称", "DAY", "星期"),
(853, "MonthName", "Month (Jan)", "月份", "MON", ""),
(868, "MonthNum", "Month (6)", "月份数字", "MON", ""),
(858, "Meridiem", "AM/PM", "上午/下午", "", ""),
(859, "WeekIso", "Week Number (ISO)", "周数 (ISO)", "WEEK", ""),
(850, "Seconds", "Seconds", "", "SEC", ""),
(869, "Seconds0", "Seconds (09)", "秒 (前导零)", "SEC", ""),
(873, "WeekCal", "Week Number", "周数", "WEEK", ""),
# ---- activity -------------------------------------------------------
(1, "Steps", "Steps", "步数", "STEP", "步数"),
(14, "StepsRound", "Steps Rounded", "步数 (取整)", "STEP", "步数"),
(707, "StepsToGo", "Steps to Go", "剩余步数", "TO GO", "还差"),
(2, "Calories", "Calories", "卡路里", "KCAL", "千卡"),
(603, "ActiveCal", "Active Calories", "活动卡路里", "ACAL", "活动"),
(150, "ActiveMin", "Active Minutes", "活动分钟", "MIN", "分钟"),
(152, "ActiveMod", "Active Min (Moderate)", "中强度分钟", "MOD", "中强度"),
(154, "ActiveVig", "Active Min (Vigorous)", "高强度分钟", "VIG", "高强度"),
(151, "ActiveWeek", "Active Minutes (Week)", "本周活动分钟", "WEEK", "周分钟"),
(3, "Distance", "Distance", "距离", "DST", "距离"),
(4, "FloorsUp", "Floors Climbed", "上楼层数", "FLR", "楼层"),
(5, "FloorsDown", "Floors Descended", "下楼层数", "DESC", "下楼"),
(6, "MetersUp", "Distance Climbed", "爬升高度", "ASC", "爬升"),
(9, "PushDist", "Push Distance", "推行距离", "PUSH", "推行"),
(10, "Pushes", "Pushes", "推行次数", "PUSH", "推行"),
(219, "StepsWeek", "Steps (Week to Date)", "本周步数", "WEEK", "周步数"),
(220, "Steps7d", "Steps (Last 7 Days)", "近 7 天步数", "7D", "7天"),
(211, "CalWeek", "Calories (Week to Date)", "本周卡路里", "WEEK", "周千卡"),
(212, "Cal7d", "Calories (Last 7 Days)", "近 7 天卡路里", "7D", "7天"),
(213, "DistWeek", "Distance (Week to Date)", "本周距离", "WEEK", "周距离"),
(214, "Dist7d", "Distance (Last 7 Days)", "近 7 天距离", "7D", "7天"),
(203, "FloorsWeek", "Floors (Week to Date)", "本周楼层", "WEEK", "周楼层"),
(204, "Floors7d", "Floors (Last 7 Days)", "近 7 天楼层", "7D", "7天"),
(200, "ActiveMin7d", "Active Minutes (7 Days)", "近 7 天活动分钟", "7D", "7天"),
# ---- body -----------------------------------------------------------
(618, "HeartRate", "Heart Rate", "心率", "HR", "心率"),
(276, "RestingHr", "Resting Heart Rate", "静息心率", "RHR", "静息"),
(12, "Stress", "Stress Score", "压力指数", "STRS", "压力"),
(635, "PulseOx", "Pulse Ox", "血氧", "SPO2", "血氧"),
(11, "Respiration", "Respiration Rate", "呼吸频率", "RESP", "呼吸"),
(623, "BodyBattery", "Body Battery", "身体电量", "BODY", "体能"),
(8, "MoveBar", "Move Bar", "活动条", "MOVE", "活动"),
(15, "MoveBarRev", "Move Bar Reversed", "活动条 (反向)", "MOVE", "活动"),
(281, "Weight", "Weight", "体重", "WT", "体重"),
(742, "Bmi", "BMI", "BMI", "BMI", "BMI"),
(13, "Recovery", "Time to Recovery", "恢复时间", "RCVR", "恢复"),
(278, "Vo2Run", "VO2 Max (Run)", "最大摄氧量 (跑)", "VO2", "摄氧"),
(277, "Vo2Bike", "VO2 Max (Bike)", "最大摄氧量 (骑)", "VO2", "摄氧"),
# ---- system ---------------------------------------------------------
(250, "Battery", "Battery", "电量", "BATT", "电量"),
(251, "BatteryDays", "Battery in Days", "剩余天数", "DAYS", "天数"),
(257, "BatteryBoth", "Battery / Days", "电量 / 天数", "BATT", "电量"),
(253, "Alarms", "Alarms", "闹钟", "ALRM", "闹钟"),
(255, "Notifications", "Notifications", "通知", "MSG", "通知"),
(254, "DoNotDisturb", "Do Not Disturb", "勿扰模式", "DND", "勿扰"),
(256, "Bluetooth", "Bluetooth", "蓝牙", "BT", "蓝牙"),
(252, "Solar", "Solar Intensity", "太阳能强度", "SOLR", "太阳能"),
# ---- environment ----------------------------------------------------
(615, "Altitude", "Altitude", "海拔", "ALT", "海拔"),
(616, "SeaPressure", "Sea Level Pressure", "海平面气压", "hPa", "气压"),
(733, "AmbPressure", "Ambient Pressure", "环境气压", "hPa", "气压"),
(712, "Sunrise", "Sunrise", "日出", "RISE", "日出"),
(717, "Sunset", "Sunset", "日落", "SET", "日落"),
(700, "NextSun", "Next Sun Event", "下一太阳事件", "SUN", "太阳"),
(731, "UntilSun", "Time Until Sun Event", "距太阳事件", "SUN", "太阳"),
(706, "MoonAge", "Moon Age", "月龄", "MOON", "月龄"),
(732, "MoonLit", "Moon Illumination", "月球照明", "MOON", "月相"),
(710, "CivilAm", "Civil Twilight Start", "民用晨光始", "CIVIL", "民用晨"),
(709, "NauticalAm", "Nautical Twilight Start", "航海晨光始", "NAUT", "航海晨"),
(708, "AstroAm", "Astronomical Twilight Start", "天文晨光始", "ASTR", "天文晨"),
(711, "BlueAm", "Blue Hour (AM)", "蓝调时刻 (晨)", "BLUE", "蓝调晨"),
(714, "GoldenAm", "Golden Hour (AM)", "黄金时刻 (晨)", "GOLD", "黄金晨"),
(715, "GoldenPm", "Golden Hour (PM)", "黄金时刻 (昏)", "GOLD", "黄金昏"),
(718, "BluePm", "Blue Hour (PM)", "蓝调时刻 (昏)", "BLUE", "蓝调昏"),
(719, "CivilPm", "Civil Twilight End", "民用昏影终", "CIVIL", "民用昏"),
(720, "NauticalPm", "Nautical Twilight End", "航海昏影终", "NAUT", "航海昏"),
(721, "AstroPm", "Astronomical Twilight End", "天文昏影终", "ASTR", "天文昏"),
# ---- weather --------------------------------------------------------
(306, "Temp", "Temperature", "气温", "TEMP", "气温"),
(639, "TempHiLo", "Temperature High/Low", "最高/最低气温", "HI/LO", "高低温"),
(300, "FeelsLike", "Feels Like", "体感温度", "FEEL", "体感"),
(301, "TempHigh", "High Temperature", "最高气温", "HIGH", "最高"),
(302, "TempLow", "Low Temperature", "最低气温", "LOW", "最低"),
(305, "Humidity", "Humidity", "湿度", "HUM", "湿度"),
(304, "Precip", "Precipitation %", "降水概率", "RAIN", "降水"),
(308, "WindSpeed", "Wind Speed", "风速", "WIND", "风速"),
(307, "WindBearing", "Wind Bearing", "风向角", "WIND", "风向"),
(310, "WindDir", "Wind Direction", "风向", "WIND", "风向"),
(309, "WeatherTime", "Weather Update Time", "天气更新时间", "WX", "更新"),
(312, "PressureSea", "Pressure (Sea Level)", "海平面气压", "hPa", "气压"),
(313, "Uvi", "UV Index", "紫外线指数", "UVI", "紫外线"),
(314, "Visibility", "Visibility", "能见度", "VIS", "能见度"),
(315, "DewPoint", "Dew Point", "露点", "DEW", "露点"),
# ---- OpenWeatherMap -------------------------------------------------
# Fetched by the background service; see source/Owm.mc.
(500, "OwmTemp", "OWM Temperature", "OWM 气温", "TEMP", "气温"),
(515, "OwmMinMax", "OWM Max/Min Temp", "OWM 最高/最低", "HI/LO", "高低温"),
(503, "OwmFeels", "OWM Feels Like", "OWM 体感温度", "FEEL", "体感"),
(504, "OwmMax", "OWM Temp Max", "OWM 最高温", "HIGH", "最高"),
(505, "OwmMin", "OWM Temp Min", "OWM 最低温", "LOW", "最低"),
(507, "OwmDescShort", "OWM Description", "OWM 天气", "WX", "天气"),
(502, "OwmDescLong", "OWM Description (Long)", "OWM 天气详述", "WX", "天气"),
(501, "OwmHumidity", "OWM Humidity", "OWM 湿度", "HUM", "湿度"),
(506, "OwmPressure", "OWM Pressure", "OWM 气压", "hPa", "气压"),
(509, "OwmVisibility", "OWM Visibility", "OWM 能见度", "VIS", "能见度"),
(510, "OwmWind", "OWM Wind Speed", "OWM 风速", "WIND", "风速"),
(511, "OwmWindDeg", "OWM Wind Degrees", "OWM 风向角", "WIND", "风向"),
(518, "OwmWindDir", "OWM Wind Direction", "OWM 风向", "WIND", "风向"),
(512, "OwmGust", "OWM Wind Gust", "OWM 阵风", "GUST", "阵风"),
(513, "OwmRain", "OWM Rain 1h", "OWM 1h 降雨", "RAIN", "降雨"),
(532, "OwmSnow", "OWM Snow 1h", "OWM 1h 降雪", "SNOW", "降雪"),
(536, "OwmClouds", "OWM Cloud Cover", "OWM 云量", "CLD", "云量"),
(537, "OwmPrecip", "OWM Precipitation %", "OWM 降水概率", "RAIN", "降水"),
(530, "OwmDew", "OWM Dew Point", "OWM 露点", "DEW", "露点"),
(534, "OwmTomorrow", "OWM Temp Tomorrow", "OWM 明日气温", "TMRW", "明日"),
(522, "OwmMinMaxTmrw", "OWM Min/Max Tomorrow", "OWM 明日高低温", "TMRW", "明日"),
(535, "OwmDayAfter", "OWM Temp Day After", "OWM 后日气温", "D+2", "后日"),
(523, "OwmMinMaxD2", "OWM Min/Max Day After", "OWM 后日高低温", "D+2", "后日"),
(516, "OwmCity", "OWM City Name", "OWM 城市", "CITY", "城市"),
(517, "OwmUpdated", "OWM Update Time", "OWM 更新时间", "OWM", "更新"),
# ---- custom ---------------------------------------------------------
(703, "Custom1", "Custom Text 1", "自定义文字 1", "", ""),
(704, "Custom2", "Custom Text 2", "自定义文字 2", "", ""),
(705, "Custom3", "Custom Text 3", "自定义文字 3", "", ""),
]
SLOTS = [("LeftTop", "Top-Left Field", "左上数据"),
("RightTop", "Top-Right Field", "右上数据"),
("BottomLeft", "Bottom-Left Field", "左下数据"),
("BottomRight", "Bottom-Right Field", "右下数据")]
RING = [(0, "Steps", "Steps", "步数"), (1, "Calories", "Calories", "卡路里"),
(2, "Distance", "Distance", "距离"), (3, "Floors", "Floors", "楼层"),
(4, "MoveBar", "Move Bar", "动动条"), (5, "ActiveMin", "Active Minutes", "活动分钟"),
(6, "BodyBattery", "Body Battery", "身体电量")]
OTHER_SETTINGS = """
<setting propertyKey="@Properties.TempUnit" title="@Strings.SettingTempUnit">
<settingConfig type="list">
<listEntry value="0">@Strings.TempUnitAuto</listEntry>
<listEntry value="1">@Strings.TempUnitC</listEntry>
<listEntry value="2">@Strings.TempUnitF</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.AltOffset1" title="@Strings.SettingAltOffset1">
<settingConfig type="numeric" min="-720" max="840"/>
</setting>
<setting propertyKey="@Properties.AltOffset2" title="@Strings.SettingAltOffset2">
<settingConfig type="numeric" min="-720" max="840"/>
</setting>
<setting propertyKey="@Properties.OwmKey" title="@Strings.SettingOwmKey">
<settingConfig type="alphaNumeric" maxLength="40"/>
</setting>
<setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1">
<settingConfig type="alphaNumeric" maxLength="12"/>
</setting>
<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 propertyKey="@Properties.ShowTop" title="@Strings.SettingShowTop">
<settingConfig type="boolean"/>
</setting>
<setting propertyKey="@Properties.ShowBand" title="@Strings.SettingShowBand">
<settingConfig type="boolean"/>
</setting>
<setting propertyKey="@Properties.ShowBottom" title="@Strings.SettingShowBottom">
<settingConfig type="boolean"/>
</setting>
"""
def field_list(indent=8):
pad = " " * indent
return "\n".join('%s<listEntry value="%d">@Strings.Fld%s</listEntry>'
% (pad, fid, key) for fid, key, _, _, _, _ in FIELDS)
def write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w").write(text)
print("wrote", os.path.relpath(path, ROOT))
def main():
ids = [f[0] for f in FIELDS]
assert len(ids) == len(set(ids)), "duplicate field id"
# ---- FieldTable.mc ----
L = ["// ⚠️ 本文件由 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 = ["]
row = " "
for i, fid in enumerate(ids):
piece = "%d%s" % (fid, "," if i < len(ids) - 1 else "")
if len(row) + len(piece) > 92:
L.append(row)
row = " "
row += piece
L += [row, " ];", "",
" // 由字段 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;",
" }",
"}"]
write(os.path.join(ROOT, "source", "FieldTable.mc"), "\n".join(L) + "\n")
# ---- settings.xml ----
S = ['<settings>', '',
' <setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme">',
' <settingConfig type="list">']
names = ["Ember", "Aurora", "Brass", "Voltage", "Reef", "Acid", "Kelp"]
for i, n in enumerate(names):
S.append(' <listEntry value="%d">@Strings.Theme%s</listEntry>' % (i + 1, n))
S += [' </settingConfig>', ' </setting>', '']
for slot, _, _ in SLOTS:
S += [' <setting propertyKey="@Properties.%s" title="@Strings.Setting%s">' % (slot, slot),
' <settingConfig type="list">', field_list(12),
' </settingConfig>', ' </setting>', '']
for ring in ("RingTR", "RingTL", "RingBR", "RingBL"):
S += [' <setting propertyKey="@Properties.%s" title="@Strings.Setting%s">' % (ring, ring),
' <settingConfig type="list">']
for rid, key, _, _ in RING:
S.append(' <listEntry value="%d">@Strings.Metric%s</listEntry>' % (rid, key))
S += [' </settingConfig>', ' </setting>', '']
S.append(OTHER_SETTINGS.rstrip())
S.append('</settings>')
write(os.path.join(ROOT, "resources", "settings", "settings.xml"), "\n".join(S) + "\n")
# ---- field strings, both languages ----
for lang, folder, ni, li in (("en", "resources", 2, 4), ("chn", "resources-chn", 3, 5)):
X = ['<strings>']
X.append(' <!-- Field names shown in the settings picker. -->')
for f in FIELDS:
X.append(' <string id="Fld%s">%s</string>' % (f[1], f[ni]))
X.append('')
X.append(' <!-- On-face captions, in FieldTable.IDS order. -->')
X.append(' <string id="FieldLabels">%s</string>'
% ",".join(f[li] for f in FIELDS))
X.append('')
for rid, key, en, cn in RING:
X.append(' <string id="Metric%s">%s</string>' % (key, en if lang == "en" else cn))
X.append('</strings>')
write(os.path.join(ROOT, folder, "strings", "fields.xml"), "\n".join(X) + "\n")
print("%d fields, %d slots" % (len(FIELDS), len(SLOTS)))
if __name__ == "__main__":
main()

202
tools/gen_icons.py Normal file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""把设计稿的图标路径光栅化成可染色的位图资源。
design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 23 个设计
像素宽。用圆和线段去重画它们在 454px 上还行,到 280px 就彻底糊掉 —— 那个尺寸
下这些细节不足一个物理像素。
所以改成:把真实路径按每种屏宽各渲染一次,运行时用
drawBitmap2(:tintColor) 染上主题色。这样每台设备拿到的都是为它的像素网格
专门渲染的图。
python3 tools/gen_icons.py
产出 resources-icons-<屏宽>/drawables/{sunrise,sunset,weather}.png 以及配套的
drawables.xml另外产出 resources-launcher-<尺寸>/ 下各机型要求尺寸的启动图标。
⚠️ 两个要点:
1. drawables.xml 里必须写 packingFormat="png"。否则资源编译器会把图片压成
调色板格式,而 :tintColor 拒绝调色板化的源,运行时直接抛异常。
2. MIP 屏没有 alpha 混合,所以那几个尺寸的覆盖率蒙版会被阈值化成硬边 ——
软边在 64 色调色板下反而更脏。
"""
import os
import re
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = os.path.join(ROOT, "design", "watchface-01-ember.svg")
try:
from PIL import Image
except ImportError:
raise SystemExit("this generator needs Pillow: python3 -m pip install pillow")
# The three icons the comps actually contain, and the design-space centre each
# one is drawn around. A common 32x32 design box keeps blitting trivial.
BOX = 32.0
ICONS = {
"sunrise": ("sunrise-icon", 156.32, 89.5, False),
"sunset": ("sunrise-icon", 156.32, 89.5, True), # same art, chevron flipped
"weather": ("weather-icon", 343.55, 89.5, False),
}
# screen width -> whether the panel can blend alpha (MIP cannot)
WIDTHS = {240: False, 260: False, 280: False, 390: True, 416: True, 454: True}
SS = 8 # supersampling factor
def subpaths(svg, pid):
d = re.search(r'<path id="%s" d="([^"]+)"' % pid, svg).group(1)
out = []
for chunk in d.split("Z"):
pts = [(float(x), float(y))
for x, y in re.findall(r"(-?\d+\.\d+),(-?\d+\.\d+)", chunk)]
if len(pts) >= 3:
out.append(pts)
return out
def flip_chevron(paths):
"""Mirror the horizon bar so its notch points up -- our sunset variant."""
out = []
for p in paths:
ys = [q[1] for q in p]
if min(ys) > 96.0: # the bar is the lowest subpath
mid = (min(ys) + max(ys)) / 2.0
p = [(x, 2 * mid - y) for x, y in p]
out.append(p)
return out
def scanline_evenodd(paths, size, ox, oy, scale):
"""Even-odd fill at supersampled resolution, returned as a coverage mask."""
n = size * SS
cov = [bytearray(n) for _ in range(n)]
edges = []
for p in paths:
for i in range(len(p)):
x0, y0 = p[i]
x1, y1 = p[(i + 1) % len(p)]
X0 = (x0 - ox) * scale * SS
Y0 = (y0 - oy) * scale * SS
X1 = (x1 - ox) * scale * SS
Y1 = (y1 - oy) * scale * SS
if Y0 != Y1:
edges.append((Y0, Y1, X0, X1))
for row in range(n):
yc = row + 0.5
xs = []
for Y0, Y1, X0, X1 in edges:
if (Y0 <= yc < Y1) or (Y1 <= yc < Y0):
xs.append(X0 + (yc - Y0) * (X1 - X0) / (Y1 - Y0))
if not xs:
continue
xs.sort()
line = cov[row]
for i in range(0, len(xs) - 1, 2):
a = max(0, int(round(xs[i])))
b = min(n, int(round(xs[i + 1])))
for x in range(a, b):
line[x] = 255
return cov
def render(paths, size, cx, cy, blend):
scale = size / BOX
ox, oy = cx - BOX / 2, cy - BOX / 2
cov = scanline_evenodd(paths, size, ox, oy, scale)
n = size * SS
big = Image.frombytes("L", (n, n), b"".join(bytes(r) for r in cov))
small = big.resize((size, size), Image.LANCZOS)
if not blend:
small = small.point(lambda v: 255 if v >= 110 else 0)
white = Image.new("RGB", (size, size), (255, 255, 255))
img = white.convert("RGBA")
img.putalpha(small)
return img
# Launcher icon sizes the target devices ask for.
LAUNCHER = [40, 56, 60, 65, 70]
def launcher(size):
"""A miniature of the face: the gradient ring plus the date band."""
from PIL import ImageDraw
ss = 8
n = size * ss
img = Image.new("RGBA", (n, n), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse([0, 0, n - 1, n - 1], fill=(0, 0, 0, 255))
svg = open(SRC).read()
top = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
re.search(r'<g id="ring-top-right">(.*?)</g>', svg, re.S).group(1))
bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
re.search(r'<g id="ring-bottom-left">(.*?)</g>', svg, re.S).group(1))
ro, ri = n * 0.495, n * 0.375
box_o = [n / 2 - ro, n / 2 - ro, n / 2 + ro, n / 2 + ro]
# sweep the whole rim, mirroring the comp's two ramps into four quadrants
for q, ramp in ((0, top), (1, top), (2, bot), (3, bot)):
for i, c in enumerate(ramp):
col = tuple(int(c[1 + 2 * j:3 + 2 * j], 16) for j in range(3)) + (255,)
span = 90.0 / len(ramp)
if q == 0:
a0 = -90 + i * span
elif q == 1:
a0 = -90 - (i + 1) * span
elif q == 2:
a0 = 90 - (i + 1) * span
else:
a0 = 90 + i * span
d.pieslice(box_o, a0, a0 + span, fill=col)
d.ellipse([n / 2 - ri, n / 2 - ri, n / 2 + ri, n / 2 + ri], fill=(0, 0, 0, 255))
band = tuple(int("FEAA00"[2 * j:2 * j + 2], 16) for j in range(3)) + (255,)
d.rectangle([0, n * 0.44, n, n * 0.60], fill=band)
mask = Image.new("L", (n, n), 0)
ImageDraw.Draw(mask).ellipse([0, 0, n - 1, n - 1], fill=255)
img.putalpha(mask)
return img.resize((size, size), Image.LANCZOS)
def main():
svg = open(SRC).read()
for size in LAUNCHER:
outdir = os.path.join(ROOT, "resources-launcher-%d" % size, "drawables")
os.makedirs(outdir, exist_ok=True)
launcher(size).save(os.path.join(outdir, "launcher_icon.png"))
with open(os.path.join(outdir, "drawables.xml"), "w") as f:
f.write('<drawables>\n'
' <bitmap id="LauncherIcon" filename="launcher_icon.png"/>\n'
'</drawables>\n')
print("wrote %s (%dx%d)" % (outdir, size, size))
cache = {}
for width, blend in sorted(WIDTHS.items()):
size = int(round(BOX * width / 500.0))
outdir = os.path.join(ROOT, "resources-icons-%d" % width, "drawables")
os.makedirs(outdir, exist_ok=True)
names = []
for name, (pid, cx, cy, flip) in ICONS.items():
key = (pid, flip)
if key not in cache:
p = subpaths(svg, pid)
cache[key] = flip_chevron(p) if flip else p
img = render(cache[key], size, cx, cy, blend)
img.save(os.path.join(outdir, "%s.png" % name))
names.append(name)
with open(os.path.join(outdir, "drawables.xml"), "w") as f:
f.write("<drawables>\n")
for n in names:
# packingFormat="png" keeps the alpha channel instead of
# collapsing the image to a palette; drawBitmap2(:tintColor)
# refuses a palettised source.
f.write(' <bitmap id="Icon%s" filename="%s.png" '
'packingFormat="png"/>\n' % (n.capitalize(), n))
f.write("</drawables>\n")
print("wrote %s (%dx%d, %s edges)"
% (outdir, size, size, "soft" if blend else "hard"))
if __name__ == "__main__":
main()

330
tools/gen_preview_svg.py Normal file
View File

@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""重放表盘自己的绘制逻辑,产出 preview-themes.svg 和 preview.html。
颜色直接解析自 source/Themes.mc几何常量与 source/Fenix8V3View.mc /
source/Layout.mc 保持同一套字面值,所以预览不会和固件漂移。
⚠️ 文字用的是网页字体,真机是 Garmin 内置的 Roboto / Bionic字形会有差异。
**几何和配色可信,字形不可信** —— 判断字形要看模拟器截图。
python3 tools/gen_preview_svg.py
"""
import math
import os
import re
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "preview-themes.svg")
OUT_HTML = os.path.join(ROOT, "preview.html")
# ---------------------------------------------------------------- geometry --
R_IN, R_OUT = 231.09, 249.10
TOP_N, TOP_PH, TOP_PITCH, TOP_HW = 21, 6.500, 4.000, 1.554
BOT_N, BOT_PH, BOT_PITCH, BOT_HW = 21, 6.005, 3.000, 1.043
DOT_RAD, DOT_R = 240.10, 9.00
DOT_ANG = [0.0, 93.15, 180.0, 266.85]
BATT_X, BATT_Y, BATT_W, BATT_H = 192.70, 44.02, 26.20, 17.81
BATT_WALL, BATT_TW, BATT_TH = 2.20, 2.53, 9.20
BATT_VX, BATT_CY = 232.00, 53.20
LEFT_X, RIGHT_X, ICON_CY, VALUE_CY = 157.00, 343.70, 90.00, 131.06
TIME_CY, HOURS_R, MINUTES_L = 221.10, 211.40, 276.02
COLON_X, COLON_W, COLON_H, COLON_Y1, COLON_Y2 = 237.88, 24.24, 21.30, 183.68, 234.57
BAND_Y, BAND_H, BAND_CY = 286.30, 44.19, 308.10
BT_CX, BT_CY, BT_HW, BT_HH = 36.88, 306.99, 8.89, 12.92
DOW_CX, MD_CX, AMPM_R = 178.05, 298.28, 465.89
BOT_VAL_CY, BOT_LAB_CY, DIST_R, STEP_L = 366.76, 410.88, 234.30, 267.40
CAP_BAND, CAP_COMP, CAP_TIME = 25.34, 31.50, 98.70
DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
# ------------------------------------------------------------ Themes.mc in --
def load_themes():
src = open(os.path.join(ROOT, "source", "Themes.mc")).read()
def const(name):
m = re.search(r"const %s = \[(.*?)\n \];" % name, src, re.S)
if m: # nested: one row per theme
rows, cur = [], []
for line in m.group(1).split("\n"):
if line.strip().startswith("//"):
if cur:
rows.append(cur)
cur = []
continue
cur += [int(v, 16) for v in re.findall(r"0x([0-9A-Fa-f]{6})", line)]
if cur:
rows.append(cur)
return rows
m = re.search(r"const %s = \[(.*?)\];" % name, src)
return [int(v, 16) for v in re.findall(r"0x([0-9A-Fa-f]{6})", m.group(1))]
names = re.findall(r'^\s+"(\w+)",', src, re.M)
return names, {k: const(k) for k in
("RINGTOP", "RINGBOTTOM", "HOURS", "MINUTES", "TICKOFF",
"ANCHORDOT", "BANDFILL", "BANDTEXT", "COLON",
"ACCENT", "TEXTPRIMARY")}
def hx(c):
return "#%06X" % (c & 0xFFFFFF)
def polar(ang, r):
t = math.radians(ang)
return 250.0 + math.sin(t) * r, 250.0 - math.cos(t) * r
# ------------------------------------------------------------------ pieces --
def ring(th, T, p):
out = []
top, bot, off = T["RINGTOP"][th], T["RINGBOTTOM"][th], T["TICKOFF"][th]
def arc(ramp, lit, ph, pitch, hw, n, base, mirror, from_end):
for i in range(n):
a = ph + pitch * i
on = (i >= n - lit) if from_end else (i < lit)
ang = base + (-a if mirror else a)
c = [polar(ang - hw, R_OUT), polar(ang + hw, R_OUT),
polar(ang + hw, R_IN), polar(ang - hw, R_IN)]
out.append('<polygon points="%s" fill="%s"/>' % (
" ".join("%.2f,%.2f" % q for q in c), hx(ramp[i] if on else off)))
arc(top, round(p[0] * TOP_N), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, 0.0, False, True)
arc(top, round(p[1] * TOP_N), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, 0.0, True, True)
arc(bot, round(p[3] * BOT_N), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, 180.0, False, False)
arc(bot, round(p[2] * BOT_N), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, 180.0, True, False)
for a in DOT_ANG:
x, y = polar(a, DOT_RAD)
out.append('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
% (x, y, DOT_R, hx(T["ANCHORDOT"][th])))
return "".join(out)
def txt(x, cy, cap, s, anchor, col, weight=700):
return ('<text x="%.2f" y="%.2f" font-size="%.2f" font-weight="%d" fill="%s" '
'text-anchor="%s" font-family="Roboto Condensed,Arial Narrow,sans-serif" '
'dominant-baseline="central">%s</text>'
% (x, cy, cap / 0.72, weight, col, anchor, s))
def grad_txt(gid, x, cy, cap, s, anchor, ramp):
y0, y1 = cy - cap / 2, cy + cap / 2
n = len(ramp)
stops = "".join('<stop offset="%.3f" stop-color="%s"/>' % ((i + 0.5) / n, hx(c))
for i, c in enumerate(ramp))
d = ('<linearGradient id="%s" x1="0" y1="%.2f" x2="0" y2="%.2f" '
'gradientUnits="userSpaceOnUse">%s</linearGradient>' % (gid, y0, y1, stops))
return d, txt(x, cy, cap, s, anchor, "url(#%s)" % gid)
def battery(x, cy, k, col, level):
w, h, wall = BATT_W * k, BATT_H * k, BATT_WALL * k
y = cy - h / 2
o = ['<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" fill="%s"/>'
% (x, y, w, h, 3.4 * k, col),
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
% (x + w, cy - BATT_TH * k / 2, BATT_TW * k, BATT_TH * k, col),
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#000"/>'
% (x + wall, y + wall, w - 2 * wall, h - 2 * wall)]
if level > 0:
o.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
% (x + wall, y + wall, (w - 2 * wall) * level, h - 2 * wall, col))
return "".join(o)
def icon_sun(cx, cy, k, col, direction=1):
ox, oy = cx - 156.32 * k, cy - 90.34 * k
rx, ry = 7.45 * k, 8.93 * k
bx, by = ox + 156.39 * k, oy + 94.02 * k
pts = [(bx + math.cos(math.radians(180 - 15 * i)) * rx,
by - math.sin(math.radians(180 - 15 * i)) * ry) for i in range(13)]
o = ['<polygon points="%s" fill="%s"/>'
% (" ".join("%.2f,%.2f" % q for q in pts), col)]
def ln(x1, y1, x2, y2, w):
return ('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" '
'stroke-width="%.2f" stroke-linecap="round"/>'
% (x1, y1, x2, y2, col, w))
w1 = 2.96 * k
o += [ln(ox + 156.39 * k, oy + 79.4 * k, ox + 156.39 * k, oy + 81.1 * k, w1),
ln(ox + 148.2 * k, oy + 83.3 * k, ox + 146.5 * k, oy + 84.5 * k, w1),
ln(ox + 164.6 * k, oy + 84.5 * k, ox + 166.3 * k, oy + 83.3 * k, w1),
ln(ox + 142.6 * k, oy + 92.5 * k, ox + 144.7 * k, oy + 92.5 * k, w1),
ln(ox + 168.0 * k, oy + 92.5 * k, ox + 170.1 * k, oy + 92.5 * k, w1)]
w2, hy = 2.6 * k, oy + 98.45 * k
o += [ln(ox + 142.3 * k, hy, ox + 170.3 * k, hy, w2),
ln(ox + 152.6 * k, hy, ox + 156.3 * k, hy + direction * 3.05 * k, w2),
ln(ox + 156.3 * k, hy + direction * 3.05 * k, ox + 160.0 * k, hy, w2)]
return "".join(o)
def icon_cloud(cx, cy, k, col):
ox, oy = cx - 343.55 * k, cy - 89.25 * k
def ln(x1, y1, x2, y2, w):
return ('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" '
'stroke-width="%.2f" stroke-linecap="round"/>'
% (x1, y1, x2, y2, col, w))
def body(grow, fill):
return ('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
'<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" fill="%s"/>'
% (ox + 336.6 * k, oy + 94.4 * k, 8.7 * k + grow, fill,
ox + 346.4 * k, oy + 96.9 * k, 6.3 * k + grow, fill,
ox + 328.0 * k - grow, oy + 95.5 * k - grow,
24.6 * k + 2 * grow, 7.75 * k + grow, 3.4 * k, fill))
o = ['<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
% (ox + 347.0 * k, oy + 88.9 * k, 7.8 * k, col)]
o += [ln(ox + 346.2 * k, oy + 76.0 * k, ox + 346.2 * k, oy + 78.7 * k, 2.3 * k),
ln(ox + 354.9 * k, oy + 87.7 * k, ox + 357.5 * k, oy + 87.7 * k, 2.3 * k),
ln(ox + 339.5 * k, oy + 79.5 * k, ox + 338.1 * k, oy + 80.9 * k, 2.7 * k),
ln(ox + 353.5 * k, oy + 80.9 * k, ox + 354.6 * k, oy + 79.8 * k, 2.7 * k)]
o.append(body(1.7 * k, "#000000"))
o.append(body(0.0, col))
return "".join(o)
def bluetooth(col):
l, r = BT_CX - BT_HW, BT_CX + BT_HW
t, b = BT_CY - BT_HH, BT_CY + BT_HH
qt, qb = BT_CY - BT_HH / 2, BT_CY + BT_HH / 2
pts = [(l, qt), (r, qb), (BT_CX, b), (BT_CX, t), (r, qt), (l, qb)]
return ('<polyline points="%s" fill="none" stroke="%s" stroke-width="3.2" '
'stroke-linecap="round" stroke-linejoin="round"/>'
% (" ".join("%.2f,%.2f" % q for q in pts), col))
def face(th, names, T, data):
accent, primary = hx(T["ACCENT"][th]), hx(T["TEXTPRIMARY"][th])
defs, s = [], ['<rect width="500" height="500" fill="#000"/>',
ring(th, T, data["ring"])]
s.append(battery(BATT_X, BATT_CY, 1.0, accent, data["batt"]))
s.append(txt(BATT_VX, BATT_CY, CAP_BAND, "%d%%" % round(data["batt"] * 100),
"start", primary))
s.append(icon_sun(LEFT_X, ICON_CY, 1.0, accent))
s.append(txt(LEFT_X, VALUE_CY, CAP_COMP, data["sunrise"], "middle", primary))
s.append(icon_cloud(RIGHT_X, ICON_CY, 1.0, accent))
s.append(txt(RIGHT_X, VALUE_CY, CAP_COMP, data["hilo"], "middle", primary))
d, t = grad_txt("gH%d" % th, HOURS_R, TIME_CY, CAP_TIME, data["hh"], "end",
T["HOURS"][th])
defs.append(d)
s.append(t)
d, t = grad_txt("gM%d" % th, MINUTES_L, TIME_CY, CAP_TIME, data["mm"], "start",
T["MINUTES"][th])
defs.append(d)
s.append(t)
for y in (COLON_Y1, COLON_Y2):
s.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
% (COLON_X, y, COLON_W, COLON_H, hx(T["COLON"][th])))
band, btxt = hx(T["BANDFILL"][th]), hx(T["BANDTEXT"][th])
s.append('<rect x="0" y="%.2f" width="500" height="%.2f" fill="%s"/>'
% (BAND_Y, BAND_H, band))
s.append(bluetooth(btxt))
s.append(txt(DOW_CX, BAND_CY, CAP_BAND, data["dow"], "middle", btxt))
s.append(txt(MD_CX, BAND_CY, CAP_BAND, data["md"], "middle", btxt))
s.append(txt(AMPM_R, BAND_CY, CAP_BAND, data["ampm"], "end", btxt))
s.append(txt(DIST_R, BOT_VAL_CY, CAP_COMP, data["dist"], "end", primary))
s.append(txt(DIST_R, BOT_LAB_CY, CAP_COMP, "DST", "end", accent))
s.append(txt(STEP_L, BOT_VAL_CY, CAP_COMP, data["steps"], "start", primary))
s.append(txt(STEP_L, BOT_LAB_CY, CAP_COMP, "STEP", "start", accent))
return "".join(defs), "".join(s)
HTML = """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenix 8 V3 - 表盘预览</title>
<style>
:root{--bg:#0a0a0a;--ink:#e9e6e0;--dim:#8d8a84;--line:#26261f}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.6 -apple-system,"PingFang SC",Helvetica,Arial,sans-serif}
.wrap{max-width:760px;margin:0 auto;padding:32px 20px 64px}
h1{font-size:20px;margin:0 0 4px}
p.lede{color:var(--dim);margin:0 0 24px}
.stage{background:#000;border-radius:50%%;width:420px;height:420px;
margin:0 auto 24px;overflow:hidden}
svg.f{display:none;width:420px;height:420px}
svg.f.on{display:block}
.themes{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;max-width:420px;margin:0 auto}
button{cursor:pointer;border:1px solid var(--line);background:#1c1c19;color:var(--ink);
border-radius:8px;padding:9px 4px;font-size:12px}
button.on{background:#FEAA00;color:#000;border-color:#FEAA00;font-weight:600}
footer{color:var(--dim);font-size:12px;margin-top:28px;text-align:center}
</style></head><body><div class="wrap">
<h1>Fenix 8 V3 表盘预览</h1>
<p class="lede">由 <code>tools/gen_preview_svg.py</code> 从 <code>source/Themes.mc</code>
生成,几何常量与 <code>source/Fenix8V3View.mc</code> 一致。文字用网页字体渲染,
真机为 Garmin 内置 Roboto / Bionic 字体,字形会有差异。</p>
<div class="stage">%(faces)s</div>
<div class="themes">%(buttons)s</div>
<footer>七套主题 · 进度环状态取自设计稿:右上 21/21、左上 10/21、右下 9/21、左下 14/21</footer>
</div>
<script>
var faces=document.querySelectorAll("svg.f"), btns=document.querySelectorAll("button");
faces[0].classList.add("on");
btns.forEach(function(b){b.onclick=function(){
var i=+b.dataset.i;
faces.forEach(function(f){f.classList.toggle("on",+f.dataset.i===i)});
btns.forEach(function(o){o.classList.toggle("on",o===b)});
}});
</script></body></html>
"""
def main():
names, T = load_themes()
# The state the design comps are drawn in, so the preview is comparable.
data = {"ring": [1.0, 10 / 21.0, 9 / 21.0, 14 / 21.0],
"batt": 0.40, "sunrise": "6:34", "hilo": "63°/52°",
"hh": "11", "mm": "22", "dow": "MON", "md": "MAY 26", "ampm": "AM",
"dist": "0.7", "steps": "1337"}
cell, gap = 230, 24
n = len(names)
W, H = n * cell + (n + 1) * gap, cell + 70
parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d">' % (W, H, W, H),
'<rect width="%d" height="%d" fill="#0a0a0a"/>' % (W, H)]
for i, name in enumerate(names):
ox, oy, sc = gap + i * (cell + gap), 24, cell / 500.0
defs, body = face(i, names, T, data)
parts.append('<defs><clipPath id="c%d"><circle cx="%.1f" cy="%.1f" r="%.1f"/>'
'</clipPath>%s</defs>'
% (i, ox + cell / 2, oy + cell / 2, cell / 2 - 2, defs))
parts.append('<g clip-path="url(#c%d)"><g transform="translate(%d,%d) '
'scale(%.4f)">%s</g></g>' % (i, ox, oy, sc, body))
parts.append('<text x="%.1f" y="%.1f" fill="#cfccc4" font-size="15" '
'font-family="sans-serif" text-anchor="middle">%d · %s</text>'
% (ox + cell / 2, oy + cell + 22, i + 1, name))
parts.append("</svg>")
svg = "\n".join(parts)
open(OUT, "w").write(svg)
print("wrote %s (%d x %d, %d bytes)" % (OUT, W, H, len(svg)))
# Same faces again, one per theme, as a switchable page.
big = []
for i, name in enumerate(names):
defs, body = face(i, names, T, data)
big.append('<svg class="f" data-i="%d" viewBox="0 0 500 500" '
'xmlns="http://www.w3.org/2000/svg"><defs>%s</defs>%s</svg>'
% (i, defs, body))
btns = "".join('<button data-i="%d"%s>%d · %s</button>'
% (i, ' class="on"' if i == 0 else "", i + 1, n)
for i, n in enumerate(names))
open(OUT_HTML, "w").write(HTML % {"faces": "".join(big), "buttons": btns,
"n": len(names)})
print("wrote %s (%d themes)" % (OUT_HTML, len(names)))
if __name__ == "__main__":
main()

207
tools/gen_themes.py Normal file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""从 design/ 的设计稿生成 source/Themes.mc。
事实来源是七个 watchface-NN-*.svg原作者用 design/rebuild.py 从位图逆向出来的)。
写进 Themes.mc 的每一个颜色都是从这些文件里**量出来的**,没有一个是手挑的。
python3 tools/gen_themes.py
每套主题提取出:
ringTop[21] 顶弧每根刻度点亮时的颜色,下标 0 = 6.5°20 = 86.5°
ringBottom[21] 底弧每根刻度点亮时的颜色,下标 0 = 6.0°20 = 66.0°
tickOff 未点亮刻度的颜色
anchorDot 四段弧之间的锚点圆点
bandFill/Text 日期带的底色 / 文字色
colon 两块冒号
hours[11] 小时数字的竖向渐变,从上到下
minutes[11] 分钟数字的竖向渐变
accent 图标与 DST/STEP 标签
textPrimary 各数据位与底部行的数值
⚠️ 已知近似:设计稿里底弧最多只点亮 14/21 根,所以 ringBottom 的第 1521 项
无法实测,由最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会
显示这几根。
"""
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
DESIGN = os.path.join(ROOT, "design")
OUT = os.path.join(ROOT, "source", "Themes.mc")
ORDER = ["01", "02", "03", "04", "05", "06", "07"]
N_TICK = 21
BOT_MEASURED = 14 # lit ticks in the bottom-left arc of every comp
FIT_WINDOW = 8 # ticks used for the extrapolation fit
def rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def hexs(c):
return "0x%02X%02X%02X" % tuple(max(0, min(255, int(round(v)))) for v in c)
def median(colours):
cols = [rgb(c) for c in colours]
return tuple(sorted(c[i] for c in cols)[len(cols) // 2] for i in range(3))
def group_colours(svg, gid):
m = re.search(r'<g id="%s">(.*?)</g>' % gid, svg, re.S)
if m is None:
raise SystemExit("missing group %s" % gid)
return re.findall(r'fill="(#[0-9A-Fa-f]{6})"', m.group(1))
def path_fill(svg, pid):
m = re.search(r'<path id="%s"[^>]*fill="(#[0-9A-Fa-f]{6})"' % pid, svg)
return m.group(1) if m else None
def gradient_ramp(svg, gid):
"""The 11 usable stops of a digit gradient.
rebuild.py samples the glyph row by row, so the first and last stop are the
antialiased edge rows and read far too dark. Drop them.
"""
m = re.search(r'<linearGradient id="%s".*?>(.*?)</linearGradient>' % gid, svg, re.S)
stops = re.findall(r'stop-color="(#[0-9A-Fa-f]{6})"', m.group(1))
return stops[1:-1]
def extrapolate(ramp):
"""Extend a measured colour ramp to N_TICK entries by linear fit."""
known = [rgb(c) for c in ramp]
n = len(known)
if n >= N_TICK:
return known[:N_TICK]
xs = list(range(n - FIT_WINDOW, n))
out = list(known)
for i in range(n, N_TICK):
nxt = []
for ch in range(3):
ys = [known[x][ch] for x in xs]
mx = sum(xs) / float(len(xs))
my = sum(ys) / float(len(ys))
den = sum((x - mx) ** 2 for x in xs)
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den if den else 0.0
nxt.append(max(0, min(255, my + slope * (i - mx))))
out.append(tuple(nxt))
return out
def read_theme(tid):
hits = [f for f in os.listdir(DESIGN)
if f.startswith("watchface-%s-" % tid) and f.endswith(".svg")]
if not hits:
raise SystemExit("no SVG for theme %s in %s" % (tid, DESIGN))
path = os.path.join(DESIGN, hits[0])
svg = open(path).read()
name = re.search(r"<title>Watch face \d+ (\w+)</title>", svg).group(1)
top_r = group_colours(svg, "ring-top-right") # fully lit in every comp
top_l = group_colours(svg, "ring-top-left") # lit from the far end
bot_l = group_colours(svg, "ring-bottom-left") # lit from index 0
# An unlit tick is whatever colour the dim end of the top-left arc has.
tick_off = median(top_l[:8])
ring_top = [rgb(c) for c in top_r]
ring_bottom = extrapolate(bot_l[:BOT_MEASURED])
band = re.search(r'<g id="date-band">\s*<rect[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
dot = re.search(r'<g id="ring-anchors">\s*<circle[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
colon = re.search(r'<g id="time-colon"><rect[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
accent = median([path_fill(svg, p) for p in
("battery-icon", "sunrise-icon", "weather-icon",
"dist-label", "steps-label")])
primary = median([path_fill(svg, p) for p in
("battery-value", "sunrise-value", "weather-value",
"dist-value", "steps-value")])
return {
"id": tid,
"name": name,
"ringTop": ring_top,
"ringBottom": ring_bottom,
"tickOff": tick_off,
"anchorDot": rgb(dot),
"bandFill": rgb(band),
"bandText": rgb(path_fill(svg, "bt-icon")),
"colon": rgb(colon),
"hours": [rgb(c) for c in gradient_ramp(svg, "gHours")],
"minutes": [rgb(c) for c in gradient_ramp(svg, "gMinutes")],
"accent": accent,
"textPrimary": primary,
}
def fmt_list(colours, indent):
pad = " " * indent
out, line = [], pad + "["
for i, c in enumerate(colours):
piece = hexs(c) + ("," if i < len(colours) - 1 else "]")
if len(line) + len(piece) > 108:
out.append(line)
line = pad + " "
line += piece
out.append(line)
return "\n".join(out)
def main():
themes = [read_theme(t) for t in ORDER]
L = []
L.append("// ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。")
L.append("// 要改配色就改设计稿里的 SVG然后重新跑一遍生成器。")
L.append("//")
L.append("// 七套主题的全部颜色数据。各数组的下标就是主题序号0=Ember … 6=Kelp")
L.append("//")
L.append("// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。")
L.append("// 下标 0 是最靠近 12 点 / 6 点的那根。")
L.append("// 这是从设计稿里**逐根取样**的实测值,不是插值近似。")
L.append("// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。")
L.append("// TICKOFF 未点亮刻度的颜色")
L.append("// ANCHORDOT 四个锚点圆点")
L.append("// BANDFILL/BANDTEXT 日期带的底色 / 文字色")
L.append("// ACCENT 图标与底部标签")
L.append("// TEXTPRIMARY 各项数值")
L.append("import Toybox.Lang;")
L.append("")
L.append("module Themes {")
L.append("")
L.append(" const NAMES = [")
for t in themes:
L.append(' "%s",' % t["name"])
L.append(" ];")
L.append("")
for key in ("ringTop", "ringBottom", "hours", "minutes"):
L.append(" const %s = [" % key.upper())
for t in themes:
L.append(" // %s %s" % (t["id"], t["name"]))
L.append(fmt_list(t[key], 8) + ",")
L.append(" ];")
L.append("")
for key in ("tickOff", "anchorDot", "bandFill", "bandText",
"colon", "accent", "textPrimary"):
vals = ", ".join(hexs(t[key]) for t in themes)
L.append(" const %s = [%s];" % (key.upper(), vals))
L.append("}")
text = "\n".join(L) + "\n"
open(OUT, "w").write(text)
print("wrote %s (%d themes, %d bytes)" % (OUT, len(themes), len(text)))
for t in themes:
print(" %s %-8s accent=%s text=%s tickOff=%s" %
(t["id"], t["name"], hexs(t["accent"]),
hexs(t["textPrimary"]), hexs(t["tickOff"])))
if __name__ == "__main__":
sys.exit(main())