Update watch face: modular refactor + OWM/Solar/background + codegen tools

- Split render into FieldTable/Fields/Icons/Layout/Settings modules
- Add Fenix8V3Background, Owm (weather), Solar modules
- Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg)
- Tune README/manifest/strings/settings
This commit is contained in:
2026-09-10 07:10:55 +08:00
parent 52346bdd09
commit 4d96ab0912
23 changed files with 1549 additions and 344 deletions

View File

@@ -1,12 +1,25 @@
import Toybox.Application;
import Toybox.Background;
import Toybox.Lang;
import Toybox.System;
import Toybox.Time;
import Toybox.WatchUi;
import Owm;
// Application shell. A watch face has no menus or input of its own, so this
// only hands back the view and forces a repaint when the wearer changes
// something in the Connect IQ settings.
// ============================================================================
// 应用入口
// ----------------------------------------------------------------------------
// 表盘没有菜单也拿不到输入,所以这个壳子只做三件事:
// 1. 交出 view
// 2. 设置变更时请求重绘
// 3. 管理拉取 OpenWeatherMap 的后台服务
// ============================================================================
class Fenix8V3App extends Application.AppBase {
// OWM 免费档的调用额度很宽裕,但表盘每分钟才重绘一次、数据本身也是小时级的,
// 拉太勤只是白白唤醒射频费电。半小时是个合适的折中。
const OWM_PERIOD_MIN = 30;
function initialize() {
AppBase.initialize();
}
@@ -17,15 +30,55 @@ class Fenix8V3App extends Application.AppBase {
function onStop(state as Dictionary?) as Void {
}
// ⚠️ 定时器的注册必须放在 getInitialView 里,不能放 onStart。
// 因为 onStart **在后台服务进程里也会被调用一次**,而在后台进程里调
// registerForTemporalEvent 会抛 Out of Bounds。
// getInitialView 只在前台进程运行,是安全的位置。
function getInitialView() as [Views] or [Views, InputDelegates] {
scheduleWeather();
return [new Fenix8V3View()];
}
// Fires when the phone pushes new settings down. The view reads every
// preference fresh on each draw, so a repaint is all that is needed.
function getServiceDelegate() as [System.ServiceDelegate] {
return [new Fenix8V3Background()];
}
// 只有用户真的填了 API key 才注册定时器 —— 没填就没什么可拉的,
// 注册了纯属浪费电。反过来,用户把 key 清空时也要及时注销。
function scheduleWeather() as Void {
if (!(Background has :registerForTemporalEvent)) { return; }
// ⚠️ deleteTemporalEvent 在「本来就没注册」时会抛 Out of Bounds
// 而这个错误是 VM 级的,**try/catch 抓不住**。
// 所以这里不去问系统当前状态,而是自己在 Storage 里记一个标志位。
// 重复注册本身是无害的(只是重置周期),所以只在状态真的变化时动手。
var want = Owm.enabled();
var have = Application.Storage.getValue("bgOn");
have = (have != null) && (have as Boolean);
if (want == have) { return; }
if (want) {
Background.registerForTemporalEvent(
new Time.Duration(OWM_PERIOD_MIN * 60));
} else {
Background.deleteTemporalEvent();
}
Application.Storage.setValue("bgOn", want);
}
// 手机把新设置推下来时触发。
// view 每次绘制都是现读设置,所以重绘一下就够了;
// 但天气定时器要跟着新的 key 状态启停,所以先过一遍 scheduleWeather。
function onSettingsChanged() as Void {
scheduleWeather();
WatchUi.requestUpdate();
}
// 后台服务把结果通过这里交回前台。存进 Storage 供表盘读取,然后重绘。
function onBackgroundData(data as Application.PersistableType) as Void {
if (data != null) {
Application.Storage.setValue(Owm.STORE, data);
WatchUi.requestUpdate();
}
}
}
function getApp() as Fenix8V3App {

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

@@ -13,16 +13,24 @@ import Layout;
import Icons;
import Settings;
// The face itself: the progress ring, the elements laid over it, and the
// stripped-down variant drawn while an AMOLED watch is asleep.
// ============================================================================
// 表盘主体
// ----------------------------------------------------------------------------
// 负责:进度环、盖在环上的各个元素、以及 AMOLED 常亮时的简化版画面。
//
// Anything about *where* things go lives in Layout, anything about *what* a
// data slot says lives in Fields, and the colours come from Themes. What is
// left here is the drawing order.
// 分工约定(改代码前先看清楚该动哪个文件):
// 位置尺寸 → Layout.mc
// 数据取值 → Fields.mc
// 配色 → Themes.mc自动生成
// 设置读取 → Settings.mc
// 图标 → Icons.mc
// 留在本文件里的只有「按什么顺序画」。
// ============================================================================
class Fenix8V3View extends WatchUi.WatchFace {
// Always-on display state. AMOLED panels have to cut their lit pixel
// count while asleep; MIP panels do not care and are left alone.
// 常亮显示状态。
// AMOLED 屏在息屏常亮时必须大幅减少点亮像素Garmin 上架审核会卡这一条,
// 而且不这么做既费电又有烧屏风险MIP 屏不受影响,照常全量绘制。
private var mSleeping = false;
private var mBurnIn = false;
@@ -30,18 +38,20 @@ class Fenix8V3View extends WatchUi.WatchFace {
WatchFace.initialize();
}
// 进入低功耗息屏。AMOLED 机型从这一刻起改画简化版面。
function onEnterSleep() as Void {
mSleeping = true;
WatchUi.requestUpdate();
}
// 抬腕唤醒,恢复完整版面。
function onExitSleep() as Void {
mSleeping = false;
WatchUi.requestUpdate();
}
// Called once when the face is loaded, and again by onUpdate if anything
// it caches has gone stale (a resolution change, or resources not yet in).
// 表盘加载时调一次;如果 onUpdate 发现缓存失效(分辨率变了,或资源还没
// 加载好)会再调一次。所有「一次性、开销大」的准备工作都放这里。
function onLayout(dc as Dc) as Void {
Layout.init(dc);
Fields.loadStrings();
@@ -51,27 +61,28 @@ class Fenix8V3View extends WatchUi.WatchFace {
&& ds.requiresBurnInProtection;
}
// ------------------------------------------------------------- ring
// Progress for one of the four arcs, 0..1. These ids are the ring's own
// short list, not the data field catalogue.
// ------------------------------- 进度环 -------------------------------
// 计算四段弧各自的进度0..1。
// ⚠️ 这里的 idx 是**进度环自己的一张短表**(见 Settings.ringTR 的注释),
// 和数据位那套 125 项的字段编号完全是两回事,别混。
function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float {
if (info == null) { return 0.0f; }
var p = 0.0f;
if (idx == 0) { // steps
if (idx == 0) { // 步数:对每日目标
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
p = info.steps.toFloat() / info.stepGoal.toFloat();
}
} else if (idx == 1) { // calories
} else if (idx == 1) { // 卡路里:固定 2000 kcal 为满
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
} else if (idx == 2) { // distance, 10 km ring
} else if (idx == 2) { // 距离10 km 为满distance 单位是厘米)
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
} else if (idx == 3) { // floors
} else if (idx == 3) { // 楼层:对每日目标,取不到目标就按 10 层
if (info.floorsClimbed != null) {
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
? info.floorsClimbedGoal.toFloat() : 10.0f;
p = info.floorsClimbed.toFloat() / goal;
}
} else if (idx == 5) { // active minutes vs week goal
} else if (idx == 5) { // 活动分钟:对**每周**目标
if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null
&& (info has :activeMinutesWeekGoal)
&& info.activeMinutesWeekGoal != null
@@ -79,10 +90,13 @@ class Fenix8V3View extends WatchUi.WatchFace {
p = info.activeMinutesWeek.total.toFloat()
/ info.activeMinutesWeekGoal.toFloat();
}
} else if (idx == 6) { // body battery is already 0..100
} else if (idx == 6) { // 身体电量:本身就是 0..100
var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1}));
if (bb != null) { p = bb.toFloat() / 100.0; }
} else if (idx == 4) { // move bar: a level, not a ratio
} else if (idx == 4) {
// ⚠️ 动动条是**整数等级**0..MOVE_BAR_LEVEL_MAX通常是 5
// 不是 0..1 的比例。初版直接当比例用结果等级≥1 就被钳成满环,
// 中间状态全丢了。
if (info.moveBarLevel != null) {
var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX
- ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat();
@@ -96,8 +110,9 @@ class Fenix8V3View extends WatchUi.WatchFace {
return p;
}
// One tick: an annular sector drawn as a quad. At three degrees the chord
// sagitta is under a tenth of a pixel, so straight edges are exact enough.
// 画一根刻度:环形扇区,用四点多边形填充。
// 扇区两条弧边其实是曲线,但 3° 弧的弓高不到 0.1 像素,用直边完全够精确,
// 而 fillPolygon 比 drawArc 行为更确定(各机型的 drawArc 端点样式不一致)。
function tick(dc as Dc, ang as Float, hw as Float) as Void {
var a0 = ang - hw;
var a1 = ang + hw;
@@ -109,8 +124,10 @@ class Fenix8V3View extends WatchUi.WatchFace {
[Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]);
}
// Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs
// fill outwards from 6 o'clock. That is what the comps show.
// 通用画弧。点亮方向与设计稿一致:
// 两条顶弧:从 9 点 / 3 点那一端朝 12 点方向填充fromEnd=true
// 两条底弧:从 6 点朝两侧填充fromEnd=false
// mirror 用来把同一组角度镜像到左半边,这样左右弧共用一份角度表和配色表。
function arc(dc as Dc, ramp as Array<Number>, off as Number, lit as Number,
phase as Float, pitch as Float, hw as Float, n as Number,
base as Float, mirror as Boolean, fromEnd as Boolean) as Void {
@@ -124,6 +141,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
function drawRing(dc as Dc, ti as Number, pTR as Float, pTL as Float,
pBR as Float, pBL as Float) as Void {
// 四段弧共用两份配色表:两条顶弧用 RINGTOP两条底弧用 RINGBOTTOM。
// 左右弧靠 mirror 参数镜像,颜色按刻度下标取,所以左右对称位置同色。
var top = Themes.RINGTOP[ti] as Array<Number>;
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var off = Themes.TICKOFF[ti] as Number;
@@ -137,6 +156,7 @@ class Fenix8V3View extends WatchUi.WatchFace {
arc(dc, bot, off, (pBR * Layout.BOT_N).toNumber(), Layout.BOT_PH,
Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, true, false);
// 四个锚点圆点,压在四段弧的接缝处。
dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT);
var r = Layout.px(Layout.DOT_R);
var rad = Layout.DOT_RAD * Layout.scale;
@@ -146,7 +166,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
}
}
// ---------------------------------------------------------- elements
// ------------------------------- 各元素 -------------------------------
// 顶部中央:电池图标 + 百分比。
function drawBattery(dc as Dc, ti as Number, level as Float) as Void {
Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0,
Themes.ACCENT[ti] as Number, level);
@@ -155,6 +176,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
}
// 顶部单个数据位:上方图标 + 下方数值。
// 数值走 fittedText太长会自动降字号不会冲出表盘。
function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean,
ctx as Fields.Ctx, level as Float) as Void {
var x = isLeft ? Layout.LEFT_X : Layout.RIGHT_X;
@@ -164,18 +187,22 @@ class Fenix8V3View extends WatchUi.WatchFace {
Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W);
}
// 日期带:整条横幅底色 + 蓝牙符号 + 星期 + 月日 + 上午/下午。
// 24 小时制下不显示上午/下午(跟随手表设置)。
function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var fill = Themes.BANDFILL[ti] as Number;
dc.setColor(fill, fill);
dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width,
Layout.px(Layout.BAND_H));
var col = Themes.BANDTEXT[ti] as Number;
// Dim the rune rather than hide it, so the band does not change shape
// every time the phone wanders out of range.
// 手机断连时把蓝牙符号**压暗**而不是隐藏 —— 否则手机一走远日期带就
// 少一块,整条带子的视觉重心跟着变,很干扰。
Icons.bluetooth(dc, (ds != null && !ds.phoneConnected)
? blend(col, fill, 0.6) : col);
// FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns
// day_of_week and month as numbers rather than localised strings.
// ⚠️ 必须用 FORMAT_SHORT,不能用 FORMAT_MEDIUM
// 只有短格式的 day_of_week / month 返回的是**数字**;中格式返回的是
// 本地化字符串,拿去做数组下标会直接抛类型异常 —— 初版就是这么崩的,
// 而且是每次更新都崩。
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand,
Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col);
@@ -187,6 +214,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
}
}
// 中央时间。小时和分钟各自带竖向渐变gradText 内部会分带多次绘制),
// 中间的冒号是两个实心方块,位置写死在设计稿坐标上。
function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var h = g.hour;
@@ -206,6 +235,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
}
// 底部两格:各自「数值 + 小标签」。
// 标签跟随所选字段自动变化,不需要单独配置。
function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void {
var value = Themes.TEXTPRIMARY[ti] as Number;
var label = Themes.ACCENT[ti] as Number;
@@ -225,7 +256,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
label, Layout.BOT_MAX_W);
}
// ------------------------------------------------------------ colour
// ------------------------------- 颜色工具 ------------------------------
// 两色按比例混合f=0 取 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;
@@ -236,6 +268,7 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b;
}
// 整体压暗到 f 倍(常亮模式用)。
function dim(col as Number, f as Float) as Number {
var r = (((col >> 16) & 0xFF) * f).toNumber();
var g = (((col >> 8) & 0xFF) * f).toNumber();
@@ -243,14 +276,18 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b;
}
// --------------------------------------------------------------- AOD
// Low-power face: no band fill, no unlit ticks, no data slots -- just a
// dimmed time, the lit part of the ring, and the date. The whole thing
// walks a few pixels each minute so no pixel stays lit in one place.
// ------------------------------- 常亮模式 ------------------------------
// 低功耗画面。相比正常画面砍掉的东西:
// - 日期带底色(整条亮橙色横幅是最费电的元素,直接不画)
// - 未点亮的刻度(只画有数据的那部分,且压暗到 45%
// - 四个数据位、电池(全部隐藏)
// 保留的时间和日期压暗到 55%。
//
// 另外整个画面按分钟在 ±4 像素内游走,避免同一个像素长期点亮导致烧屏。
function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?,
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // +/- 4 device px
var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // ±4 设备像素,换算回设计像素
var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
var top = Themes.RINGTOP[ti] as Array<Number>;
@@ -307,7 +344,79 @@ class Fenix8V3View extends WatchUi.WatchFace {
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band);
}
// -------------------------------------------------------------- main
// ------------------------------- 秒针刷新 ------------------------------
// 表盘正常只有每分钟一次的 onUpdate秒数根本走不动。
// 这里实现 onPartialUpdate系统每秒回调一次但要非常克制
// - 只有当真的有数据位选了「秒」时才做事,否则直接返回
// - 只重绘那一格,用 setClip 把绘制限制在格子的包围盒内
// (每秒重绘整个表盘的功耗预算是不够的)
// - 常亮的 AMOLED 机型直接跳过:那时数据位本来就不显示
function isSeconds(id as Number) as Boolean {
return id == 850 || id == 869;
}
function wantsSeconds() as Boolean {
if (Settings.showTop()
&& (isSeconds(Settings.leftTop()) || isSeconds(Settings.rightTop()))) {
return true;
}
return Settings.showBottom()
&& (isSeconds(Settings.bottomLeft()) || isSeconds(Settings.bottomRight()));
}
// 重绘单个格子:先把它的包围盒涂黑,再把新值画回去。
function repaintSlot(dc as Dc, ti as Number, ctx as Fields.Ctx, id as Number,
x as Numeric, cyDesign as Numeric, just as Number,
maxW as Float, col as Number) as Void {
var half = maxW / 2.0;
var x0 = x - half;
if (just == Graphics.TEXT_JUSTIFY_RIGHT) { x0 = x - maxW; }
else if (just == Graphics.TEXT_JUSTIFY_LEFT) { x0 = x; }
var h = Layout.CAP_COMP * 1.6;
dc.setClip(Layout.px(x0), Layout.px(cyDesign - h / 2),
Layout.px(maxW), Layout.px(h));
// 全屏涂黑。设计稿的底色就是纯黑AMOLED 上黑像素也最省电。
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
dc.clear();
Layout.fittedText(dc, x, cyDesign, Layout.fontComp, Fields.value(id, ctx),
just, col, maxW);
dc.clearClip();
}
function onPartialUpdate(dc as Dc) as Void {
if (mBurnIn || !Layout.ready() || !Fields.ready()) { return; }
if (!wantsSeconds()) { return; }
var ti = Settings.themeIndex(Themes.NAMES.size());
var ctx = new Fields.Ctx(System.getDeviceSettings(), Settings.tempUnit());
var col = Themes.TEXTPRIMARY[ti] as Number;
if (Settings.showTop()) {
if (isSeconds(Settings.leftTop())) {
repaintSlot(dc, ti, ctx, Settings.leftTop(), Layout.LEFT_X,
Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER,
Layout.TOP_MAX_W, col);
}
if (isSeconds(Settings.rightTop())) {
repaintSlot(dc, ti, ctx, Settings.rightTop(), Layout.RIGHT_X,
Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER,
Layout.TOP_MAX_W, col);
}
}
if (Settings.showBottom()) {
if (isSeconds(Settings.bottomLeft())) {
repaintSlot(dc, ti, ctx, Settings.bottomLeft(), Layout.DIST_R,
Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_RIGHT,
Layout.BOT_MAX_W, col);
}
if (isSeconds(Settings.bottomRight())) {
repaintSlot(dc, ti, ctx, Settings.bottomRight(), Layout.STEP_L,
Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_LEFT,
Layout.BOT_MAX_W, col);
}
}
}
// ------------------------------- 主绘制 --------------------------------
// 每分钟调用一次(以及唤醒、设置变更时)。
function onUpdate(dc as Dc) as Void {
if (!Layout.ready() || Layout.width != dc.getWidth()
|| !Fields.ready() || !Icons.ready()) {
@@ -315,6 +424,8 @@ class Fenix8V3View extends WatchUi.WatchFace {
}
if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
// 每次绘制只构造一个 Ctx四个数据位共用 —— 天气、用户档案这些
// 开销大的查询因此每帧最多做一次,且只在真有格子要用时才做。
var ti = Settings.themeIndex(Themes.NAMES.size());
var ds = System.getDeviceSettings();
var ctx = new Fields.Ctx(ds, Settings.tempUnit());
@@ -333,6 +444,7 @@ class Fenix8V3View extends WatchUi.WatchFace {
var pBR = ringProgress(Settings.ringBR(), info);
var pBL = ringProgress(Settings.ringBL(), info);
// 常亮模式走另一条绘制路径,画完直接返回。
if (mSleeping && mBurnIn) {
drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL);
return;

View File

@@ -1,18 +1,25 @@
// AUTO-GENERATED by tools/gen_fields.py -- do not edit by hand.
// Ids follow https://watchface.io/docs/datafields; only the fields a
// Connect IQ watch face can source on-device are listed.
// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。
// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,
// 再到 source/Fields.mc 对应分类的函数里补一个分支。
//
// 字段编号沿用 https://watchface.io/docs/datafields只收录 Connect IQ
// 表盘在本机能算出来的那些。
import Toybox.Lang;
module FieldTable {
// Field ids, in the same order as the Labels resource string.
// 全部字段 id顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——
// Fields.label() 就是靠这个下标去取对应标签的。
const IDS = [
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,873,1,14,707,2,603,150,
152,154,151,3,4,5,6,9,10,618,276,12,635,11,623,8,15,281,742,13,278,277,250,251,257,
253,255,254,256,252,615,616,733,712,717,700,731,706,732,306,639,300,301,302,305,304,
308,307,310,309,703,704,705
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,1,14,707,2,
603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,618,276,12,635,
11,623,8,15,281,742,13,278,277,250,251,257,253,255,254,256,252,615,616,733,712,717,
700,731,706,732,710,709,708,711,714,715,718,719,720,721,306,639,300,301,302,305,304,
308,307,310,309,312,313,314,315,500,515,503,504,505,507,502,501,506,509,510,511,518,
512,513,532,536,537,530,534,522,535,523,516,517,703,704,705
];
// 由字段 id 反查它在 IDS 里的下标;找不到返回 0对应「关闭」
function indexOf(id as Number) as Number {
for (var i = 0; i < IDS.size(); i++) {
if (IDS[i] == id) { return i; }

View File

@@ -13,16 +13,27 @@ import Toybox.Position;
import Toybox.WatchUi;
import FieldTable;
import Settings;
import Solar;
import Owm;
// Everything a complication slot can show. Ids match
// https://watchface.io/docs/datafields so the two catalogues line up; see
// tools/gen_fields.py for the table that drives the settings and the labels.
// ============================================================================
// 数据字段
// ----------------------------------------------------------------------------
// 四个数据位能显示的全部内容,共 125 项。
//
// Only fields Connect IQ can answer from the watch itself are here. The
// week-to-date and 7/28-day per-sport aggregates in that catalogue come from a
// phone-side Garmin Connect integration, and the OpenWeatherMap / StormGlass /
// third-party-app fields need network calls and API keys; neither is something
// a self-contained watch face can produce.
// 字段编号沿用 https://watchface.io/docs/datafields两边可以直接对照。
// 驱动设置项和标签文案的那张表在 tools/gen_fields.py —— 加字段要改那里,
// 然后在本文件对应分类的函数里补一个分支。
//
// 只收录**手表本机能算出来**的字段。没做的几类及原因见 README 第 7 节,简单说:
// - 按运动分类的周/月累计CIQ 只有按天的总计,没有分运动的历史
// - StormGlass 潮汐、第三方 App 复杂功能:需要额外 key 或用户装了对应 App
// - 日历、计时器CIQ 不开放日历,表盘也拿不到触摸输入
//
// 组织方式value() 按分类依次尝试 7 个函数,每个函数不认识编号就返回 null。
// 之所以不按编号区间派发,是因为编号是**交错**的 —— 1/2/3 是活动8/11/12 是
// 身体14 又回到活动。
// ============================================================================
module Fields {
const DASH = "--";
@@ -43,7 +54,7 @@ module Fields {
function ready() as Boolean { return mDow != null; }
// Monkey C has no String.split.
// Monkey C 没有 String.split,只能自己按逗号切。
function splitCsv(str as String) as Array<String> {
var out = [] as Array<String>;
var rest = str;
@@ -69,8 +80,8 @@ module Fields {
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
}
// "10" in English, "10日" in Chinese -- the format string carries the
// suffix, so feed it an empty month.
// 英文出 "10",中文出 "10日"。
// 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
function dayOnly(g as Gregorian.Info) as String {
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
}
@@ -79,16 +90,18 @@ module Fields {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
}
// Short caption printed under a bottom-row value.
// 底部格子数值下方的小标签。按字段编号查 FieldTable 的下标,
// 再到本地化的标签表里取词,所以中英文会自动跟随系统语言。
function label(id as Number) as String {
if (mLabels == null) { return ""; }
var i = FieldTable.indexOf(id);
return i < mLabels.size() ? mLabels[i] : "";
}
// One of these is built per onUpdate and handed to every slot, so the
// expensive lookups (forecast, sensor history, user profile) happen at most
// once per draw and only if some slot actually asks for them.
// 每次绘制构造一个,传给所有数据位共用。
// 目的是让开销大的查询(天气预报、传感器历史、用户档案)**每次绘制最多做
// 一次,且只在真的有格子要用时才做** —— 四个格子可能都要天气,但只查一次;
// 没有格子用到 UserProfile 时就完全不去碰它。
class Ctx {
var greg as Gregorian.Info;
var is24 as Boolean = true;
@@ -152,18 +165,40 @@ module Fields {
return mProf;
}
// 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空:
// - 天气在同步之前没有观测点坐标
// - currentLocation 要有东西刚定过位才有值
// 所以每次拿到位置就缓存进 Storage两个来源都空时用缓存兜底
// 免得这些字段一会儿有一会儿变 "--"。
function where() as Position.Location? {
var w = wx();
if (w != null && w.observationLocationPosition != null) {
return w.observationLocationPosition;
if (w != null && (w has :observationLocationPosition)
&& w.observationLocationPosition != null) {
return remember(w.observationLocationPosition);
}
var a = act();
if (a != null && a.currentLocation != null) { return a.currentLocation; }
if (a != null && a.currentLocation != null) {
return remember(a.currentLocation);
}
var lat = Application.Storage.getValue("lastLat");
var lon = Application.Storage.getValue("lastLon");
if (lat != null && lon != null) {
return new Position.Location({:latitude => lat as Double,
:longitude => lon as Double,
:format => :degrees});
}
return null;
}
function remember(loc as Position.Location) as Position.Location {
var d = loc.toDegrees();
Application.Storage.setValue("lastLat", d[0]);
Application.Storage.setValue("lastLon", d[1]);
return loc;
}
}
// ------------------------------------------------------------- helpers
// ------------------------------- 格式化工具 ----------------------------
function num(v as Numeric?) as String {
return v == null ? DASH : v.format("%d");
}
@@ -197,7 +232,7 @@ module Fields {
return t.format("%d") + "°";
}
// ActivityMonitor distances are centimetres.
// ActivityMonitor 的距离单位是**厘米**,先换算成公里再按单位制转换。
function dist(cm as Numeric?, c as Ctx) as String {
if (cm == null) { return DASH; }
var km = cm.toFloat() / 100000.0;
@@ -217,7 +252,7 @@ module Fields {
return (n / 1000.0).format("%.1f") + "k";
}
// Newest reading out of a SensorHistory iterator, or null.
// 从 SensorHistory 迭代器里取最新一条读数;没有则 null
function latest(iter) as Numeric? {
if (iter == null) { return null; }
var s = iter.next();
@@ -225,6 +260,53 @@ module Fields {
return s.data;
}
// 历史累计。
// ActivityMonitor.getHistory() 返回过去若干天(最新在前),**不含今天**。
// 把它加起来是本机唯一能做出「本周累计 / 近 7 天累计」的路子;
// 按运动项目细分的那些仍然做不到,那需要手机侧与 Garmin Connect 的集成。
//
// 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;
@@ -234,7 +316,7 @@ module Fields {
return d;
}
// ISO 8601 week: weeks start Monday, week 1 holds the first Thursday.
// ISO 8601 周数:周一为一周之始,含当年第一个周四的那周为第 1 周。
function isoWeek(g as Gregorian.Info) as Number {
var dow = g.day_of_week - 1; // 0 = Sunday
var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday
@@ -244,7 +326,7 @@ module Fields {
return w;
}
// Days since the new moon of 2000-01-06, folded into one synodic month.
// 月龄:距 2000-01-06 那次新月的天数对朔望月29.530588853 天)取余。
function moonAge(g as Gregorian.Info) as Float {
var y = g.year;
var m = g.month;
@@ -253,7 +335,7 @@ module Fields {
var b = 2 - a + a / 4;
var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber()
+ g.day + b - 1524.5 + g.hour / 24.0;
// Monkey C has no float modulo.
// ⚠️ Monkey C 的 % 不支持浮点,只能手写取余。
var syn = 29.530588853;
var x = jd - 2451550.1;
var age = x - (x / syn).toNumber() * syn;
@@ -271,113 +353,136 @@ module Fields {
return pts[i];
}
// --------------------------------------------------------------- value
// Clock, calendar and week-number fields.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ------------------------------- 分类取值 ------------------------------
// ---- 日期时间类 ----
// 不属于本类的编号返回 null交给 value() 试下一个分类。
function dateTime(id as Number, c as Ctx) as String? {
if (id == 860) { return clockInfo(c.greg, c); }
if (id == 880) {
if (id == 860) { return clockInfo(c.greg, c); } // 时间
if (id == 880) { // UTC 时间
var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT);
return clockInfo(utc, c);
}
// 第二时区 1
if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); }
// 第二时区 2
if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); }
if (id == 855) { return c.greg.day.format("%d"); }
if (id == 868) { return c.greg.month.format("%d"); }
if (id == 859) { return isoWeek(c.greg).format("%d"); }
if (id == 855) { return c.greg.day.format("%d"); } // 日
if (id == 868) { return c.greg.month.format("%d"); } // 月份数字
if (id == 859) { return isoWeek(c.greg).format("%d"); } // 周数 (ISO)
// 周数
if (id == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); }
if (id == 870) { return dow(c.greg); }
if (id == 856) { return dow(c.greg); }
if (id == 853) { return month(c.greg); }
if (id == 852) { return monthDay(c.greg); }
if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); }
if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); }
if (id == 858) { return meridiem(c.greg); }
// 秒数要动起来依赖设备支持 onPartialUpdate
// view 只在有格子选了秒时才去请求那个回调。
if (id == 850) { return c.greg.sec.format("%d"); } // 秒
if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零)
if (id == 870) { return dow(c.greg); } // 星期简写
if (id == 856) { return dow(c.greg); } // 星期全称
if (id == 853) { return month(c.greg); } // 月份
if (id == 852) { return monthDay(c.greg); } // 月 日
if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); } // 星期 日
if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } // 星期 月 日
if (id == 858) { return meridiem(c.greg); } // 上午/下午
return null;
}
// Steps, calories, distance, floors and active minutes.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 活动类 ----
// 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 7 天累计。
function activity(id as Number, c as Ctx) as String? {
var i = c.info();
if (id == 1) { return i == null ? DASH : num(i.steps); }
if (id == 14) { return i == null ? DASH : rounded(i.steps); }
if (id == 707) {
if (id == 1) { return i == null ? DASH : num(i.steps); } // 步数
if (id == 14) { return i == null ? DASH : rounded(i.steps); } // 步数 (取整)
if (id == 707) { // 剩余步数
if (i == null || i.steps == null || i.stepGoal == null) { return DASH; }
var left = i.stepGoal - i.steps;
return left > 0 ? left.format("%d") : "0";
}
if (id == 2) { return i == null ? DASH : num(i.calories); }
if (id == 603) {
if (id == 2) { return i == null ? DASH : num(i.calories); } // 卡路里
if (id == 603) { // 活动卡路里
if (i == null || i.calories == null) { return DASH; }
var p = c.profile();
if (p == null || p.weight == null || p.height == null) { return num(i.calories); }
return num(i.calories); // device reports total only
}
if (id == 150 || id == 152 || id == 154) {
if (id == 150 || id == 152 || id == 154) { // 活动分钟 / 中强度分钟 / 高强度分钟
if (i == null || !(i has :activeMinutesDay) || i.activeMinutesDay == null) {
return DASH;
}
var am = i.activeMinutesDay;
if (id == 152) { return num(am.moderate); }
if (id == 154) { return num(am.vigorous); }
if (id == 152) { return num(am.moderate); } // 中强度分钟
if (id == 154) { return num(am.vigorous); } // 高强度分钟
return num(am.total);
}
if (id == 151) {
if (id == 151) { // 本周活动分钟
if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) {
return DASH;
}
return num(i.activeMinutesWeek.total);
}
// 距离
if (id == 3) { return i == null ? DASH : dist(i.distance, c); }
// 上楼层数
if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); }
// Several ActivityMonitor.Info members compile everywhere but only
// exist on some devices and firmware -- the wheelchair counters in
// particular threw "Symbol Not Found" on a fenix 8. Check before use.
if (id == 5) {
// ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有
// 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛
// "Symbol Not Found",用户一选这个字段表盘就崩。
// 所以凡是非通用成员,用之前一律先 has 检查。
if (id == 5) { // 下楼层数
return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended);
}
if (id == 6) {
if (id == 6) { // 爬升高度
return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c);
}
if (id == 9) {
if (id == 9) { // 推行距离
return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c);
}
if (id == 10) {
if (id == 10) { // 推行次数
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
}
if (id == 8 || id == 15) {
if (id == 219 || id == 211 || id == 213 || id == 203) { // 本周步数 / 本周卡路里 / 本周距离 / 本周楼层
var which = (id == 219) ? 0 : ((id == 211) ? 1 : ((id == 213) ? 2 : 3));
var back = daysIntoWeek(c.greg);
var v = sumHistory(c, which, back);
if (v == null) { return DASH; }
return (id == 213) ? dist(v, c) : rounded(v);
}
// 近 7 天步数 / 近 7 天卡路里 / 近 7 天距离 / 近 7 天楼层 / 近 7 天活动分钟
if (id == 220 || id == 212 || id == 214 || id == 204 || id == 200) {
var which = (id == 220) ? 0 : ((id == 212) ? 1
: ((id == 214) ? 2 : ((id == 204) ? 3 : 4)));
var v = sumHistory(c, which, 6);
if (v == null) { return DASH; }
return (id == 214) ? dist(v, c) : rounded(v);
}
if (id == 8 || id == 15) { // 活动条 / 活动条 (反向)
if (i == null || i.moveBarLevel == null) { return DASH; }
var lvl = i.moveBarLevel;
// 活动条 (反向)
if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; }
return lvl.format("%d");
}
if (id == 13) {
if (id == 13) { // 恢复时间
return (i == null || !(i has :timeToRecovery)) ? DASH
: num(i.timeToRecovery);
}
if (id == 11) {
if (id == 11) { // 呼吸频率
return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate);
}
if (id == 12) {
if (id == 12) { // 压力指数
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
}
return null;
}
// Heart, stress, oxygen, body battery and profile-derived values.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 身体类 ----
// 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。
function body(id as Number, c as Ctx) as String? {
if (id == 618) {
if (id == 618) { // 心率
var a = c.act();
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
var h = ActivityMonitor.getHeartRateHistory(1, true);
@@ -390,34 +495,34 @@ module Fields {
}
return DASH;
}
if (id == 276) {
if (id == 276) { // 静息心率
var p = c.profile();
return (p == null || !(p has :restingHeartRate)) ? DASH
: num(p.restingHeartRate);
}
if (id == 635) {
if (id == 635) { // 血氧
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1})));
}
if (id == 623) {
if (id == 623) { // 身体电量
if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; }
return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1})));
}
if (id == 281 || id == 742) {
if (id == 281 || id == 742) { // 体重 / BMI
var p = c.profile();
if (p == null || !(p has :weight) || p.weight == null) { return DASH; }
var kg = p.weight.toFloat() / 1000.0;
if (id == 281) {
if (id == 281) { // 体重
return (c.metric ? kg : kg * 2.20462).format("%.1f");
}
if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; }
var m = p.height.toFloat() / 100.0;
return (kg / (m * m)).format("%.1f");
}
if (id == 277 || id == 278) {
if (id == 277 || id == 278) { // 最大摄氧量 (骑) / 最大摄氧量 (跑)
var p = c.profile();
if (p == null) { return DASH; }
if (id == 278) {
if (id == 278) { // 最大摄氧量 (跑)
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
}
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
@@ -426,84 +531,87 @@ module Fields {
return null;
}
// Battery, alarms, notifications and radio state.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 系统类 ----
// 电量、闹钟、通知、勿扰、蓝牙、太阳能强度。
function system(id as Number, c as Ctx) as String? {
var st = c.stats();
if (id == 250) {
if (id == 250) { // 电量
return st == null || st.battery == null ? DASH
: st.battery.format("%d") + "%";
}
if (id == 251) {
if (id == 251) { // 剩余天数
if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) {
return DASH;
}
return st.batteryInDays.format("%d") + "d";
}
if (id == 257) {
if (id == 257) { // 电量 / 天数
var pct = (st == null || st.battery == null) ? DASH : st.battery.format("%d");
var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null)
? DASH : st.batteryInDays.format("%d");
return pct + "/" + d;
}
if (id == 252) {
if (id == 252) { // 太阳能强度
if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) {
return DASH;
}
return st.solarIntensity.format("%d") + "%";
}
var ds = c.ds();
// 闹钟
if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); }
// 通知
if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); }
if (id == 254) {
if (id == 254) { // 勿扰模式
return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF");
}
if (id == 256) {
if (id == 256) { // 蓝牙
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
}
return null;
}
// Altitude, pressure, sun events and moon phase.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- 环境类 ----
// 海拔、气压、各类太阳事件(含晨昏蒙影与黄金/蓝调时刻)、月相。
function environment(id as Number, c as Ctx) as String? {
if (id == 615) {
if (id == 615) { // 海拔
var a = c.act();
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
}
if (id == 616 || id == 733) {
if (id == 616 || id == 733) { // 海平面气压 / 环境气压
var a = c.act();
if (a == null) { return DASH; }
var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) {
if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压
pa = a.meanSeaLevelPressure;
} else if (id == 733 && (a has :ambientPressure)) {
} else if (id == 733 && (a has :ambientPressure)) { // 环境气压
pa = a.ambientPressure;
}
if (pa == null) { return DASH; }
return (pa.toFloat() / 100.0).format("%d");
}
if (id == 712 || id == 717 || id == 700 || id == 731) {
if (id == 712 || id == 717 || id == 700 || id == 731) { // 日出 / 日落 / 下一太阳事件 / 距太阳事件
var loc = c.where();
if (loc == null) { return DASH; }
var now = Time.now();
var rise = Weather.getSunrise(loc, now);
var set = Weather.getSunset(loc, now);
if (id == 712) { return clock(rise, c); }
if (id == 717) { return clock(set, c); }
if (id == 712) { return clock(rise, c); } // 日出
if (id == 717) { return clock(set, c); } // 日落
if (rise == null || set == null) { return DASH; }
var next = (now.compare(rise) < 0) ? rise
: ((now.compare(set) < 0) ? set : rise);
if (id == 700) { return clock(next, c); }
if (id == 700) { return clock(next, c); } // 下一太阳事件
var secs = next.compare(now);
if (secs < 0) { secs = secs + 86400; }
return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d");
}
if (id == 706) { return moonAge(c.greg).format("%.1f"); }
if (id == 732) {
if (Solar.owns(id)) {
return clock(Solar.byId(id, c.where()), c);
}
if (id == 706) { return moonAge(c.greg).format("%.1f"); } // 月龄
if (id == 732) { // 月球照明
var age = moonAge(c.greg);
var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0;
return (lit * 100.0).format("%d") + "%";
@@ -512,66 +620,170 @@ module Fields {
return null;
}
// Garmin's cached conditions and today's forecast.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- Garmin 原生天气 ----
// 手表同步下来的当前天气与今日预报。与 OWM 那套完全独立。
function weather(id as Number, c as Ctx) as String? {
var w = c.wx();
// 气温
if (id == 306) { return w == null ? DASH : temp(w.temperature, c); }
if (id == 300) {
if (id == 300) { // 体感温度
return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c);
}
if (id == 305) {
if (id == 305) { // 湿度
return (w == null || !(w has :relativeHumidity)) ? DASH
: num(w.relativeHumidity) + "%";
}
if (id == 304) {
if (id == 304) { // 降水概率
if (w == null || !(w has :precipitationChance)) { return DASH; }
return num(w.precipitationChance) + "%";
}
if (id == 308) {
if (id == 308) { // 风速
if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; }
var kmh = w.windSpeed.toFloat() * 3.6;
return (c.metric ? kmh : kmh * 0.621371).format("%d");
}
if (id == 307) {
if (id == 307) { // 风向角
return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
}
if (id == 310) {
if (id == 310) { // 风向
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
}
if (id == 309) {
if (id == 309) { // 天气更新时间
if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH;
}
return clock(w.observationTime, c);
}
if (id == 301 || id == 302 || id == 639) {
if (id == 312) { // 海平面气压
if (w == null || !(w has :pressure) || w.pressure == null) { return DASH; }
return (w.pressure.toFloat() / 100.0).format("%d");
}
if (id == 313) { // 紫外线指数
return (w == null || !(w has :uvIndex)) ? DASH : num(w.uvIndex);
}
if (id == 314) { // 能见度
if (w == null || !(w has :visibility) || w.visibility == null) { return DASH; }
var km = w.visibility.toFloat() / 1000.0;
return (c.metric ? km : km * 0.621371).format("%.1f");
}
if (id == 315) { // 露点
// CIQ 没有露点字段,用 Magnus 公式从温度和湿度反推,
// 误差在零点几度以内,够用。
if (w == null || w.temperature == null
|| !(w has :relativeHumidity) || w.relativeHumidity == null) {
return DASH;
}
var t = w.temperature.toFloat();
var rh = w.relativeHumidity.toFloat();
if (rh <= 0.0) { return DASH; }
var g2 = (17.62 * t) / (243.12 + t) + Math.ln(rh / 100.0);
return temp(243.12 * g2 / (17.62 - g2), c);
}
if (id == 301 || id == 302 || id == 639) { // 最高气温 / 最低气温 / 最高/最低气温
var f = c.forecast();
if (f == null) { return DASH; }
var hi = (f has :highTemperature) ? f.highTemperature : null;
var lo = (f has :lowTemperature) ? f.lowTemperature : null;
if (id == 301) { return temp(hi, c); }
if (id == 302) { return temp(lo, c); }
if (id == 301) { return temp(hi, c); } // 最高气温
if (id == 302) { return temp(lo, c); } // 最低气温
return temp(hi, c) + "/" + temp(lo, c);
}
return null;
}
// Free text the wearer typed into the settings.
// Returns null when the id is not one of ours, so value()
// can try the next category.
// ---- OpenWeatherMap ----
// 读后台服务上次存下来的数据(本函数不联网,见 Owm.mc / Fenix8V3Background.mc
// 后台一律用公制请求,单位换算和其他字段一样在这里做。
function owm(id as Number, c as Ctx) as String? {
if (!Owm.owns(id)) { return null; }
if (!Owm.enabled()) { return DASH; }
if (id == 500) { return temp(Owm.get("temp"), c); } // OWM 气温
if (id == 503) { return temp(Owm.get("feels"), c); } // OWM 体感温度
if (id == 504) { return temp(Owm.get("tmax"), c); } // OWM 最高温
if (id == 505) { return temp(Owm.get("tmin"), c); } // OWM 最低温
if (id == 515) { // OWM 最高/最低
return temp(Owm.get("tmax"), c) + "/" + temp(Owm.get("tmin"), c);
}
if (id == 507) { return str(Owm.get("short")); } // OWM 天气
if (id == 502) { return str(Owm.get("long")); } // OWM 天气详述
if (id == 501) { // OWM 湿度
var h = Owm.get("humidity");
return h == null ? DASH : num(h) + "%";
}
if (id == 506) { return num(Owm.get("pressure")); } // OWM 气压
if (id == 509) { // OWM 能见度
var v = Owm.get("visibility");
if (v == null) { return DASH; }
var km = v.toFloat() / 1000.0;
return (c.metric ? km : km * 0.621371).format("%.1f");
}
if (id == 510 || id == 512) { // OWM 风速 / OWM 阵风
var w = Owm.get(id == 510 ? "wind" : "gust");
if (w == null) { return DASH; }
var kmh = w.toFloat() * 3.6;
return (c.metric ? kmh : kmh * 0.621371).format("%d");
}
if (id == 511) { return num(Owm.get("winddeg")); } // OWM 风向角
if (id == 518) { return windDir(Owm.get("winddeg")); } // OWM 风向
if (id == 513) { // OWM 1h 降雨
var r = Owm.get("rain");
return r == null ? "0" : r.toFloat().format("%.1f");
}
if (id == 532) { // OWM 1h 降雪
var sn = Owm.get("snow");
return sn == null ? "0" : sn.toFloat().format("%.1f");
}
if (id == 536) { // OWM 云量
var cl = Owm.get("clouds");
return cl == null ? DASH : num(cl) + "%";
}
if (id == 537) { // OWM 降水概率
var pop = Owm.get("pop");
return pop == null ? DASH : (pop.toFloat() * 100.0).format("%d") + "%";
}
if (id == 530) { // OWM 露点
// OWM 免费接口不返回露点,同样用 Magnus 公式从温湿度算。
var t = Owm.get("temp");
var rh = Owm.get("humidity");
if (t == null || rh == null || rh.toFloat() <= 0.0) { return DASH; }
var tf = t.toFloat();
var gg = (17.62 * tf) / (243.12 + tf) + Math.ln(rh.toFloat() / 100.0);
return temp(243.12 * gg / (17.62 - gg), c);
}
if (id == 534) { return temp(Owm.get("hi1"), c); } // OWM 明日气温
if (id == 535) { return temp(Owm.get("hi2"), c); } // OWM 后日气温
if (id == 522) { // OWM 明日高低温
return temp(Owm.get("hi1"), c) + "/" + temp(Owm.get("lo1"), c);
}
if (id == 523) { // OWM 后日高低温
return temp(Owm.get("hi2"), c) + "/" + temp(Owm.get("lo2"), c);
}
if (id == 516) { return str(Owm.get("city")); } // OWM 城市
if (id == 517) { // OWM 更新时间
var dt = Owm.get("dt");
return dt == null ? DASH : clock(new Time.Moment(dt as Number), c);
}
return DASH;
}
function str(v) as String {
return v == null ? DASH : v.toString();
}
// ---- 自定义文字 ----
// 用户在设置里自己填的三段文字。
function custom(id as Number, c as Ctx) as String? {
if (id == 703) { return Settings.text("Custom1"); }
if (id == 704) { return Settings.text("Custom2"); }
if (id == 705) { return Settings.text("Custom3"); }
if (id == 703) { return Settings.text("Custom1"); } // 自定义文字 1
if (id == 704) { return Settings.text("Custom2"); } // 自定义文字 2
if (id == 705) { return Settings.text("Custom3"); } // 自定义文字 3
return null;
}
// Text for one data slot. Categories are tried in turn rather
// than dispatched on an id range, because the ids interleave.
// 取某个字段的显示文本。
// 按分类依次尝试,而不是按编号区间派发 —— 编号是交错的1/2/3 活动、
// 8/11/12 身体、14 又是活动),区间派发行不通。
function value(id as Number, c as Ctx) as String {
var v = dateTime(id, c);
if (v == null) { v = activity(id, c); }
@@ -579,6 +791,7 @@ module Fields {
if (v == null) { v = system(id, c); }
if (v == null) { v = environment(id, c); }
if (v == null) { v = weather(id, c); }
if (v == null) { v = owm(id, c); }
if (v == null) { v = custom(id, c); }
return v == null ? "" : v;
}

View File

@@ -3,24 +3,34 @@ import Toybox.Graphics;
import Toybox.WatchUi;
import Layout;
// The glyphs that sit above the two top data slots, plus the battery and the
// bluetooth rune.
// ============================================================================
// 图标
// ----------------------------------------------------------------------------
// 顶部两个数据位上方的图标,加上电池和蓝牙符号。
//
// The comps contain three icons -- sunrise, weather, battery. The first two
// are traced outlines whose finest features are two or three design pixels
// across; rebuilding them from circles and lines looked right at 454 px and
// turned to mush at 280. They ship instead as bitmaps rasterised from the
// design paths once per screen width (tools/gen_icons.py) and are tinted with
// the theme's accent at draw time.
// 设计稿里只有三个图标:日出、天气、电池。
//
// Everything else -- steps, calories, stairs, heart -- has no counterpart in
// the comps, so those stay as primitives sized off the same design canvas.
// 前两个是位图描出来的矢量轮廓,最细的特征只有 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);
@@ -29,8 +39,9 @@ module Icons {
function ready() as Boolean { return mSunrise != null; }
// drawBitmap2 refuses a palettised source, which is why the generated
// drawables carry packingFormat="png" to keep their alpha channel.
// ⚠️ drawBitmap2 的 :tintColor 拒绝**调色板化**的位图,运行时会抛
// "Source must not use a color palette"。所以生成的 drawables.xml 里每个
// bitmap 都带 packingFormat="png",保住 alpha 通道不被压成调色板。
function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void {
if (bmp == null) { return; }
dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2,
@@ -38,8 +49,9 @@ module Icons {
bmp, {:tintColor => col});
}
// Outline, charge bar and terminal. The bar tracks the real level, the
// way the comp draws it at 40%.
// 电池:外框 + 内部电量条 + 右侧触点。
// 电量条长度跟随真实电量(设计稿画的是 40% 的状态)。
// 画法是「先整块填充,再用黑色掏空内部,最后按比例填回电量条」。
function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
k as Float, col as Number, level as Float) as Void {
var x0 = Layout.px(x);
@@ -62,7 +74,7 @@ module Icons {
}
}
// The standard bluetooth rune, drawn as one polyline.
// 标准蓝牙符号,用一条折线一笔画成(六段,中间的竖线会被走两次)。
function bluetooth(dc as Graphics.Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.2));
@@ -79,8 +91,9 @@ module Icons {
Layout.line(dc, r, qt, l, qb);
}
// Which glyph, if any, belongs above a given field id. Fields with no
// natural icon -- dates, week numbers, custom text -- read fine bare.
// 按字段编号决定上方画哪个图标。
// 日期、周数、自定义文字这类字段没有合适的图标,就不画 —— 光有数值也读得懂,
// 硬配一个反而干扰。
function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric,
col as Number, level as Float) as Void {
if (id == 712 || id == 700) {
@@ -105,25 +118,25 @@ module Icons {
var cyd = Layout.ICON_CY;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.0));
if (id == 2 || id == 603) { // calories: flame
if (id == 2 || id == 603) { // 卡路里:火焰
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.72), Layout.px(cyd + r * 0.25)],
[Layout.px(xDesign + r * 0.40), Layout.px(cyd + r * 0.85)],
[Layout.px(xDesign - r * 0.40), Layout.px(cyd + r * 0.85)],
[Layout.px(xDesign - r * 0.72), Layout.px(cyd + r * 0.25)]]);
} else if (id == 1 || id == 14 || id == 707) { // steps: two prints
} else if (id == 1 || id == 14 || id == 707) { // 步数:两个脚印
dc.fillRoundedRectangle(Layout.px(xDesign - r * 0.85), Layout.px(cyd - r * 0.75),
Layout.px(r * 0.62), Layout.px(r * 1.15),
Layout.px(r * 0.3));
dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35),
Layout.px(r * 0.62), Layout.px(r * 1.15),
Layout.px(r * 0.3));
} else if (id == 3 || id == 9 || id == 615) { // distance: arrow
} else if (id == 3 || id == 9 || id == 615) { // 距离/海拔:箭头
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.35)],
[Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]);
} else if (id == 618 || id == 276 || id == 623) { // heart
} else if (id == 618 || id == 276 || id == 623) { // 心率/静息/体能:心形
dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28),
Layout.px(r * 0.44));
dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28),
@@ -131,7 +144,7 @@ module Icons {
dc.fillPolygon([[Layout.px(xDesign - r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign + r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]);
} else if (id == 4 || id == 5 || id == 6) { // floors: stairs
} else if (id == 4 || id == 5 || id == 6) { // 楼层:台阶
for (var i = 0; i < 3; i++) {
dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i),
Layout.px(cyd + r * 0.6 - r * 0.55 * i),

View File

@@ -2,52 +2,57 @@ import Toybox.Lang;
import Toybox.Graphics;
import Toybox.Math;
// Geometry and text plumbing for the face.
// ============================================================================
// 布局与文字绘制
// ----------------------------------------------------------------------------
// 本文件里所有的数字都是**设计稿 500×500 画布上的坐标**,全部来自
// design/watchface-*.svg 的实测:
// - 进度环的几何参数取自 design/rebuild.py原作者从位图逆向出的常量
// - 各元素位置取自 SVG 描边路径的墨迹包围盒,不是目测的
//
// Every literal here is a coordinate on the design's 500x500 canvas, measured
// from design/watchface-*.svg -- ring numbers come from design/rebuild.py, the
// element positions are the ink bounding boxes of the traced SVG paths. At
// draw time everything is multiplied by s = screenWidth / 500, so one set of
// numbers serves screens from 240 px (fenix 7S) to 454 px (venu 3).
// 绘制时统一乘以 s = 屏宽 / 500所以同一套数字可以覆盖
// 240pxfenix 7S到 454pxvenu 3全部 17 款设备。
//
// Call init() from the view's onLayout before anything else touches this.
// ⚠️ 使用前必须先由 view onLayout 调用一次 init()。
// ============================================================================
module Layout {
// ---------------------------------------------------------------- ring
// Ticks are annular sectors of constant angular width, not radial lines,
// which is why they are drawn as four-point polygons rather than strokes.
const R_IN = 231.09; // inner edge of the tick band
const R_OUT = 249.10; // outer edge
const TOP_N = 21; // ticks per top arc
const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12
// ------------------------------- 进度环 -------------------------------
// 刻度是**等角宽的环形扇区**,不是从圆心发射的线段 —— 所以画的时候用四点
// 多边形填充,而不是 drawLine 描边。这点很关键:用线段画会导致刻度在内圈
// 挤、外圈疏,与设计稿对不上。
const R_IN = 231.09; // 刻度带内边缘半径
const R_OUT = 249.10; // 刻度带外边缘半径
const TOP_N = 21; // 每条顶弧的刻度数
const TOP_PH = 6.500; // 第 0 根刻度的角度(自 12 点起顺时针,度)
const TOP_PITCH = 4.000;
const TOP_HW = 1.554; // half of the 3.108 deg tick width
const BOT_N = 21; // ticks per bottom arc
const BOT_PH = 6.005; // measured clockwise from 6 o'clock
const TOP_HW = 1.554; // 刻度角宽 3.108° 的一半
const BOT_N = 21; // 每条底弧的刻度数
const BOT_PH = 6.005; // 底弧角度自 6 点起量
const BOT_PITCH = 3.000;
const BOT_HW = 1.043;
const DOT_RAD = 240.10; // anchor dots sit on the band's mid-radius
const DOT_RAD = 240.10; // 四个锚点圆点所在半径(正好是刻度带中线)
const DOT_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------------------------------------- elements
const BATT_X = 192.70; // battery body, left edge
const BATT_W = 26.20; // body only; the terminal sits to its right
// ------------------------------- 各元素 -------------------------------
const BATT_X = 192.70; // 电池外框左边缘
const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算
const BATT_H = 17.81;
const BATT_WALL = 2.20; // outline thickness
const BATT_TW = 2.53; // terminal
const BATT_WALL = 2.20; // 外框线宽
const BATT_TW = 2.53; // 右侧触点宽
const BATT_TH = 9.20;
const BATT_VX = 232.00; // "40%" left edge
const BATT_VX = 232.00; // 百分比文字左边缘
const BATT_CY = 53.20;
const LEFT_X = 157.00; // top data slots, centred
const LEFT_X = 157.00; // 顶部两个数据位的中心 x居中对齐
const RIGHT_X = 343.70;
const ICON_CY = 89.50; // matches the icon bitmaps' own centre
const ICON_CY = 89.50; // 与图标位图自身的中心对齐,别随意改
const VALUE_CY = 131.06;
const TIME_CY = 221.10;
const HOURS_R = 211.40; // hour digits, right edge
const MINUTES_L = 276.02; // minute digits, left edge
const HOURS_R = 211.40; // 小时数字右边缘(右对齐基准)
const MINUTES_L = 276.02; // 分钟数字左边缘(左对齐基准)
const COLON_X = 237.88;
const COLON_W = 24.24;
const COLON_H = 21.30;
@@ -57,7 +62,7 @@ module Layout {
const BAND_Y = 286.30;
const BAND_H = 44.19;
const BAND_CY = 308.10;
const BT_CX = 36.88; // bluetooth rune
const BT_CX = 36.88; // 蓝牙符号中心
const BT_CY = 306.99;
const BT_HW = 8.89;
const BT_HH = 12.92;
@@ -67,30 +72,37 @@ module Layout {
const BOT_VAL_CY = 366.76;
const BOT_LAB_CY = 410.88;
const DIST_R = 234.30; // right edge of both left-hand bottom lines
const STEP_L = 267.40; // left edge of both right-hand bottom lines
const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准
const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准
// Cap heights of the design's three text sizes.
const CAP_BAND = 25.34; // date band, battery percentage
const CAP_COMP = 31.50; // data slots, bottom row
const CAP_TIME = 98.70; // time digits
// 设计稿里三档文字的**字面高度**cap height大写字母/数字的高度,
// 不含上下留白)。字号选择就是按这个来匹配的。
const CAP_BAND = 25.34; // 日期带、电量百分比
const CAP_COMP = 31.50; // 四个数据位、底部行
const CAP_TIME = 98.70; // 时间大数字
// How much room a value may take before it is stepped down a size. The
// top slots are centred, so this is the full width they may span; the
// bottom pair are edge-aligned and share the middle of the dial.
// 数值最多占多宽,超了就降一档字号。
// 顶部两格是居中对齐,这个值是它能横跨的总宽度;
// 底部两格是边对齐,各占表盘中间往外的一半。
// 有了这个,用户就算把 6 个汉字的长日期放进底部格也不会压到进度环上。
const TOP_MAX_W = 185.0;
const BOT_MAX_W = 150.0;
// Connect IQ exposes ascent and descent but not cap height, and the ascent
// reserves room for diacritics that no digit or capital ever uses. These
// two ratios were measured by rendering into the simulator and reading the
// ink back off the screenshot (fenix847mm: FONT_XTINY ascent 29 -> caps
// 22 px; FONT_NUMBER_MILD ascent 82 -> digits 58 px).
// ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight**没有字面高度接口**。
// 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent
// 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有
// 设计稿的 57%)。
//
// 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。
// 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;
// --------------------------------------------------------------- state
var scale = 1.0; // device px per design px
// ------------------------------- 运行时状态 ----------------------------
var scale = 1.0; // 设备像素 / 设计像素
var width = 0;
var height = 0;
var cx = 0;
@@ -99,8 +111,7 @@ module Layout {
var fontComp = Graphics.FONT_MEDIUM;
var fontTime = Graphics.FONT_NUMBER_HOT;
// Text sizes in ascending order, so a value that will not fit can walk
// down the list until it does.
// 文字字号按从小到大排列,这样放不下的值可以沿着这张表往下逐档缩。
var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
@@ -119,7 +130,7 @@ module Layout {
function ready() as Boolean { return width > 0; }
// Design pixels to device pixels.
// 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。
function px(v as Numeric) as Number {
return Math.round(v * scale).toNumber();
}
@@ -128,8 +139,8 @@ module Layout {
return Graphics.getFontAscent(font) * ratio;
}
// Closest cap height wins -- not "largest that still fits", which throws
// away a whole size whenever the design falls between two fonts.
// 选**字面高度最接近**目标的那一档,而不是「不超过目标的最大档」。
// 后者在设计尺寸恰好卡在两档字体之间时会白白小一号。
function pickFont(cands as Array<Graphics.FontDefinition>, targetCap as Float,
ratio as Float) as Graphics.FontDefinition {
var best = cands[0];
@@ -142,8 +153,8 @@ module Layout {
return best;
}
// A long value -- twelve characters of custom text, say -- would run off
// the dial at the design's size. Step down until it fits the slot.
// 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会
// 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。
function fitFont(dc as Graphics.Dc, str as String,
font as Graphics.FontDefinition, maxDesignW as Float)
as Graphics.FontDefinition {
@@ -154,7 +165,7 @@ module Layout {
if (textFonts[i] == f) { break; }
i--;
}
if (i < 0) { return f; } // not one of the text fonts
if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它
while (i >= 0) {
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
return textFonts[i];
@@ -164,9 +175,9 @@ module Layout {
return textFonts[0];
}
// TEXT_JUSTIFY_VCENTER centres the font box, but capitals occupy only the
// band from the baseline up by capHeight, so the ink sits high in that box.
// Push the draw down by the difference to land the ink on cy.
// TEXT_JUSTIFY_VCENTER 居中的是**字体盒子**,而大写字母/数字只占据
// 「基线往上 capHeight」这一段在盒子里是偏上的。
// 所以要往下推一个差值,才能让实际墨迹的中心落在 cy 上。
function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition,
cyDesign as Numeric, ratio as Float) as Number {
var fh = dc.getFontHeight(font);
@@ -182,17 +193,18 @@ module Layout {
just | Graphics.TEXT_JUSTIFY_VCENTER);
}
// Same, but shrinks the font if the string is too wide for its slot.
// 同上,但会在字符串超出槽位宽度时自动降字号。
function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String,
just as Number, col as Number, maxDesignW as Float) as Void {
text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col);
}
// Monkey C cannot fill glyphs with a gradient, so the string is drawn once
// per stop with dc.setClip holding each pass to its own horizontal band.
// The first and last band are extended off-glyph so no sliver of the
// digits is ever left undrawn.
// Monkey C 没法给文字填渐变。这里的做法是:**同一串数字画 N 遍**,每遍用
// dc.setClip 把绘制限制在一条横向色带内,色带颜色取自主题的渐变停止点。
//
// 首尾两条色带故意向外延伸到屏幕边缘,保证字形上下不会有没画到的缝隙
// (字体实际墨迹高度和我们估算的 capHeight 总有几像素出入)。
function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String,
just as Number, ramp as Array<Number>) as Void {
@@ -213,7 +225,7 @@ module Layout {
dc.clearClip();
}
// Design angles run clockwise from 12 o'clock; screen y grows downward.
// 设计稿的角度自 12 点起顺时针为正;屏幕 y 轴向下为正,所以是 cy - cos。
function polarX(ang as Float, r as Float) as Number {
return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber();
}
@@ -227,7 +239,7 @@ module Layout {
dc.drawLine(px(x1), px(y1), px(x2), px(y2));
}
// Pen widths round to zero on small screens; never let one vanish.
// 线宽在小屏上会被 round 成 0 直接消失,这里兜一个最小值 2。
function pen(v as Numeric) as Number {
var w = px(v);
return w < 2 ? 2 : w;

68
source/Owm.mc Normal file
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;
}
}

View File

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

120
source/Solar.mc Normal file
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,8 +1,17 @@
// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg.
// Do not edit by hand -- change the design kit and re-run the generator.
// ⚠️ 本文件由 tools/gen_themes.py design/watchface-*.svg 自动生成,请勿手改。
// 要改配色就改设计稿里的 SVG然后重新跑一遍生成器。
//
// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock
// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom
// 七套主题的全部颜色数据。各数组的下标就是主题序号0=Ember … 6=Kelp
//
// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。
// 下标 0 是最靠近 12 点 / 6 点的那根。
// 这是从设计稿里**逐根取样**的实测值,不是插值近似。
// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。
// TICKOFF 未点亮刻度的颜色
// ANCHORDOT 四个锚点圆点
// BANDFILL/BANDTEXT 日期带的底色 / 文字色
// ACCENT 图标与底部标签
// TEXTPRIMARY 各项数值
import Toybox.Lang;
module Themes {