Initial commit: Fenix 8 V3 data watch face

- Restore watchface-kit design (500x500, scaled per device)
- 7 themes auto-generated from tokens via gen_themes.py -> Themes.mc
- Four-segment gradient progress ring (22+21 ticks, 4 anchor dots)
- Center time, date band w/ Bluetooth icon, top battery + dual complications, bottom distance/steps
- 17 devices compiled clean (SDK 9.1.0)
- Includes browser/JS preview (preview.html) + 7-theme SVG sheet
This commit is contained in:
2026-09-09 14:40:33 +08:00
commit 553c55026a
15 changed files with 1404 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Connect IQ build artifacts (regenerable via `monkeyc`)
/bin/
# Developer signing key — PRIVATE, never commit
developer_key.der
# OS / editor noise
.DS_Store
*.swp

168
README.md Normal file
View File

@@ -0,0 +1,168 @@
# Fenix 8 V3 — Garmin 数据表盘
一款基于 Connect IQ 设计稿(`watchface-kit`1:1 还原的多指标数据表盘。设计画布为 **500×500** 正方形,渲染时按屏幕宽度等比缩放,支持圆形 AMOLEDFenix 8 / Enduro 3 / Epix 2与圆屏 MIPFenix 7 / FR965 等)设备。
- **SDK**Connect IQ SDK 9.1.0`monkeyc` 9.1.0API level `minSdkVersion="4.2.0"`
- **语言**Monkey C
- **类型**`watchface`
- **应用 ID**`f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b`
---
## 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
颜色数据由 `watchface-tokens.json``gen_themes.py` 生成到 `source/Themes.mc`**请勿手改**,改 tokens 后重新生成)。每套主题包含:顶/底弧渐变停止点、未点亮刻度色 `tickOff`、锚点色 `anchorDot`、日期带填充 `bandFill` / 文字 `bandText`、强调色 `accent`、主文字 `textPrimary`、冒号色 `colon`、小时 `hours`、分钟 `minutes`
| # | 主题 | 主色调 | 分钟数字 | 日期带 |
|---|------|--------|----------|--------|
| 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
# 单设备Enduro 3 主产物)
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3
# 导出调试资源(含 -settings.json便于模拟器载入设置
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 \
fenix7s fr965 fr265 fr255 venu3 vivoactive5; do
monkeyc -o "bin/Fenix8V3_${d}.prg" -f monkey.jungle -y developer_key.der -d "$d" \
&& echo "[$d] OK" || echo "[$d] FAIL"
done
```
已验证编译通过的设备:`enduro3, fenix843mm, fenix847mm, fenix8pro47mm, fenix8solar47mm, fenix8solar51mm, epix2, epix2pro47mm, epix2pro51mm, fenix7, fenix7x, fenix7s, fr965, fr265, fr255, venu3, vivoactive5`。产物位于 `bin/`,每个设备对应一个 `.prg``.prg.debug.xml`(调试版含资源镜像)。
---
## 6. 模拟器与侧载
1. **Connect IQ Simulator**Garmin 自带):`File → Open App Project` 选本目录,选设备后 `Run`。在 `bin/*.prg.debug.xml` 旁会生成 `-settings.json`,可在模拟器菜单 `View → Settings` 载入并实时切换主题/指标验证。
2. **真机侧载Garmin Connect / Garmin Express**
- 用 Garmin Express 或 Connect 手机 App 的“侧载sideload”功能导入对应设备的 `bin/Fenix8V3_<device>.prg`
- 或把 `.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
```
---
## 8. 二次开发与扩展
- **改主题配色**:编辑 `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. 设计稿来源
还原基准为 `watchface-kit.zip` 内的 8 张 SVG 变体 + `watchface-tokens.json`(配色)+ `watchface-spec.html`(像素测量规范)+ `rebuild.py`(几何常量)。本仓库为按该规范落地的 Monkey C 实现,设计版权归原作者所有。

172
gen_preview_svg.py Normal file
View File

@@ -0,0 +1,172 @@
#!/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)

38
manifest.xml Normal file
View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<iq:manifest xmlns:iq="http://www.garmin.com/xml/connectiq" version="3">
<iq:application
entry="Fenix8V3App"
id="f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b"
launcherIcon="@Drawables.LauncherIcon"
minSdkVersion="4.2.0"
name="@Strings.AppName"
type="watchface"
version="1.0.0">
<iq:products>
<iq:product id="enduro3"/>
<iq:product id="fenix843mm"/>
<iq:product id="fenix847mm"/>
<iq:product id="fenix8pro47mm"/>
<iq:product id="fenix8solar47mm"/>
<iq:product id="fenix8solar51mm"/>
<iq:product id="epix2"/>
<iq:product id="epix2pro47mm"/>
<iq:product id="epix2pro51mm"/>
<iq:product id="fenix7"/>
<iq:product id="fenix7x"/>
<iq:product id="fenix7s"/>
<iq:product id="fr965"/>
<iq:product id="fr265"/>
<iq:product id="fr255"/>
<iq:product id="venu3"/>
<iq:product id="vivoactive5"/>
</iq:products>
<iq:languages>
<iq:language>chn</iq:language>
<iq:language>eng</iq:language>
</iq:languages>
</iq:application>
</iq:manifest>

9
monkey.jungle Normal file
View File

@@ -0,0 +1,9 @@
project.manifest = manifest.xml
base.sourcePath = source
base.resourcePath = resources
base.excludeAnnotations = release
# Uncomment and adjust once you have SDK devices installed, e.g.:
# fenix8.resourcePath = resources;resources-round-amoled
# fenix7.resourcePath = resources;resources-round-mip

24
preview-themes.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 90 KiB

322
preview.html Normal file
View File

@@ -0,0 +1,322 @@
<!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; --panel:#141413; --ink:#e9e6e0; --dim:#8d8a84; --line:#26261f; --amber:#FEAA00; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",Helvetica,Arial,sans-serif}
.wrap{max-width:1080px;margin:0 auto;padding:32px 20px 64px}
h1{font-size:22px;margin:0 0 4px}
.lede{color:var(--dim);margin:0 0 24px}
.stage{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}
.facebox{background:#000;border-radius:14px;padding:14px;box-shadow:0 8px 30px #0008}
canvas{display:block;background:#000;border-radius:50%}
canvas.square{border-radius:12px}
.controls{display:flex;flex-direction:column;gap:18px;min-width:240px;flex:1}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
.card h3{margin:0 0 10px;font-size:13px;color:var(--dim);font-weight:500}
.themes{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.themes button{cursor:pointer;border:1px solid var(--line);background:#1c1c19;color:var(--ink);border-radius:8px;padding:8px 4px;font-size:12px;transition:.15s}
.themes button:hover{border-color:var(--amber)}
.themes button.active{background:var(--amber);color:#000;border-color:var(--amber);font-weight:600}
.row{display:flex;align-items:center;gap:10px;margin:8px 0}
.row label{flex:1;color:var(--dim)}
input[type=range]{width:130px}
.chk{display:flex;align-items:center;gap:8px;color:var(--dim)}
.val{font:12px ui-monospace,Menlo,monospace;color:var(--amber);min-width:42px;text-align:right}
.gallery{margin-top:34px}
.gallery h2{font-size:16px;border-bottom:1px solid var(--line);padding-bottom:8px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;margin-top:18px}
figure{margin:0;text-align:center}
figure canvas{margin:0 auto;border-radius:50%;background:#000}
figcaption{color:var(--dim);font-size:12px;margin-top:8px}
.note{color:var(--dim);font-size:12px;margin-top:6px}
</style>
</head>
<body>
<div class="wrap">
<h1>Fenix 8 V3 · 运行效果预览</h1>
<p class="lede">网页版 1:1 还原 <code>source/Fenix8V3View.mc</code><code>onUpdate</code> 绘制逻辑:同坐标、同主题色、同指标填充。下方可切换 7 套主题、调指标进度、套圆形遮罩。</p>
<div class="stage">
<div class="facebox"><canvas id="face" width="500" height="500"></canvas></div>
<div class="controls">
<div class="card">
<h3>主题 Theme</h3>
<div class="themes" id="themes"></div>
</div>
<div class="card">
<h3>指标进度(驱动进度环)</h3>
<div class="row"><label>步数 Steps</label><input type="range" id="m0" min="0" max="100" value="84"><span class="val" id="v0"></span></div>
<div class="row"><label>卡路里 Calories</label><input type="range" id="m1" min="0" max="100" value="64"><span class="val" id="v1"></span></div>
<div class="row"><label>距离 Distance</label><input type="range" id="m2" min="0" max="100" value="65"><span class="val" id="v2"></span></div>
<div class="row"><label>楼层 Floors</label><input type="range" id="m3" min="0" max="100" value="80"><span class="val" id="v3"></span></div>
<div class="row"><label>动动条 Move Bar</label><input type="range" id="m4" min="0" max="100" value="40"><span class="val" id="v4"></span></div>
<div class="note">默认值对应设计稿:顶右=步数 · 顶左=卡路里 · 底右=动动条 · 底左=楼层</div>
</div>
<div class="card">
<h3>显示 / 外观</h3>
<div class="row"><label>电量 Battery</label><input type="range" id="bat" min="0" max="100" value="78"><span class="val" id="vbat"></span></div>
<div class="row"><label>天气温度 °C</label><input type="range" id="temp" min="-10" max="45" value="23"><span class="val" id="vtemp"></span></div>
<div class="chk"><input type="checkbox" id="round" checked><label for="round">圆形设备遮罩(多数 Fenix/Epix 为圆屏)</label></div>
<div class="chk"><input type="checkbox" id="live" checked><label for="live">时间实时走动</label></div>
</div>
</div>
</div>
<div class="gallery">
<h2>七套主题一览(实时当前时间)</h2>
<div class="grid" id="gallery"></div>
</div>
</div>
<script>
// ---- 主题数据(与 source/Themes.mc 一致,由 watchface-tokens.json 生成)----
const 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}
};
const ORDER = ["01","02","03","04","05","06","07"];
// ---- 颜色工具 ----
const toHex = c => '#' + (c & 0xffffff).toString(16).padStart(6,'0');
function lerpColor(c1,c2,f){
const r1=(c1>>16)&255,g1=(c1>>8)&255,b1=c1&255;
const r2=(c2>>16)&255,g2=(c2>>8)&255,b2=c2&255;
return (Math.round(r1+(r2-r1)*f)<<16)|(Math.round(g1+(g2-g1)*f)<<8)|Math.round(b1+(b2-b1)*f);
}
function lerpStops(stops,t){
const n=stops.length; if(!n) return 0x888888;
const first=stops[0], last=stops[n-1];
if(t<=first[0]) return first[1];
if(t>=last[0]) return last[1];
for(let i=0;i<n-1;i++){const a=stops[i],b=stops[i+1];
if(t>=a[0]&&t<=b[0]) return lerpColor(a[1],b[1],(t-a[0])/(b[0]-a[0]));}
return last[1];
}
// ---- 几何常量(设计稿 500 基准)----
const TOP=[], BOT=[];
for(let a=2;a<=86;a+=4) TOP.push(a);
for(let a=6;a<=66;a+=3) BOT.push(a);
const DOW=["SUN","MON","TUE","WED","THU","FRI","SAT"];
const MON=["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
// ---- 绘制原语(与 Monkey C 对应)----
function drawTick(ctx,cx,cy,rIn,rOut,w,ang,col,mirror){
const eff=mirror?-ang:ang, rad=eff*Math.PI/180, s=Math.sin(rad), c=Math.cos(rad);
ctx.strokeStyle=toHex(col); ctx.lineWidth=w; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(cx+s*rIn, cy-c*rIn); ctx.lineTo(cx+s*rOut, cy-c*rOut); ctx.stroke();
}
function drawArcTop(ctx,cx,cy,rIn,rOut,w,angles,stops,off,mirror,lit,anchorOuter){
const n=angles.length;
for(let i=0;i<n;i++){const a=angles[i];
const on=anchorOuter?(i>=n-lit):(i<lit);
drawTick(ctx,cx,cy,rIn,rOut,w,a,on?lerpStops(stops,a):off,mirror);}
}
function drawArcBottom(ctx,cx,cy,rIn,rOut,w,botAngles,stops,off,mirror,lit){
const n=botAngles.length;
for(let i=0;i<n;i++){const a=botAngles[i];
const ang=180-a;
drawTick(ctx,cx,cy,rIn,rOut,w,ang,i<lit?lerpStops(stops,a):off,mirror);}
}
function drawRing(ctx,cx,cy,s,theme,pTR,pTL,pBR,pBL){
const rOut=249.5*s, rIn=231.5*s, tickOff=theme.tickOff;
const topStops=theme.ringTop, botStops=theme.ringBottom;
const topW=13*s, botW=9*s;
const litTR=Math.round(pTR*22), litTL=Math.round(pTL*22), litBR=Math.round(pBR*21), litBL=Math.round(pBL*21);
drawArcTop(ctx,cx,cy,rIn,rOut,topW,TOP,topStops,tickOff,false,litTR,true);
drawArcTop(ctx,cx,cy,rIn,rOut,topW,TOP,topStops,tickOff,true ,litTL,true);
drawArcBottom(ctx,cx,cy,rIn,rOut,botW,BOT,botStops,tickOff,true ,litBL);
drawArcBottom(ctx,cx,cy,rIn,rOut,botW,BOT,botStops,tickOff,false,litBR);
// 锚点圆点
const dotR=9.5*s, dotRad=240.5*s, dotCol=theme.anchorDot;
ctx.fillStyle=toHex(dotCol);
[0,93.3,180,266.7].forEach(da=>{const rad=da*Math.PI/180;
ctx.beginPath(); ctx.arc(cx+Math.sin(rad)*dotRad, cy-Math.cos(rad)*dotRad, dotR, 0, 7); ctx.fill();});
}
function drawBattery(ctx,cx,s,theme,pct){
const y=53.5*s, gw=30*s, gh=16*s, gx=cx-52*s, gy=y-gh/2;
ctx.strokeStyle=toHex(theme.accent); ctx.lineWidth=2;
ctx.strokeRect(gx,gy,gw,gh);
ctx.fillStyle=toHex(theme.accent);
ctx.fillRect(gx+gw, gy+gh*0.28, 3*s, gh*0.44);
ctx.fillStyle=toHex(theme.accent);
ctx.fillRect(gx+2, gy+2, (gw-4)*(pct/100), gh-4);
ctx.fillStyle=toHex(theme.textPrimary);
ctx.font=`${Math.round(16*s)}px -apple-system,Helvetica,Arial`;
ctx.textAlign='left'; ctx.textBaseline='middle';
ctx.fillText(Math.round(pct)+'%', cx+16*s, y);
}
function drawComplicationIcon(ctx,x,y,r,col,typeIdx){
ctx.strokeStyle=toHex(col); ctx.fillStyle=toHex(col); ctx.lineWidth=2;
if(typeIdx===0){ // sun
ctx.beginPath(); ctx.arc(x,y,r*0.45,0,7); ctx.fill();
for(let i=0;i<8;i++){const rad=i*45*Math.PI/180;
ctx.beginPath();
ctx.moveTo(x+Math.sin(rad)*r*0.6, y-Math.cos(rad)*r*0.6);
ctx.lineTo(x+Math.sin(rad)*r, y-Math.cos(rad)*r); ctx.stroke();}
} else if(typeIdx===1){ // flame
ctx.beginPath(); ctx.moveTo(x,y-r); ctx.lineTo(x+r*0.8,y+r*0.6); ctx.lineTo(x-r*0.8,y+r*0.6); ctx.closePath(); ctx.fill();
} else if(typeIdx===2){ // steps
ctx.beginPath(); ctx.moveTo(x-r*0.5,y-r*0.3); ctx.lineTo(x+r*0.5,y-r*0.3);
ctx.moveTo(x-r*0.3,y); ctx.lineTo(x+r*0.6,y); ctx.stroke();
} else if(typeIdx===3){ // distance dot
ctx.beginPath(); ctx.arc(x,y,r*0.5,0,7); ctx.fill();
} else if(typeIdx===4){ // floors chevron
ctx.beginPath(); ctx.moveTo(x,y-r); ctx.lineTo(x+r*0.7,y+r*0.2); ctx.lineTo(x-r*0.7,y+r*0.2); ctx.closePath(); ctx.fill();
} else { // battery
ctx.strokeRect(x-r*0.6,y-r*0.4,r*1.2,r*0.8);
}
}
function drawTopComplication(ctx,cx,cy,s,theme,typeIdx,isLeft,valStr){
const sideX=(isLeft?156:343)*s, iconY=90*s, valY=130.5*s;
ctx.fillStyle=toHex(theme.textPrimary);
ctx.font=`${Math.round(22*s)}px -apple-system,Helvetica,Arial`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(valStr, sideX, valY);
drawComplicationIcon(ctx, sideX, iconY, 13*s, theme.accent, typeIdx);
}
function drawBluetooth(ctx,x,y,r,col){
ctx.strokeStyle=toHex(col); ctx.lineWidth=3;
ctx.beginPath();
ctx.moveTo(x,y-r); ctx.lineTo(x,y+r);
ctx.moveTo(x,y-r); ctx.lineTo(x+r*0.7,y-r*0.3);
ctx.lineTo(x,y); ctx.moveTo(x,y); ctx.lineTo(x+r*0.7,y+r*0.3);
ctx.lineTo(x,y+r); ctx.stroke();
}
function drawDateBand(ctx,w,cx,s,theme,gi){
const by=286.5*s, bh=44*s, yc=308.5*s;
ctx.fillStyle=toHex(theme.bandFill); ctx.fillRect(0,by,w,bh);
const txt=toHex(theme.bandText);
drawBluetooth(ctx,36.5*s,yc,10*s,theme.bandText);
const dow=DOW[gi.day_of_week-1], md=MON[gi.month-1]+' '+gi.day;
ctx.fillStyle=txt; ctx.font=`${Math.round(16*s)}px -apple-system,Helvetica,Arial`;
ctx.textBaseline='middle';
ctx.textAlign='center'; ctx.fillText(dow,177.5*s,yc);
ctx.fillText(md,297.5*s,yc);
ctx.textAlign='right'; ctx.fillText(gi.hour>=12?'PM':'AM',465*s,yc);
}
function drawTime(ctx,cx,s,theme,gi){
let hh=gi.hour; if(hh===0)hh=12; else if(hh>12)hh=hh-12;
const hStr=''+hh, mStr=('0'+gi.min).slice(-2), ty=220*s;
ctx.font=`700 ${Math.round(66*s)}px -apple-system,Helvetica,Arial`;
ctx.textBaseline='middle';
ctx.fillStyle=toHex(theme.hours); ctx.textAlign='right'; ctx.fillText(hStr,211*s,ty);
ctx.fillStyle=toHex(theme.minutes); ctx.textAlign='left'; ctx.fillText(mStr,272*s,ty);
ctx.fillStyle=toHex(theme.colon);
ctx.fillRect(238*s,184*s,24*s,20*s);
ctx.fillRect(238*s,235*s,24*s,20*s);
}
function drawBottom(ctx,cx,s,theme,dist,steps){
const valY=366*s, labY=410.5*s, leftX=234*s, rightX=266*s;
ctx.textBaseline='middle';
ctx.fillStyle=toHex(theme.textPrimary); ctx.font=`${Math.round(22*s)}px -apple-system,Helvetica,Arial`;
ctx.textAlign='right'; ctx.fillText(dist,leftX,valY);
ctx.fillStyle=toHex(theme.accent); ctx.font=`${Math.round(16*s)}px -apple-system,Helvetica,Arial`;
ctx.fillText('DST',leftX,labY);
ctx.fillStyle=toHex(theme.textPrimary); ctx.font=`${Math.round(22*s)}px -apple-system,Helvetica,Arial`;
ctx.textAlign='left'; ctx.fillText(steps,rightX,valY);
ctx.fillStyle=toHex(theme.accent);
ctx.fillText('STEP',rightX,labY);
}
// ---- 主渲染 ----
function renderWatch(ctx, W, themeId, data, round){
const cx=W/2, cy=W/2, s=W/500;
ctx.clearRect(0,0,W,W);
ctx.fillStyle='#000'; ctx.fillRect(0,0,W,W);
if(round){ ctx.save(); ctx.beginPath(); ctx.arc(cx,cy,W/2-2,0,7); ctx.clip(); }
const theme=THEMES[themeId];
const p=[data.m[0],data.m[1],data.m[2],data.m[3],data.m[4]];
const idx={TR:0,TL:1,BR:4,BL:3}; // 默认设置
drawRing(ctx,cx,cy,s,theme, p[idx.TR],p[idx.TL],p[idx.BR],p[idx.BL]);
drawBattery(ctx,cx,s,theme,data.battery);
drawTopComplication(ctx,cx,cy,s,theme,1,true, Math.round(data.m[1]*2000).toLocaleString()); // 左上=卡路里
drawTopComplication(ctx,cx,cy,s,theme,0,false, data.temp+'°'); // 右上=天气温度
const gi=data.time;
drawTime(ctx,cx,s,theme,gi);
drawDateBand(ctx,W,cx,s,theme,gi);
drawBottom(ctx,cx,s,theme, (data.m[2]*10).toFixed(1), Math.round(data.m[0]*10000).toLocaleString());
if(round) ctx.restore();
}
// ---- 状态 ----
const state={ themeId:'01', round:true, live:true,
m:[0.84,0.64,0.65,0.80,0.40], battery:78, temp:23 };
function nowGI(){const d=new Date();return {hour:d.getHours(),min:d.getMinutes(),day:d.getDate(),month:d.getMonth()+1,day_of_week:(d.getDay()+1)%7||7};}
const face=document.getElementById('face'), fctx=face.getContext('2d');
function drawMain(){
const gi=state.live?nowGI():{hour:10,min:8,day:9,month:9,day_of_week:3};
renderWatch(fctx,500,state.themeId,{m:state.m,battery:state.battery,temp:state.temp,time:gi},state.round);
}
// 主题按钮
const themesBox=document.getElementById('themes');
ORDER.forEach((id,i)=>{
const b=document.createElement('button'); b.textContent=(i+1)+' '+THEMES[id].name;
b.onclick=()=>{state.themeId=id; [...themesBox.children].forEach(c=>c.classList.remove('active')); b.classList.add('active'); drawMain(); drawGallery();};
themesBox.appendChild(b);
});
themesBox.children[0].classList.add('active');
// 滑块
[0,1,2,3,4].forEach(i=>{
const el=document.getElementById('m'+i), v=document.getElementById('v'+i);
const upd=()=>{state.m[i]=el.value/100; v.textContent=el.value+'%'; drawMain();};
el.oninput=upd; v.textContent=el.value+'%';
});
const bat=document.getElementById('bat'), vbat=document.getElementById('vbat');
bat.oninput=()=>{state.battery=+bat.value; vbat.textContent=bat.value+'%'; drawMain();}; vbat.textContent=bat.value+'%';
const temp=document.getElementById('temp'), vtemp=document.getElementById('vtemp');
temp.oninput=()=>{state.temp=+temp.value; vtemp.textContent=temp.value+'°'; drawMain();}; vtemp.textContent=temp.value+'°';
document.getElementById('round').onchange=e=>{state.round=e.target.checked; drawMain();};
document.getElementById('live').onchange=e=>{state.live=e.target.checked; drawMain();};
// 画廊
const gal=document.getElementById('gallery');
ORDER.forEach(id=>{
const fig=document.createElement('figure');
const c=document.createElement('canvas'); c.width=150; c.height=150;
const cap=document.createElement('figcaption'); cap.textContent=(ORDER.indexOf(id)+1)+' · '+THEMES[id].name;
fig.appendChild(c); fig.appendChild(cap); gal.appendChild(fig);
});
function drawGallery(){
[...gal.children].forEach((fig,i)=>{
const c=fig.querySelector('canvas'), x=c.getContext('2d');
renderWatch(x,150,ORDER[i],{m:state.m,battery:state.battery,temp:state.temp,time:nowGI()},true);
});
}
drawMain(); drawGallery();
setInterval(()=>{ if(state.live) drawMain(); drawGallery(); }, 1000);
</script>
</body>
</html>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -0,0 +1,12 @@
<properties>
<property id="Theme" type="number">1</property>
<property id="RingTR" type="number">0</property>
<property id="RingTL" type="number">1</property>
<property id="RingBR" type="number">4</property>
<property id="RingBL" type="number">3</property>
<property id="LeftTop" type="number">1</property>
<property id="RightTop" type="number">0</property>
<property id="ShowTop" type="boolean">true</property>
<property id="ShowBand" type="boolean">true</property>
<property id="ShowBottom" type="boolean">true</property>
</properties>

View File

@@ -0,0 +1,87 @@
<settings>
<setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme">
<settingConfig type="list">
<listEntry value="1">@Strings.ThemeEmber</listEntry>
<listEntry value="2">@Strings.ThemeAurora</listEntry>
<listEntry value="3">@Strings.ThemeBrass</listEntry>
<listEntry value="4">@Strings.ThemeVoltage</listEntry>
<listEntry value="5">@Strings.ThemeReef</listEntry>
<listEntry value="6">@Strings.ThemeAcid</listEntry>
<listEntry value="7">@Strings.ThemeKelp</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RingTR" title="@Strings.SettingRingTR">
<settingConfig type="list">
<listEntry value="0">@Strings.MetricSteps</listEntry>
<listEntry value="1">@Strings.MetricCalories</listEntry>
<listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RingTL" title="@Strings.SettingRingTL">
<settingConfig type="list">
<listEntry value="0">@Strings.MetricSteps</listEntry>
<listEntry value="1">@Strings.MetricCalories</listEntry>
<listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RingBR" title="@Strings.SettingRingBR">
<settingConfig type="list">
<listEntry value="0">@Strings.MetricSteps</listEntry>
<listEntry value="1">@Strings.MetricCalories</listEntry>
<listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RingBL" title="@Strings.SettingRingBL">
<settingConfig type="list">
<listEntry value="0">@Strings.MetricSteps</listEntry>
<listEntry value="1">@Strings.MetricCalories</listEntry>
<listEntry value="2">@Strings.MetricDistance</listEntry>
<listEntry value="3">@Strings.MetricFloors</listEntry>
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop">
<settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop">
<settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry>
</settingConfig>
</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>
</settings>

View File

@@ -0,0 +1,35 @@
<strings>
<string id="AppName">Fenix 8 V3</string>
<string id="SettingTheme">Theme</string>
<string id="SettingRingTR">Ring · Top-Right</string>
<string id="SettingRingTL">Ring · Top-Left</string>
<string id="SettingRingBR">Ring · Bottom-Right</string>
<string id="SettingRingBL">Ring · Bottom-Left</string>
<string id="SettingLeftTop">Top-Left Complication</string>
<string id="SettingRightTop">Top-Right Complication</string>
<string id="SettingShowTop">Show Top Row</string>
<string id="SettingShowBand">Show Date Band</string>
<string id="SettingShowBottom">Show Bottom Row</string>
<string id="ThemeEmber">1 · Ember</string>
<string id="ThemeAurora">2 · Aurora</string>
<string id="ThemeBrass">3 · Brass</string>
<string id="ThemeVoltage">4 · Voltage</string>
<string id="ThemeReef">5 · Reef</string>
<string id="ThemeAcid">6 · Acid</string>
<string id="ThemeKelp">7 · Kelp</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="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>

28
source/Fenix8V3App.mc Normal file
View File

@@ -0,0 +1,28 @@
import Toybox.Application;
import Toybox.Lang;
import Toybox.WatchUi;
class Fenix8V3App extends Application.AppBase {
function initialize() {
AppBase.initialize();
}
function onStart(state as Dictionary?) as Void {
}
function onStop(state as Dictionary?) as Void {
}
function getInitialView() as [Views] or [Views, InputDelegates] {
return [new Fenix8V3View()];
}
function onSettingsChanged() as Void {
WatchUi.requestUpdate();
}
}
function getApp() as Fenix8V3App {
return Application.getApp() as Fenix8V3App;
}

387
source/Fenix8V3View.mc Normal file
View File

@@ -0,0 +1,387 @@
import Toybox.Application;
import Toybox.Graphics;
import Toybox.Lang;
import Toybox.Math;
import Toybox.ActivityMonitor;
import Toybox.Weather;
import Toybox.System;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.WatchUi;
import Themes;
class Fenix8V3View extends WatchUi.WatchFace {
// ----------------------------------------------------------------- utils
function getSetting(name as String, def as Number) as Number {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Number;
}
function getBool(name as String, def as Boolean) as Boolean {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Boolean;
}
function currentTheme() as Dictionary {
var idx = getSetting("Theme", 1); // 1..7
var i = idx - 1;
if (i < 0) { i = 0; }
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 {
var r1 = (c1 & 0xFF0000) >> 16;
var g1 = (c1 & 0x00FF00) >> 8;
var b1 = (c1 & 0x0000FF);
var r2 = (c2 & 0xFF0000) >> 16;
var g2 = (c2 & 0x00FF00) >> 8;
var b2 = (c2 & 0x0000FF);
var r = (r1 + ((r2 - r1) * f).toNumber()).toNumber();
var g = (g1 + ((g2 - g1) * f).toNumber()).toNumber();
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 {
var n = stops.size();
if (n == 0) { return 0x888888; }
var first = stops[0];
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; }
var p = 0.0f;
if (idx == 0) { // Steps
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
p = info.steps.toFloat() / info.stepGoal.toFloat();
}
} else if (idx == 1) { // Calories
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
} else if (idx == 2) { // Distance (cm -> 10 km cap)
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
} else if (idx == 3) { // Floors
if (info.floorsClimbed != null) {
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
? info.floorsClimbedGoal.toFloat() : 10.0f;
p = info.floorsClimbed.toFloat() / goal;
}
} else if (idx == 4) { // Move Bar
if (info.moveBarLevel != null) { p = info.moveBarLevel.toFloat(); }
}
if (p > 1.0f) { p = 1.0f; }
if (p < 0.0f) { p = 0.0f; }
return p;
}
// value string for a top complication type
function complicationValue(typeIdx as Number, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions, stats as System.Stats) as String {
if (typeIdx == 0) { // Weather temp
if (weather != null && weather.temperature != null) {
return weather.temperature.format("%d") + "\u00B0";
}
return "--";
} else if (typeIdx == 1) { // Calories
return info != null && info.calories != null ? info.calories.format("%d") : "0";
} else if (typeIdx == 2) { // Steps
return info != null && info.steps != null ? info.steps.format("%d") : "0";
} else if (typeIdx == 3) { // Distance 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,
w as Number, ang as Number, col as Number, mirror as Boolean) as Void {
var eff = mirror ? -ang : ang;
var rad = Math.toRadians(eff);
var sin = Math.sin(rad);
var cos = Math.cos(rad);
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++) {
var a = angles[i];
var on = anchorOuter ? (i >= n - lit) : (i < lit);
var col = on ? lerpStops(stops, a) : off;
drawTick(dc, cx, cy, rIn, rOut, w, a, col, mirror);
}
}
function drawArcBottom(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number,
w as Number, botAngles as Array<Number>, stops as Array<Array<Number>>,
off as Number, mirror as Boolean, lit as Number) as Void {
var n = botAngles.size();
for (var i = 0; i < n; i++) {
var a = botAngles[i];
var on = (i < lit);
var ang = (180.0 - a).toNumber();
var col = on ? lerpStops(stops, a) : off;
drawTick(dc, cx, cy, rIn, rOut, w, ang, col, mirror);
}
}
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();
var rIn = (231.5 * s).toNumber();
var tickOff = theme["tickOff"] as Number;
var topStops = theme["ringTop"] as Array<Array<Number>>;
var botStops = theme["ringBottom"] as Array<Array<Number>>;
var topW = (13.0 * s).toNumber();
var botW = (9.0 * s).toNumber();
var TOP = [2,6,10,14,18,22,26,30,34,38,42,46,50,54,58,62,66,70,74,78,82,86];
var BOT = [6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66];
var litTR = (pTR * 22.0).toNumber();
var litTL = (pTL * 22.0).toNumber();
var litBR = (pBR * 21.0).toNumber();
var litBL = (pBL * 21.0).toNumber();
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, false, litTR, true);
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, true, litTL, true);
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, true, litBL);
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, false, litBR);
// anchor dots
var dotR = (9.5 * s).toNumber();
var dotRad = (240.5 * s).toNumber();
var dotCol = theme["anchorDot"] as Number;
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 rad = Math.toRadians(dotAngles[i]);
var dx = (cx + Math.sin(rad) * dotRad).toNumber();
var dy = (cy - Math.cos(rad) * dotRad).toNumber();
dc.fillCircle(dx, dy, dotR);
}
}
// ------------------------------------------------------------- complications
function drawBattery(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
stats as System.Stats) as Void {
var y = (53.5 * s).toNumber();
var pct = 0;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
var txt = pct.format("%d") + "%";
var gw = (30.0 * s).toNumber();
var gh = (16.0 * s).toNumber();
var gx = (cx - 52.0 * s).toNumber();
var gy = (y - gh / 2).toNumber();
dc.setPenWidth(2);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawRectangle(gx, gy, gw, gh);
dc.fillRectangle((gx + gw).toNumber(), (gy + gh * 0.28).toNumber(),
(3.0 * s).toNumber(), (gh * 0.44).toNumber());
var fillW = ((gw - 4) * (pct.toFloat() / 100.0)).toNumber();
dc.fillRectangle((gx + 2).toNumber(), (gy + 2).toNumber(), fillW, (gh - 4).toNumber());
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText((cx + 16.0 * s).toNumber(), y, Graphics.FONT_SMALL, txt,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
}
function drawComplicationIcon(dc as Dc, x as Number, y as Number, r as Number, col as Number,
typeIdx as Number) as Void {
dc.setPenWidth(2);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
if (typeIdx == 0) { // sun
dc.fillCircle(x, y, (r * 0.45).toNumber());
for (var i = 0; i < 8; i++) {
var rad = Math.toRadians(i * 45.0);
var x1 = (x + Math.sin(rad) * r * 0.6).toNumber();
var y1 = (y - Math.cos(rad) * r * 0.6).toNumber();
var x2 = (x + Math.sin(rad) * r).toNumber();
var y2 = (y - Math.cos(rad) * r).toNumber();
dc.drawLine(x1, y1, x2, y2);
}
} else if (typeIdx == 1) { // flame
dc.fillPolygon([[x, (y - r).toNumber()],
[(x + r * 0.8).toNumber(), (y + r * 0.6).toNumber()],
[(x - r * 0.8).toNumber(), (y + r * 0.6).toNumber()]]);
} else if (typeIdx == 2) { // steps
dc.drawLine((x - r * 0.5).toNumber(), (y - r * 0.3).toNumber(),
(x + r * 0.5).toNumber(), (y - r * 0.3).toNumber());
dc.drawLine((x - r * 0.3).toNumber(), y, (x + r * 0.6).toNumber(), y);
} else if (typeIdx == 3) { // distance dot
dc.fillCircle(x, y, (r * 0.5).toNumber());
} else if (typeIdx == 4) { // floors chevron
dc.fillPolygon([[x, (y - r).toNumber()],
[(x + r * 0.7).toNumber(), (y + r * 0.2).toNumber()],
[(x - r * 0.7).toNumber(), (y + r * 0.2).toNumber()]]);
} else { // battery
dc.drawRectangle((x - r * 0.6).toNumber(), (y - r * 0.4).toNumber(),
(r * 1.2).toNumber(), (r * 0.8).toNumber());
}
}
function drawTopComplication(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
typeIdx as Number, isLeft as Boolean, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions, stats as System.Stats) as Void {
var sideX = isLeft ? (156.0 * s).toNumber() : (343.0 * s).toNumber();
var iconY = (90.0 * s).toNumber();
var valY = (130.5 * s).toNumber();
var valStr = complicationValue(typeIdx, info, weather, stats);
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(sideX, valY, Graphics.FONT_MEDIUM, valStr,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
drawComplicationIcon(dc, sideX, iconY, (13.0 * s).toNumber(), theme["accent"] as Number, typeIdx);
}
function drawBluetooth(dc as Dc, cx as Number, cy as Number, r as Number, col as Number) as Void {
dc.setPenWidth(3);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawLine(cx, (cy - r).toNumber(), cx, (cy + r).toNumber());
dc.drawLine(cx, (cy - r).toNumber(), (cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber());
dc.drawLine((cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber(), cx, cy);
dc.drawLine(cx, cy, (cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber());
dc.drawLine((cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber(), cx, (cy + r).toNumber());
}
function drawDateBand(dc as Dc, w as Number, cx as Number, s as Float, theme as Dictionary,
ds as System.DeviceSettings) as Void {
var by = (286.5 * s).toNumber();
var bh = (44.0 * s).toNumber();
dc.setColor(theme["bandFill"] as Number, theme["bandFill"] as Number);
dc.fillRectangle(0, by, w, bh);
var txtCol = theme["bandText"] as Number;
var yc = (308.5 * s).toNumber();
drawBluetooth(dc, (36.5 * s).toNumber(), yc, (10.0 * s).toNumber(), txtCol);
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
var DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
var MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
var dow = DOW[gi.day_of_week - 1];
var md = MON[gi.month - 1] + " " + gi.day.format("%d");
dc.setColor(txtCol, Graphics.COLOR_TRANSPARENT);
dc.drawText((177.5 * s).toNumber(), yc, Graphics.FONT_SMALL, dow,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
dc.drawText((297.5 * s).toNumber(), yc, Graphics.FONT_SMALL, md,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
var ampm = gi.hour >= 12 ? "PM" : "AM";
dc.drawText((465.0 * s).toNumber(), yc, Graphics.FONT_SMALL, ampm,
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
}
function drawTime(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary) as Void {
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
var hh = gi.hour;
if (hh == 0) { hh = 12; } else if (hh > 12) { hh = hh - 12; }
var hStr = hh.format("%d");
var mStr = gi.min.format("%02d");
var ty = (220.0 * s).toNumber();
dc.setColor(theme["hours"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText((211.0 * s).toNumber(), ty, Graphics.FONT_NUMBER_HOT, hStr,
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["minutes"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText((272.0 * s).toNumber(), ty, Graphics.FONT_NUMBER_HOT, mStr,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
// colon: two blocks centred on x=250
dc.setColor(theme["colon"] as Number, Graphics.COLOR_TRANSPARENT);
var cw = (24.0 * s).toNumber();
var ch = (20.0 * s).toNumber();
var cxc = (238.0 * s).toNumber();
dc.fillRectangle(cxc, (184.0 * s).toNumber(), cw, ch);
dc.fillRectangle(cxc, (235.0 * s).toNumber(), cw, ch);
}
function drawBottom(dc as Dc, cx as Number, s as Float, theme as Dictionary,
info as ActivityMonitor.Info) as Void {
var valY = (366.0 * s).toNumber();
var labY = (410.5 * s).toNumber();
var leftX = (234.0 * s).toNumber();
var rightX = (266.0 * s).toNumber();
var dist = info != null && info.distance != null
? (info.distance.toFloat() / 100000.0).format("%.1f") : "0.0";
var steps = info != null && info.steps != null ? info.steps.format("%d") : "0";
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(leftX, valY, Graphics.FONT_MEDIUM, dist,
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(leftX, labY, Graphics.FONT_SMALL, "DST",
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(rightX, valY, Graphics.FONT_MEDIUM, steps,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawText(rightX, labY, Graphics.FONT_SMALL, "STEP",
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
}
// ----------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void {
var w = dc.getWidth();
var h = dc.getHeight();
var s = w / 500.0;
var cx = w / 2;
var cy = h / 2;
var theme = currentTheme();
var info = ActivityMonitor.getInfo();
var stats = System.getSystemStats();
var ds = System.getDeviceSettings();
var weather = Weather.getCurrentConditions();
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
dc.clear();
var pTR = metricProgress(getSetting("RingTR", 0), info);
var pTL = metricProgress(getSetting("RingTL", 1), info);
var pBR = metricProgress(getSetting("RingBR", 4), info);
var pBL = metricProgress(getSetting("RingBL", 3), info);
drawRing(dc, cx, cy, s, theme, pTR, pTL, pBR, pBL);
if (getBool("ShowTop", true)) {
drawBattery(dc, cx, cy, s, theme, stats);
drawTopComplication(dc, cx, cy, s, theme, getSetting("LeftTop", 1), true, info, weather, stats);
drawTopComplication(dc, cx, cy, s, theme, getSetting("RightTop", 0), false, info, weather, stats);
}
drawTime(dc, cx, cy, s, theme);
if (getBool("ShowBand", true)) {
drawDateBand(dc, w, cx, s, theme, ds);
}
if (getBool("ShowBottom", true)) {
drawBottom(dc, cx, s, theme, info);
}
}
}

110
source/Themes.mc Normal file
View File

@@ -0,0 +1,110 @@
// AUTO-GENERATED from watchface-tokens.json — do not edit by hand.
module Themes {
// THEMES: id -> theme dictionary.
// ringTop / ringBottom are gradient stop arrays: [angle, 0xRRGGBB] sorted ascending.
var 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,
}
};
// theme id list for settings enum (1-based index -> id)
var ORDER = [
"01", "02", "03", "04", "05", "06", "07"
];
}