Architecture
Fenix8V3View had grown to 632 lines mixing layout constants, font
metrics, icon drawing, data lookup and orchestration. Split into:
Layout the 500-canvas constants, scaling, font choice and text drawing
Icons the three bitmap icons plus the primitive glyphs
Settings every stored preference and its default, in one place
Fields unchanged in scope, but value() is now seven category
functions that each return null for ids they do not own --
the ids interleave, so dispatching on ranges does not work
View what is left: the drawing order
The view is down to 359 lines and no longer reaches for a property or a
font metric directly.
Verification
Added a harness that walks all 75 ids in the simulator and reports each
rendered value with its pixel width. It caught a real one: pushDistance
and pushes compile on every device but throw "Symbol Not Found" at
runtime on a fenix 8 -- they are wheelchair counters that only some
firmware carries. Every optional ActivityMonitor, UserProfile, Weather
and Activity member is now behind a `has` check.
The same run showed two date fields overflowing their slot, so long
values now step down a font size rather than running into the ring.
Always-on mode checked against the simulator's Always-On display mode:
band fill gone, unlit ticks gone, slots hidden, time and date dimmed.
Also: Chinese "Day Date" was missing its 日, since it built the string by
hand instead of going through the localised date format.
All 17 devices build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
17 KiB
MonkeyC
360 lines
17 KiB
MonkeyC
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;
|
|
|
|
// The face itself: the progress ring, the elements laid over it, and the
|
|
// stripped-down variant drawn while an AMOLED watch is asleep.
|
|
//
|
|
// 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.
|
|
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.
|
|
private var mSleeping = false;
|
|
private var mBurnIn = false;
|
|
|
|
function initialize() {
|
|
WatchFace.initialize();
|
|
}
|
|
|
|
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).
|
|
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;
|
|
}
|
|
|
|
// ------------------------------------------------------------- ring
|
|
// Progress for one of the four arcs, 0..1. These ids are the ring's own
|
|
// short list, not the data field catalogue.
|
|
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 (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
|
|
p = info.steps.toFloat() / info.stepGoal.toFloat();
|
|
}
|
|
} else if (idx == 1) { // calories
|
|
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
|
|
} else if (idx == 2) { // distance, 10 km ring
|
|
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
|
|
} else if (idx == 3) { // floors
|
|
if (info.floorsClimbed != null) {
|
|
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
|
|
? info.floorsClimbedGoal.toFloat() : 10.0f;
|
|
p = info.floorsClimbed.toFloat() / goal;
|
|
}
|
|
} else if (idx == 5) { // active minutes vs week goal
|
|
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) { // body battery is already 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
|
|
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;
|
|
}
|
|
|
|
// 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.
|
|
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)]]);
|
|
}
|
|
|
|
// 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.
|
|
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 {
|
|
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 {
|
|
var top = Themes.RINGTOP[ti] as Array<Number>;
|
|
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------- 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);
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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.
|
|
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);
|
|
}
|
|
}
|
|
|
|
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<Number>);
|
|
Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime,
|
|
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
|
|
Themes.MINUTES[ti] as Array<Number>);
|
|
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);
|
|
}
|
|
|
|
// ------------------------------------------------------------ colour
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// --------------------------------------------------------------- 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.
|
|
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 oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
|
|
|
|
var top = Themes.RINGTOP[ti] as Array<Number>;
|
|
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
|
|
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<Number>;
|
|
var mins = Themes.MINUTES[ti] as Array<Number>;
|
|
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);
|
|
}
|
|
|
|
// -------------------------------------------------------------- main
|
|
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); }
|
|
|
|
var ti = Settings.themeIndex(Themes.NAMES.size());
|
|
var ds = System.getDeviceSettings();
|
|
var ctx = new Fields.Ctx(ds, Settings.tempUnit());
|
|
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);
|
|
}
|
|
}
|
|
}
|