接入系统 Complication,补齐倒计时/自定义格式/28天累计/空气质量

字段 125 → 156 项。

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:03:21 +08:00
parent 4d96ab0912
commit d9d45d983b
14 changed files with 651 additions and 19 deletions

View File

@@ -15,6 +15,7 @@ import FieldTable;
import Settings;
import Solar;
import Owm;
import Comp;
// ============================================================================
// 数据字段
@@ -80,6 +81,89 @@ module Fields {
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
}
// 按用户填的格式串拼时间/日期。
//
// 用单字母占位符,因为设置项里能填的长度有限,而且这样中英文都好写:
// h 小时12/24 跟随手表) H 小时补零
// m 分钟补零 s 秒补零
// a 上午/下午24 小时制下为空)
// D 日 M 月(数字) N 月(名称)
// Y 四位年 y 两位年 w 星期
// 其它字符原样输出,所以 "D/M" → "10/9""w N D" → "周四 9月 10"。
function applyFormat(fmt as String, g as Gregorian.Info, c as Ctx) as String {
var out = "";
var chars = fmt.toCharArray();
for (var i = 0; i < chars.size(); i++) {
var ch = chars[i];
if (ch == 'h') {
var hh = g.hour;
if (!c.is24) {
if (hh == 0) { hh = 12; } else if (hh > 12) { hh = hh - 12; }
}
out += hh.format("%d");
} else if (ch == 'H') {
out += g.hour.format("%02d");
} else if (ch == 'm') {
out += g.min.format("%02d");
} else if (ch == 's') {
out += g.sec.format("%02d");
} else if (ch == 'a') {
out += c.is24 ? "" : meridiem(g);
} else if (ch == 'D') {
out += g.day.format("%d");
} else if (ch == 'M') {
out += g.month.format("%d");
} else if (ch == 'N') {
out += month(g);
} else if (ch == 'Y') {
out += g.year.format("%d");
} else if (ch == 'y') {
out += (g.year % 100).format("%02d");
} else if (ch == 'w') {
out += dow(g);
} else {
out += ch.toString();
}
}
return out;
}
// 距目标日期还有几天。目标日期是设置里填的 YYYY-MM-DD 字符串。
// 解析失败(没填/格式不对)返回 null字段显示 "--"。
function daysUntil(target as String, g as Gregorian.Info) as Number? {
if (target.length() < 8) { return null; }
var parts = splitOn(target, '-');
if (parts.size() < 3) { return null; }
var y = parts[0].toNumber();
var mo = parts[1].toNumber();
var d = parts[2].toNumber();
if (y == null || mo == null || d == null) { return null; }
// 都换算成「儒略日式」的绝对天数再相减,省得处理跨月跨年。
return absDay(y, mo, d) - absDay(g.year, g.month, g.day);
}
function absDay(y as Number, m as Number, d as Number) as Number {
var yy = y;
var mm = m;
if (mm <= 2) { yy = yy - 1; mm = mm + 12; }
var a = yy / 100;
var b = 2 - a + a / 4;
return (365.25 * (yy + 4716)).toNumber() + (30.6001 * (mm + 1)).toNumber()
+ d + b - 1524;
}
function splitOn(str as String, sep as Char) as Array<String> {
var out = [] as Array<String>;
var cur = "";
var chars = str.toCharArray();
for (var i = 0; i < chars.size(); i++) {
if (chars[i] == sep) { out.add(cur); cur = ""; }
else { cur += chars[i].toString(); }
}
out.add(cur);
return out;
}
// 英文出 "10",中文出 "10日"。
// 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
function dayOnly(g as Gregorian.Info) as String {
@@ -375,6 +459,15 @@ module Fields {
// view 只在有格子选了秒时才去请求那个回调。
if (id == 850) { return c.greg.sec.format("%d"); } // 秒
if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零)
// 自定义格式:格式串来自设置,占位符说明见 applyFormat
if (id == 861) { return applyFormat(Settings.text("TimeFmt"), c.greg, c); }
if (id == 862) { return applyFormat(Settings.text("DateFmt1"), c.greg, c); }
if (id == 863) { return applyFormat(Settings.text("DateFmt2"), c.greg, c); }
if (id == 735) { // 事件倒计时
var left = daysUntil(Settings.text("CountdownDate"), c.greg);
if (left == null) { return DASH; }
return left.format("%d");
} // 秒 (前导零)
if (id == 870) { return dow(c.greg); } // 星期简写
if (id == 856) { return dow(c.greg); } // 星期全称
if (id == 853) { return month(c.greg); } // 月份
@@ -450,6 +543,14 @@ module Fields {
return (id == 213) ? dist(v, c) : rounded(v);
}
// 近 7 天步数 / 近 7 天卡路里 / 近 7 天距离 / 近 7 天楼层 / 近 7 天活动分钟
if (id == 828 || id == 832 || id == 836 || id == 840) {
// 近 28 天。getHistory 能返回多少天由固件决定sumHistory 内部会按
// 实际条数截断,所以拿不到 28 天时是「有多少算多少」而不是报错。
var w28 = (id == 828) ? 0 : ((id == 832) ? 1 : ((id == 836) ? 2 : 3));
var v28 = sumHistory(c, w28, 27);
if (v28 == null) { return DASH; }
return (id == 836) ? dist(v28, c) : rounded(v28);
}
if (id == 220 || id == 212 || id == 214 || id == 204 || id == 200) {
var which = (id == 220) ? 0 : ((id == 212) ? 1
: ((id == 214) ? 2 : ((id == 204) ? 3 : 4)));
@@ -760,6 +861,18 @@ module Fields {
if (id == 523) { // OWM 后日高低温
return temp(Owm.get("hi2"), c) + "/" + temp(Owm.get("lo2"), c);
}
if (id == 538 || id == 539) { // 空气质量指数 / 描述
var aqi = Owm.get("aqi");
if (aqi == null) { return DASH; }
if (id == 538) { return num(aqi); }
// OWM 的 AQI 是 1..5 的等级,不是欧美常见的 0..500 数值。
var names = ["", "优", "良", "中", "差", "很差"];
var k = aqi.toNumber();
return (k >= 1 && k <= 5) ? names[k] : DASH;
}
if (id == 540) { return oneDp(Owm.get("pm2_5")); } // PM2.5
if (id == 541) { return oneDp(Owm.get("pm10")); } // PM10
if (id == 542) { return oneDp(Owm.get("co")); } // 一氧化碳
if (id == 516) { return str(Owm.get("city")); } // OWM 城市
if (id == 517) { // OWM 更新时间
var dt = Owm.get("dt");
@@ -768,10 +881,21 @@ module Fields {
return DASH;
}
function oneDp(v) as String {
return v == null ? DASH : v.toFloat().format("%.1f");
}
function str(v) as String {
return v == null ? DASH : v.toString();
}
// ---- 系统 Complication ----
// 日历、训练状态、完赛/配速预测、按运动的周距离、以及第三方 App 发布的
// 复杂功能。全部由 Comp.mc 经 Toybox.Complications 取得。
function complication(id as Number, c as Ctx) as String? {
return Comp.value(id);
}
// ---- 自定义文字 ----
// 用户在设置里自己填的三段文字。
function custom(id as Number, c as Ctx) as String? {
@@ -791,6 +915,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 = complication(id, c); }
if (v == null) { v = owm(id, c); }
if (v == null) { v = custom(id, c); }
return v == null ? "" : v;