import Toybox.Graphics; import Toybox.Lang; import Toybox.Math; import Toybox.ActivityMonitor; import Toybox.SensorHistory; import Toybox.System; import Toybox.Time; import Toybox.Time.Gregorian; import Toybox.WatchUi; import Themes; import Fields; import Layout; import Icons; import Settings; import Comp; // ============================================================================ // 表盘主体 // ---------------------------------------------------------------------------- // 负责:进度环、盖在环上的各个元素、以及 AMOLED 常亮时的简化版画面。 // // 分工约定(改代码前先看清楚该动哪个文件): // 位置尺寸 → Layout.mc // 数据取值 → Fields.mc // 配色 → Themes.mc(自动生成) // 设置读取 → Settings.mc // 图标 → Icons.mc // 留在本文件里的只有「按什么顺序画」。 // ============================================================================ class Fenix8V3View extends WatchUi.WatchFace { // 常亮显示状态。 // AMOLED 屏在息屏常亮时必须大幅减少点亮像素(Garmin 上架审核会卡这一条, // 而且不这么做既费电又有烧屏风险);MIP 屏不受影响,照常全量绘制。 private var mSleeping = false; private var mBurnIn = false; function initialize() { WatchFace.initialize(); } // 进入低功耗(息屏)。AMOLED 机型从这一刻起改画简化版面。 function onEnterSleep() as Void { mSleeping = true; WatchUi.requestUpdate(); } // 抬腕唤醒,恢复完整版面。 function onExitSleep() as Void { mSleeping = false; WatchUi.requestUpdate(); } // 表盘加载时调一次;如果 onUpdate 发现缓存失效(分辨率变了,或资源还没 // 加载好)会再调一次。所有「一次性、开销大」的准备工作都放这里。 function onLayout(dc as Dc) as Void { Layout.init(dc); Fields.loadStrings(); Icons.load(); var ds = System.getDeviceSettings(); mBurnIn = (ds != null) && (ds has :requiresBurnInProtection) && ds.requiresBurnInProtection; } // ------------------------------- 进度环 ------------------------------- // 计算四段弧各自的进度,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) { // 步数:对每日目标 if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) { p = info.steps.toFloat() / info.stepGoal.toFloat(); } } else if (idx == 1) { // 卡路里:固定 2000 kcal 为满 if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; } } else if (idx == 2) { // 距离:10 km 为满(distance 单位是厘米) if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; } } 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) { // 活动分钟:对**每周**目标 if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null && (info has :activeMinutesWeekGoal) && info.activeMinutesWeekGoal != null && info.activeMinutesWeekGoal > 0) { p = info.activeMinutesWeek.total.toFloat() / info.activeMinutesWeekGoal.toFloat(); } } else if (idx == 6) { // 身体电量:本身就是 0..100 var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1})); if (bb != null) { p = bb.toFloat() / 100.0; } } else if (idx == 4) { // ⚠️ 动动条是**整数等级**(0..MOVE_BAR_LEVEL_MAX,通常是 5), // 不是 0..1 的比例。初版直接当比例用,结果等级≥1 就被钳成满环, // 中间状态全丢了。 if (info.moveBarLevel != null) { var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX - ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat(); if (span > 0) { p = (info.moveBarLevel - ActivityMonitor.MOVE_BAR_LEVEL_MIN) / span; } } } if (p > 1.0f) { p = 1.0f; } if (p < 0.0f) { p = 0.0f; } return p; } // 画一根刻度:环形扇区,用四点多边形填充。 // 扇区两条弧边其实是曲线,但 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; var ro = Layout.R_OUT * Layout.scale; var ri = Layout.R_IN * Layout.scale; dc.fillPolygon([[Layout.polarX(a0, ro), Layout.polarY(a0, ro)], [Layout.polarX(a1, ro), Layout.polarY(a1, ro)], [Layout.polarX(a1, ri), Layout.polarY(a1, ri)], [Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]); } // 通用画弧。点亮方向与设计稿一致: // 两条顶弧:从 9 点 / 3 点那一端朝 12 点方向填充(fromEnd=true) // 两条底弧:从 6 点朝两侧填充(fromEnd=false) // mirror 用来把同一组角度镜像到左半边,这样左右弧共用一份角度表和配色表。 function arc(dc as Dc, ramp as Array, 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 { for (var i = 0; i < n; i++) { var a = phase + pitch * i; var on = fromEnd ? (i >= n - lit) : (i < lit); dc.setColor(on ? ramp[i] : off, Graphics.COLOR_TRANSPARENT); tick(dc, base + (mirror ? -a : a), hw); } } 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; var bot = Themes.RINGBOTTOM[ti] as Array; var off = Themes.TICKOFF[ti] as Number; arc(dc, top, off, (pTR * Layout.TOP_N).toNumber(), Layout.TOP_PH, Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, false, true); arc(dc, top, off, (pTL * Layout.TOP_N).toNumber(), Layout.TOP_PH, Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, true, true); arc(dc, bot, off, (pBL * Layout.BOT_N).toNumber(), Layout.BOT_PH, Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, false, false); arc(dc, bot, off, (pBR * Layout.BOT_N).toNumber(), Layout.BOT_PH, Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, true, false); // 四个锚点圆点,压在四段弧的接缝处。 dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT); var r = Layout.px(Layout.DOT_R); var rad = Layout.DOT_RAD * Layout.scale; for (var i = 0; i < Layout.DOT_ANG.size(); i++) { var a = Layout.DOT_ANG[i]; dc.fillCircle(Layout.polarX(a, rad), Layout.polarY(a, rad), r); } } // ------------------------------- 各元素 ------------------------------- // 顶部中央:电池图标 + 百分比。 function 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); Layout.text(dc, Layout.BATT_VX, Layout.BATT_CY, Layout.fontBand, (level * 100.0).toNumber().format("%d") + "%", 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; Icons.forField(dc, id, x, Themes.ACCENT[ti] as Number, level); Layout.fittedText(dc, x, Layout.VALUE_CY, Layout.fontComp, Fields.value(id, ctx), Graphics.TEXT_JUSTIFY_CENTER, Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W); } // 日期带:整条横幅底色 + 蓝牙符号 + 星期 + 月日 + 上午/下午。 // 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; // 手机断连时把蓝牙符号**压暗**而不是隐藏 —— 否则手机一走远日期带就 // 少一块,整条带子的视觉重心跟着变,很干扰。 Icons.bluetooth(dc, (ds != null && !ds.phoneConnected) ? blend(col, fill, 0.6) : col); // ⚠️ 必须用 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); Layout.text(dc, Layout.MD_CX, Layout.BAND_CY, Layout.fontBand, Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, col); if (ds == null || !ds.is24Hour) { Layout.text(dc, Layout.AMPM_R, Layout.BAND_CY, Layout.fontBand, Fields.meridiem(g), Graphics.TEXT_JUSTIFY_RIGHT, col); } } // 中央时间。小时和分钟各自带竖向渐变(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; if (ds == null || !ds.is24Hour) { if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; } } Layout.gradText(dc, Layout.HOURS_R, Layout.TIME_CY, Layout.fontTime, h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT, Themes.HOURS[ti] as Array); Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime, g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT, Themes.MINUTES[ti] as Array); dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT); dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y1), Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y2), 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; var left = Settings.bottomLeft(); var right = Settings.bottomRight(); Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_VAL_CY, Layout.fontComp, Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT, value, Layout.BOT_MAX_W); Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_LAB_CY, Layout.fontComp, Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT, label, Layout.BOT_MAX_W); Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_VAL_CY, Layout.fontComp, Fields.value(right, ctx), Graphics.TEXT_JUSTIFY_LEFT, value, Layout.BOT_MAX_W); Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_LAB_CY, Layout.fontComp, Fields.label(right), Graphics.TEXT_JUSTIFY_LEFT, label, Layout.BOT_MAX_W); } // ------------------------------- 颜色工具 ------------------------------ // 两色按比例混合,f=0 取 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; var b1 = c1 & 0xFF; var r = r1 + (((((c2 >> 16) & 0xFF) - r1) * f)).toNumber(); var g = g1 + (((((c2 >> 8) & 0xFF) - g1) * f)).toNumber(); var b = b1 + ((((c2 & 0xFF) - b1) * f)).toNumber(); return (r << 16) | (g << 8) | b; } // 整体压暗到 f 倍(常亮模式用)。 function dim(col as Number, f as Float) as Number { var r = (((col >> 16) & 0xFF) * f).toNumber(); var g = (((col >> 8) & 0xFF) * f).toNumber(); var b = ((col & 0xFF) * f).toNumber(); return (r << 16) | (g << 8) | b; } // ------------------------------- 常亮模式 ------------------------------ // 低功耗画面。相比正常画面砍掉的东西: // - 日期带底色(整条亮橙色横幅是最费电的元素,直接不画) // - 未点亮的刻度(只画有数据的那部分,且压暗到 45%) // // 关于点亮量:实测普通模式的平均亮度(AMOLED 功耗的合理代理)是 14.1%, // 常亮是 2.9%,约 1/5。曾经试过连进度环也砍掉,平均亮度只从 3.01% 降到 // 2.91% —— 0.1 个百分点换掉一个可见特征,不划算,所以环留着。 // (常按「点亮像素数」算会得到 10% 左右,但那个口径把每个抗锯齿边缘像素 // 都算成「亮」,跟实际功耗对不上。) // - 四个数据位、电池(全部隐藏) // 保留的时间和日期压暗到 55%。 // // 另外整个画面按分钟在 ±4 像素内游走,避免同一个像素长期点亮导致烧屏。 function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?, pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // ±4 设备像素,换算回设计像素 var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale; var top = Themes.RINGTOP[ti] as Array; var bot = Themes.RINGBOTTOM[ti] as Array; var litTR = (pTR * Layout.TOP_N).toNumber(); var litTL = (pTL * Layout.TOP_N).toNumber(); var litBR = (pBR * Layout.BOT_N).toNumber(); var litBL = (pBL * Layout.BOT_N).toNumber(); for (var i = 0; i < Layout.TOP_N; i++) { var a = Layout.TOP_PH + Layout.TOP_PITCH * i; if (i >= Layout.TOP_N - litTR) { dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); tick(dc, a, Layout.TOP_HW); } if (i >= Layout.TOP_N - litTL) { dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); tick(dc, -a, Layout.TOP_HW); } } for (var i = 0; i < Layout.BOT_N; i++) { var a = Layout.BOT_PH + Layout.BOT_PITCH * i; if (i < litBL) { dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); tick(dc, 180.0 + a, Layout.BOT_HW); } if (i < litBR) { dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); tick(dc, 180.0 - a, Layout.BOT_HW); } } var h = g.hour; if (ds == null || !ds.is24Hour) { if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; } } var hours = Themes.HOURS[ti] as Array; var mins = Themes.MINUTES[ti] as Array; Layout.text(dc, Layout.HOURS_R + ox, Layout.TIME_CY + oy, Layout.fontTime, h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT, dim(hours[hours.size() / 2], 0.55)); Layout.text(dc, Layout.MINUTES_L + ox, Layout.TIME_CY + oy, Layout.fontTime, g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT, dim(mins[mins.size() / 2], 0.55)); dc.setColor(dim(Themes.COLON[ti] as Number, 0.55), Graphics.COLOR_TRANSPARENT); dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y1 + oy), Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y2 + oy), Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); var band = dim(Themes.ACCENT[ti] as Number, 0.55); Layout.text(dc, Layout.DOW_CX + ox, Layout.BAND_CY + oy, Layout.fontBand, Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, band); Layout.text(dc, Layout.MD_CX + ox, Layout.BAND_CY + oy, Layout.fontBand, Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band); } // ------------------------------- 秒针刷新 ------------------------------ // 表盘正常只有每分钟一次的 onUpdate,秒数根本走不动。 // 这里实现 onPartialUpdate(系统每秒回调一次),但要非常克制: // - 只有当真的有数据位选了「秒」时才做事,否则直接返回 // - 只重绘那一格,用 setClip 把绘制限制在格子的包围盒内 // (每秒重绘整个表盘的功耗预算是不够的) // - 常亮的 AMOLED 机型直接跳过:那时数据位本来就不显示 function isSeconds(id as Number) as Boolean { return id == 850 || id == 869; } function wantsSeconds() as Boolean { if (Settings.showTop() && (isSeconds(Settings.leftTop()) || isSeconds(Settings.rightTop()))) { return true; } return Settings.showBottom() && (isSeconds(Settings.bottomLeft()) || isSeconds(Settings.bottomRight())); } // 重绘单个格子:先把它的包围盒涂黑,再把新值画回去。 function repaintSlot(dc as Dc, ti as Number, ctx as Fields.Ctx, id as Number, x as Numeric, cyDesign as Numeric, just as Number, maxW as Float, col as Number) as Void { var half = maxW / 2.0; var x0 = x - half; if (just == Graphics.TEXT_JUSTIFY_RIGHT) { x0 = x - maxW; } else if (just == Graphics.TEXT_JUSTIFY_LEFT) { x0 = x; } var h = Layout.CAP_COMP * 1.6; dc.setClip(Layout.px(x0), Layout.px(cyDesign - h / 2), Layout.px(maxW), Layout.px(h)); // 全屏涂黑。设计稿的底色就是纯黑,AMOLED 上黑像素也最省电。 dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); dc.clear(); Layout.fittedText(dc, x, cyDesign, Layout.fontComp, Fields.value(id, ctx), just, col, maxW); dc.clearClip(); } function onPartialUpdate(dc as Dc) as Void { if (mBurnIn || !Layout.ready() || !Fields.ready()) { return; } if (!wantsSeconds()) { return; } var ti = Settings.themeIndex(Themes.NAMES.size()); var ctx = new Fields.Ctx(System.getDeviceSettings(), Settings.tempUnit()); var col = Themes.TEXTPRIMARY[ti] as Number; if (Settings.showTop()) { if (isSeconds(Settings.leftTop())) { repaintSlot(dc, ti, ctx, Settings.leftTop(), Layout.LEFT_X, Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER, Layout.TOP_MAX_W, col); } if (isSeconds(Settings.rightTop())) { repaintSlot(dc, ti, ctx, Settings.rightTop(), Layout.RIGHT_X, Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER, Layout.TOP_MAX_W, col); } } if (Settings.showBottom()) { if (isSeconds(Settings.bottomLeft())) { repaintSlot(dc, ti, ctx, Settings.bottomLeft(), Layout.DIST_R, Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_RIGHT, Layout.BOT_MAX_W, col); } if (isSeconds(Settings.bottomRight())) { repaintSlot(dc, ti, ctx, Settings.bottomRight(), Layout.STEP_L, Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_LEFT, Layout.BOT_MAX_W, col); } } } // ------------------------------- 主绘制 -------------------------------- // 每分钟调用一次(以及唤醒、设置变更时)。 function onUpdate(dc as Dc) as Void { if (!Layout.ready() || Layout.width != dc.getWidth() || !Fields.ready() || !Icons.ready()) { onLayout(dc); } 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()); Comp.beginFrame(); // 让第三方 App 列表这一帧只枚举一次 var info = ctx.info(); var stats = ctx.stats(); var level = 0.0f; if (stats != null && stats.battery != null) { level = stats.battery / 100.0; } if (level > 1.0f) { level = 1.0f; } if (level < 0.0f) { level = 0.0f; } dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); dc.clear(); var pTR = ringProgress(Settings.ringTR(), info); var pTL = ringProgress(Settings.ringTL(), info); var pBR = ringProgress(Settings.ringBR(), info); var pBL = ringProgress(Settings.ringBL(), info); // 常亮模式走另一条绘制路径,画完直接返回。 if (mSleeping && mBurnIn) { drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL); return; } drawRing(dc, ti, pTR, pTL, pBR, pBL); if (Settings.showTop()) { drawBattery(dc, ti, level); drawTopSlot(dc, ti, Settings.leftTop(), true, ctx, level); drawTopSlot(dc, ti, Settings.rightTop(), false, ctx, level); } drawTime(dc, ti, ds); if (Settings.showBand()) { drawDateBand(dc, ti, ds); } if (Settings.showBottom()) { drawBottom(dc, ti, ctx); } } }