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:
@@ -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 取 c1,f=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;
|
||||
|
||||
Reference in New Issue
Block a user