Split the face into modules, comment it, and verify every field

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>
This commit is contained in:
2026-09-10 05:48:58 +08:00
parent d457f8244e
commit 52346bdd09
7 changed files with 781 additions and 499 deletions

View File

@@ -2,6 +2,9 @@ import Toybox.Application;
import Toybox.Lang;
import Toybox.WatchUi;
// 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.
class Fenix8V3App extends Application.AppBase {
function initialize() {
@@ -18,6 +21,8 @@ class Fenix8V3App extends Application.AppBase {
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 onSettingsChanged() as Void {
WatchUi.requestUpdate();
}

View File

@@ -1,103 +1,30 @@
import Toybox.Application;
import Toybox.Graphics;
import Toybox.Lang;
import Toybox.Math;
import Toybox.ActivityMonitor;
import Toybox.Activity;
import Toybox.Position;
import Toybox.Weather;
import Toybox.SensorHistory;
import Toybox.System;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.WatchUi;
import Themes;
import Fields;
import Toybox.SensorHistory;
import Layout;
import Icons;
import Settings;
// Every literal below is a coordinate on the 500x500 design canvas, measured
// from design/watchface-*.svg. Nothing here is eyeballed: ring geometry comes
// from design/rebuild.py, element positions are the ink bounding boxes of the
// traced SVG paths. At draw time everything is multiplied by s = width / 500.
// 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 {
// ---------------------------------------------------------------- ring
// Ticks are annular sectors of constant angular width, not radial lines.
const R_IN = 231.09;
const R_OUT = 249.10;
const TOP_N = 21;
const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12
const TOP_PITCH = 4.000;
const TOP_HW = 1.554; // half of the 3.108 deg tick width
const BOT_N = 21;
const BOT_PH = 6.005; // measured clockwise from 6 o'clock
const BOT_PITCH = 3.000;
const BOT_HW = 1.043;
const DOT_RAD = 240.10; // distance of the anchor dots from centre
const DOT_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------------------------------------- layout
const BATT_X = 192.70; // battery body, left edge
const BATT_Y = 44.02;
const BATT_W = 26.20; // body only, terminal sits to its right
const BATT_H = 17.81;
const BATT_WALL = 2.20;
const BATT_TW = 2.53; // terminal
const BATT_TH = 9.20;
const BATT_VX = 232.00; // "40%" left edge
const BATT_CY = 53.20;
const LEFT_X = 157.00; // top complications, centred
const RIGHT_X = 343.70;
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 COLON_X = 237.88;
const COLON_W = 24.24;
const COLON_H = 21.30;
const COLON_Y1 = 183.68;
const COLON_Y2 = 234.57;
const BAND_Y = 286.30;
const BAND_H = 44.19;
const BAND_CY = 308.10;
const BT_CX = 36.88;
const BT_CY = 306.99;
const BT_HW = 8.89; // half width of the bluetooth rune
const BT_HH = 12.92;
const DOW_CX = 178.05;
const MD_CX = 298.28;
const AMPM_R = 465.89;
const BOT_VAL_CY = 366.76;
const BOT_LAB_CY = 410.88;
const DIST_R = 234.30; // right edge of both DST lines
const STEP_L = 267.40; // left edge of both STEP lines
// Cap heights of the three text sizes in the design.
const CAP_BAND = 25.34; // date band, battery percentage
const CAP_COMP = 31.50; // complications, bottom row
const CAP_TIME = 98.70; // time digits
// ------------------------------------------------------------- cached
private var mS = 1.0f;
private var mW = 0;
private var mH = 0;
private var mCx = 0;
private var mCy = 0;
private var mFontBand = Graphics.FONT_SMALL;
private var mFontComp = Graphics.FONT_MEDIUM;
private var mFontTime = Graphics.FONT_NUMBER_HOT;
// Always-on display: AMOLED devices must cut lit pixels hard while
// asleep, and shift what is left so nothing burns in.
// 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;
private var mIconSunrise = null;
private var mIconSunset = null;
private var mIconWeather = null;
function initialize() {
WatchFace.initialize();
@@ -113,157 +40,49 @@ class Fenix8V3View extends WatchUi.WatchFace {
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 {
mW = dc.getWidth();
mH = dc.getHeight();
mS = mW / 500.0;
mCx = mW / 2;
mCy = mH / 2;
Layout.init(dc);
Fields.loadStrings();
Icons.load();
var ds = System.getDeviceSettings();
mBurnIn = (ds != null) && (ds has :requiresBurnInProtection)
&& ds.requiresBurnInProtection;
var text = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
mFontBand = pickFont(text, CAP_BAND * mS, CAP_OF_TEXT);
mFontComp = pickFont(text, CAP_COMP * mS, CAP_OF_TEXT);
mFontTime = pickFont(nums, CAP_TIME * mS, CAP_OF_NUM);
loadStrings();
}
function loadStrings() as Void {
Fields.loadStrings();
mIconSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise);
mIconSunset = WatchUi.loadResource(Rez.Drawables.IconSunset);
mIconWeather = WatchUi.loadResource(Rez.Drawables.IconWeather);
}
// Connect IQ exposes ascent and descent but not cap height, and the ascent
// reserves room for diacritics no digit or capital ever uses. These two
// ratios were measured by rendering in the simulator and reading the ink
// back off the screenshot (fenix847mm: xtiny ascent 29 -> caps 22 px,
// numberMild ascent 82 -> digits 58 px).
static const CAP_OF_TEXT = 0.76;
static const CAP_OF_NUM = 0.71;
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
return Graphics.getFontAscent(font) * ratio;
}
// Closest cap height wins -- not the largest that fits, which would leave a
// whole size on the table 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];
var bestErr = -1.0;
for (var i = 0; i < cands.size(); i++) {
var err = capHeight(cands[i], ratio) - targetCap;
if (err < 0) { err = -err; }
if (bestErr < 0 || err < bestErr) { bestErr = err; best = cands[i]; }
}
return best;
}
function px(v as Numeric) as Number {
return Math.round(v * mS).toNumber();
}
// ----------------------------------------------------------------- utils
function getSetting(name as String, def as Number) as Number {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Number;
}
function getBool(name as String, def as Boolean) as Boolean {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Boolean;
}
function themeIndex() as Number {
var i = getSetting("Theme", 1) - 1;
if (i < 0 || i >= Themes.NAMES.size()) { i = 0; }
return i;
}
// TEXT_JUSTIFY_VCENTER centres the font box. Caps 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.
function inkY(font as Graphics.FontDefinition, dc as Dc, cy as Numeric,
ratio as Float) as Number {
var fh = dc.getFontHeight(font);
var asc = Graphics.getFontAscent(font);
return px(cy) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
}
function text(dc as Dc, x as Numeric, cy as Numeric, font as Graphics.FontDefinition,
str as String, just as Number, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), inkY(font, dc, cy, CAP_OF_TEXT), font, str,
just | Graphics.TEXT_JUSTIFY_VCENTER);
}
// Draw str once per gradient stop, each pass clipped to its own horizontal
// band -- Monkey C cannot fill glyphs with a gradient directly. The first
// and last band are extended off-glyph so nothing is ever left undrawn.
function gradText(dc as Dc, x as Numeric, cy as Numeric, font as Graphics.FontDefinition,
str as String, just as Number, ramp as Array<Number>) as Void {
var n = ramp.size();
var y = inkY(font, dc, cy, CAP_OF_NUM);
var ink = capHeight(font, CAP_OF_NUM).toNumber();
var top = px(cy) - ink / 2;
for (var i = 0; i < n; i++) {
var y0 = top + (ink * i) / n;
var y1 = top + (ink * (i + 1)) / n;
if (i == 0) { y0 = 0; }
if (i == n - 1) { y1 = mH; }
if (y1 <= y0) { continue; }
dc.setClip(0, y0, mW, y1 - y0);
dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER);
}
dc.clearClip();
}
// Design angles run clockwise from 12 o'clock.
function polarX(ang as Float, r as Float) as Number {
return Math.round(mCx + Math.sin(Math.toRadians(ang)) * r).toNumber();
}
function polarY(ang as Float, r as Float) as Number {
return Math.round(mCy - Math.cos(Math.toRadians(ang)) * r).toNumber();
}
// progress 0..1 for a metric index
function metricProgress(idx as Number, info as ActivityMonitor.Info) as Float {
// ------------------------------------------------------------- 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 (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
} else if (idx == 1) { // calories
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
} else if (idx == 2) { // Distance (cm -> 10 km cap)
} else if (idx == 2) { // distance, 10 km ring
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
} else if (idx == 3) { // Floors
} 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 the weekly goal
if (info.activeMinutesWeek != null && info.activeMinutesWeekGoal != null
} 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
} 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: an integer level, not a ratio
} 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();
@@ -277,14 +96,17 @@ class Fenix8V3View extends WatchUi.WatchFace {
return p;
}
// ----------------------------------------------------------------- ring
// 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;
dc.fillPolygon([[polarX(a0, R_OUT * mS), polarY(a0, R_OUT * mS)],
[polarX(a1, R_OUT * mS), polarY(a1, R_OUT * mS)],
[polarX(a1, R_IN * mS), polarY(a1, R_IN * mS)],
[polarX(a0, R_IN * mS), polarY(a0, R_IN * mS)]]);
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
@@ -306,178 +128,104 @@ class Fenix8V3View extends WatchUi.WatchFace {
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var off = Themes.TICKOFF[ti] as Number;
arc(dc, top, off, (pTR * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N,
0.0, false, true);
arc(dc, top, off, (pTL * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N,
0.0, true, true);
arc(dc, bot, off, (pBL * BOT_N).toNumber(), BOT_PH, BOT_PITCH, BOT_HW, BOT_N,
180.0, false, false);
arc(dc, bot, off, (pBR * BOT_N).toNumber(), BOT_PH, BOT_PITCH, BOT_HW, BOT_N,
180.0, true, false);
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 = px(DOT_R);
for (var i = 0; i < DOT_ANG.size(); i++) {
var a = DOT_ANG[i];
dc.fillCircle(polarX(a, DOT_RAD * mS), polarY(a, DOT_RAD * mS), r);
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);
}
}
// ------------------------------------------------------------ icons
// The design ships three icons; they are rebuilt here from primitives at
// the sub-shape positions measured out of the SVG paths.
function iconBattery(dc as Dc, x as Numeric, cy as Numeric, scale as Float,
col as Number, level as Float) as Void {
var w = BATT_W * scale;
var h = BATT_H * scale;
var wall = BATT_WALL * scale;
var x0 = px(x);
var y0 = px(cy - h / 2);
var pw = px(w);
var ph = px(h);
var r = px(3.4 * scale);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRoundedRectangle(x0, y0, pw, ph, r);
dc.fillRectangle(x0 + pw, px(cy - BATT_TH * scale / 2),
px(BATT_TW * scale), px(BATT_TH * scale));
var ix = x0 + px(wall);
var iy = y0 + px(wall);
var iw = pw - 2 * px(wall);
var ih = ph - 2 * px(wall);
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(ix, iy, iw, ih);
var fill = (iw * level).toNumber();
if (fill > 0) {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(ix, iy, fill, ih);
}
// ---------------------------------------------------------- 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 stub(dc as Dc, x1 as Numeric, y1 as Numeric, x2 as Numeric, y2 as Numeric) as Void {
dc.drawLine(px(x1), px(y1), px(x2), px(y2));
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);
}
// The comps' sunrise and weather icons are traced outlines with two- and
// three-pixel features. Rebuilding them from circles and lines held up at
// 454 px and turned to mush at 280, so they ship as bitmaps rasterised
// from the design paths at each screen size (tools/gen_icons.py) and get
// the theme's accent applied through :tintColor.
function blitIcon(dc as Dc, bmp, cx as Numeric, cy as Numeric, col as Number) as Void {
if (bmp == null) { return; }
dc.drawBitmap2(px(cx) - bmp.getWidth() / 2, px(cy) - bmp.getHeight() / 2,
bmp, {:tintColor => col});
}
function maxNum(a as Number, b as Number) as Number {
return a > b ? a : b;
}
function drawComplicationIcon(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number, typeIdx as Number, level as Float) as Void {
if (typeIdx == 712 || typeIdx == 700) {
blitIcon(dc, mIconSunrise, cx, ICON_CY, col);
} else if (typeIdx == 717 || typeIdx == 731) {
blitIcon(dc, mIconSunset, cx, ICON_CY, col);
} else if (typeIdx == 306 || typeIdx == 639 || typeIdx == 300
|| typeIdx == 301 || typeIdx == 302 || typeIdx == 304
|| typeIdx == 305 || typeIdx == 308 || typeIdx == 309
|| typeIdx == 310 || typeIdx == 307) {
blitIcon(dc, mIconWeather, cx, ICON_CY, col);
} else if (typeIdx == 250 || typeIdx == 251 || typeIdx == 257) {
iconBattery(dc, cx - 14.4 * scale, cy, scale, col, level);
} else {
var r = 13.0 * scale;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(maxNum(2, px(3.0 * scale)));
if (typeIdx == 2 || typeIdx == 603) { // calories: flame
dc.fillPolygon([[px(cx), px(cy - r)],
[px(cx + r * 0.72), px(cy + r * 0.25)],
[px(cx + r * 0.40), px(cy + r * 0.85)],
[px(cx - r * 0.40), px(cy + r * 0.85)],
[px(cx - r * 0.72), px(cy + r * 0.25)]]);
} else if (typeIdx == 1 || typeIdx == 14 || typeIdx == 707) { // steps
dc.fillRoundedRectangle(px(cx - r * 0.85), px(cy - r * 0.75),
px(r * 0.62), px(r * 1.15), px(r * 0.3));
dc.fillRoundedRectangle(px(cx + r * 0.22), px(cy - r * 0.35),
px(r * 0.62), px(r * 1.15), px(r * 0.3));
} else if (typeIdx == 3 || typeIdx == 9 || typeIdx == 615) { // distance
dc.fillPolygon([[px(cx), px(cy - r)],
[px(cx + r * 0.75), px(cy + r * 0.8)],
[px(cx), px(cy + r * 0.35)],
[px(cx - r * 0.75), px(cy + r * 0.8)]]);
} else if (typeIdx == 618 || typeIdx == 276 || typeIdx == 623) { // heart
dc.fillCircle(px(cx - r * 0.38), px(cy - r * 0.28), px(r * 0.44));
dc.fillCircle(px(cx + r * 0.38), px(cy - r * 0.28), px(r * 0.44));
dc.fillPolygon([[px(cx - r * 0.78), px(cy - r * 0.12)],
[px(cx + r * 0.78), px(cy - r * 0.12)],
[px(cx), px(cy + r * 0.85)]]);
} else if (typeIdx == 4 || typeIdx == 5 || typeIdx == 6) { // stairs
for (var i = 0; i < 3; i++) {
dc.fillRectangle(px(cx - r * 0.85 + r * 0.58 * i),
px(cy + r * 0.6 - r * 0.55 * i),
px(r * 0.62), px(r * 0.4));
}
}
// Everything else -- dates, week numbers, custom text -- reads fine
// without a glyph, so no icon is drawn.
}
}
// ------------------------------------------------------------ elements
function drawBattery(dc as Dc, ti as Number, stats as System.Stats) as Void {
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; }
iconBattery(dc, BATT_X, BATT_CY, 1.0, Themes.ACCENT[ti] as Number, level);
text(dc, BATT_VX, BATT_CY, mFontBand, (level * 100.0).toNumber().format("%d") + "%",
Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
}
function drawTopComplication(dc as Dc, ti as Number, typeIdx as Number, isLeft as Boolean,
ctx as Fields.Ctx, level as Float) as Void {
var x = isLeft ? LEFT_X : RIGHT_X;
drawComplicationIcon(dc, x, ICON_CY, 1.0, Themes.ACCENT[ti] as Number, typeIdx, level);
text(dc, x, VALUE_CY, mFontComp, Fields.value(typeIdx, ctx),
Graphics.TEXT_JUSTIFY_CENTER, Themes.TEXTPRIMARY[ti] as Number);
}
function drawBluetooth(dc as Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(maxNum(2, px(3.2)));
var l = BT_CX - BT_HW;
var r = BT_CX + BT_HW;
var t = BT_CY - BT_HH;
var b = BT_CY + BT_HH;
var qt = BT_CY - BT_HH / 2;
var qb = BT_CY + BT_HH / 2;
stub(dc, l, qt, r, qb);
stub(dc, r, qb, BT_CX, b);
stub(dc, BT_CX, b, BT_CX, t);
stub(dc, BT_CX, t, r, qt);
stub(dc, r, qt, l, qb);
}
function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings) as Void {
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, px(BAND_Y), mW, px(BAND_H));
dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width,
Layout.px(Layout.BAND_H));
var col = Themes.BANDTEXT[ti] as Number;
drawBluetooth(dc, (ds != null && !ds.phoneConnected)
? blend(col, fill, 0.6) : col);
// 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);
text(dc, DOW_CX, BAND_CY, mFontBand, Fields.dow(g),
Graphics.TEXT_JUSTIFY_CENTER, col);
text(dc, MD_CX, BAND_CY, mFontBand, Fields.monthDay(g),
Graphics.TEXT_JUSTIFY_CENTER, col);
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) {
text(dc, AMPM_R, BAND_CY, mFontBand, Fields.meridiem(g),
Graphics.TEXT_JUSTIFY_RIGHT, col);
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;
@@ -488,37 +236,6 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b;
}
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; }
}
gradText(dc, HOURS_R, TIME_CY, mFontTime, h.format("%d"),
Graphics.TEXT_JUSTIFY_RIGHT, Themes.HOURS[ti] as Array<Number>);
gradText(dc, MINUTES_L, TIME_CY, mFontTime, 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(px(COLON_X), px(COLON_Y1), px(COLON_W), px(COLON_H));
dc.fillRectangle(px(COLON_X), px(COLON_Y2), px(COLON_W), px(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 = getSetting("BottomLeft", 3);
var right = getSetting("BottomRight", 1);
text(dc, DIST_R, BOT_VAL_CY, mFontComp, Fields.value(left, ctx),
Graphics.TEXT_JUSTIFY_RIGHT, value);
text(dc, DIST_R, BOT_LAB_CY, mFontComp, Fields.label(left),
Graphics.TEXT_JUSTIFY_RIGHT, label);
text(dc, STEP_L, BOT_VAL_CY, mFontComp, Fields.value(right, ctx),
Graphics.TEXT_JUSTIFY_LEFT, value);
text(dc, STEP_L, BOT_LAB_CY, mFontComp, Fields.label(right),
Graphics.TEXT_JUSTIFY_LEFT, label);
}
// ------------------------------------------------------------------ AOD
function dim(col as Number, f as Float) as Number {
var r = (((col >> 16) & 0xFF) * f).toNumber();
var g = (((col >> 8) & 0xFF) * f).toNumber();
@@ -526,41 +243,42 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b;
}
// Low-power face: no band fill, no unlit ticks, no complications -- just a
// --------------------------------------------------------------- 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,
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 / mS; // +/- 4 device px, in design units
var oy = ((g.min / 5) % 5 - 2) * 2.0 / mS;
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 * TOP_N).toNumber();
var litTL = (pTL * TOP_N).toNumber();
var litBR = (pBR * BOT_N).toNumber();
var litBL = (pBL * BOT_N).toNumber();
for (var i = 0; i < TOP_N; i++) {
var a = TOP_PH + TOP_PITCH * i;
if (i >= TOP_N - litTR) {
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, TOP_HW);
tick(dc, a, Layout.TOP_HW);
}
if (i >= TOP_N - litTL) {
if (i >= Layout.TOP_N - litTL) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, -a, TOP_HW);
tick(dc, -a, Layout.TOP_HW);
}
}
for (var i = 0; i < BOT_N; i++) {
var a = BOT_PH + BOT_PITCH * i;
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, BOT_HW);
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, BOT_HW);
tick(dc, 180.0 - a, Layout.BOT_HW);
}
}
@@ -570,39 +288,50 @@ class Fenix8V3View extends WatchUi.WatchFace {
}
var hours = Themes.HOURS[ti] as Array<Number>;
var mins = Themes.MINUTES[ti] as Array<Number>;
text(dc, HOURS_R + ox, TIME_CY + oy, mFontTime, h.format("%d"),
Graphics.TEXT_JUSTIFY_RIGHT, dim(hours[hours.size() / 2], 0.55));
text(dc, MINUTES_L + ox, TIME_CY + oy, mFontTime, g.min.format("%02d"),
Graphics.TEXT_JUSTIFY_LEFT, dim(mins[mins.size() / 2], 0.55));
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(px(COLON_X + ox), px(COLON_Y1 + oy), px(COLON_W), px(COLON_H));
dc.fillRectangle(px(COLON_X + ox), px(COLON_Y2 + oy), px(COLON_W), px(COLON_H));
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);
text(dc, DOW_CX + ox, BAND_CY + oy, mFontBand, Fields.dow(g),
Graphics.TEXT_JUSTIFY_CENTER, band);
text(dc, MD_CX + ox, BAND_CY + oy, mFontBand, Fields.monthDay(g),
Graphics.TEXT_JUSTIFY_CENTER, band);
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
// -------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void {
if (mW != dc.getWidth() || !Fields.ready()) { onLayout(dc); }
if (!Layout.ready() || Layout.width != dc.getWidth()
|| !Fields.ready() || !Icons.ready()) {
onLayout(dc);
}
if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
var ti = themeIndex();
var ti = Settings.themeIndex(Themes.NAMES.size());
var ds = System.getDeviceSettings();
var ctx = new Fields.Ctx(ds, getSetting("TempUnit", 0));
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 = metricProgress(getSetting("RingTR", 0), info);
var pTL = metricProgress(getSetting("RingTL", 1), info);
var pBR = metricProgress(getSetting("RingBR", 4), info);
var pBL = metricProgress(getSetting("RingBL", 3), info);
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);
@@ -611,21 +340,19 @@ class Fenix8V3View extends WatchUi.WatchFace {
drawRing(dc, ti, pTR, pTL, pBR, pBL);
if (getBool("ShowTop", true)) {
var level = 0.0f;
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
drawBattery(dc, ti, stats);
drawTopComplication(dc, ti, getSetting("LeftTop", 712), true, ctx, level);
drawTopComplication(dc, ti, getSetting("RightTop", 639), false, ctx, level);
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 (getBool("ShowBand", true)) {
if (Settings.showBand()) {
drawDateBand(dc, ti, ds);
}
if (getBool("ShowBottom", true)) {
if (Settings.showBottom()) {
drawBottom(dc, ti, ctx);
}
}

View File

@@ -12,6 +12,7 @@ import Toybox.Weather;
import Toybox.Position;
import Toybox.WatchUi;
import FieldTable;
import Settings;
// Everything a complication slot can show. Ids match
// https://watchface.io/docs/datafields so the two catalogues line up; see
@@ -68,6 +69,12 @@ 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.
function dayOnly(g as Gregorian.Info) as String {
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
}
function meridiem(g as Gregorian.Info) as String {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
}
@@ -210,18 +217,6 @@ module Fields {
return (n / 1000.0).format("%.1f") + "k";
}
function setting(name as String, def as Number) as Number {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Number;
}
function settingText(name as String) as String {
var v = Application.Properties.getValue(name);
if (v == null) { return ""; }
return v.toString();
}
// Newest reading out of a SensorHistory iterator, or null.
function latest(iter) as Numeric? {
if (iter == null) { return null; }
@@ -277,15 +272,17 @@ module Fields {
}
// --------------------------------------------------------------- value
function value(id as Number, c as Ctx) as String {
// ---- date / time
// Clock, calendar and week-number fields.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function dateTime(id as Number, c as Ctx) as String? {
if (id == 860) { return clockInfo(c.greg, c); }
if (id == 880) {
var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT);
return clockInfo(utc, c);
}
if (id == 864) { return offsetClock(setting("AltOffset1", 0), c); }
if (id == 865) { return offsetClock(setting("AltOffset2", 0), c); }
if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); }
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"); }
@@ -294,11 +291,17 @@ module Fields {
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) + " " + c.greg.day.format("%d"); }
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); }
// ---- activity
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.
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); }
@@ -315,33 +318,65 @@ module Fields {
return num(i.calories); // device reports total only
}
if (id == 150 || id == 152 || id == 154) {
if (i == null || i.activeMinutesDay == null) { return DASH; }
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); }
return num(am.total);
}
if (id == 151) {
if (i == null || i.activeMinutesWeek == null) { return DASH; }
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); }
if (id == 5) { return i == null ? DASH : num(i.floorsDescended); }
if (id == 6) { return i == null ? DASH : metres(i.metersClimbed, c); }
if (id == 9) { return i == null ? DASH : dist(i.pushDistance, c); }
if (id == 10) { return i == null ? DASH : num(i.pushes); }
// 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) {
return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended);
}
if (id == 6) {
return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c);
}
if (id == 9) {
return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c);
}
if (id == 10) {
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
}
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) { return i == null ? DASH : num(i.timeToRecovery); }
if (id == 11) { return i == null ? DASH : num(i.respirationRate); }
if (id == 12) { return i == null ? DASH : num(i.stressScore); }
if (id == 13) {
return (i == null || !(i has :timeToRecovery)) ? DASH
: num(i.timeToRecovery);
}
if (id == 11) {
return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate);
}
if (id == 12) {
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
}
// ---- body
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.
function body(id as Number, c as Ctx) as String? {
if (id == 618) {
var a = c.act();
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
@@ -357,7 +392,8 @@ module Fields {
}
if (id == 276) {
var p = c.profile();
return (p == null) ? DASH : num(p.restingHeartRate);
return (p == null || !(p has :restingHeartRate)) ? DASH
: num(p.restingHeartRate);
}
if (id == 635) {
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
@@ -369,22 +405,31 @@ module Fields {
}
if (id == 281 || id == 742) {
var p = c.profile();
if (p == null || p.weight == null) { return DASH; }
if (p == null || !(p has :weight) || p.weight == null) { return DASH; }
var kg = p.weight.toFloat() / 1000.0;
if (id == 281) {
return (c.metric ? kg : kg * 2.20462).format("%.1f");
}
if (p.height == null || p.height <= 0) { return DASH; }
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) {
var p = c.profile();
if (p == null) { return DASH; }
return num(id == 278 ? p.vo2maxRunning : p.vo2maxCycling);
if (id == 278) {
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
}
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
}
// ---- system
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) {
return st == null || st.battery == null ? DASH
@@ -418,15 +463,26 @@ module Fields {
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
}
// ---- environment
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) {
var a = c.act();
return (a == null) ? DASH : metres(a.altitude, c);
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
}
if (id == 616 || id == 733) {
var a = c.act();
if (a == null) { return DASH; }
var pa = (id == 616) ? a.meanSeaLevelPressure : a.ambientPressure;
var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) {
pa = a.meanSeaLevelPressure;
} else if (id == 733 && (a has :ambientPressure)) {
pa = a.ambientPressure;
}
if (pa == null) { return DASH; }
return (pa.toFloat() / 100.0).format("%d");
}
@@ -453,39 +509,77 @@ module Fields {
return (lit * 100.0).format("%d") + "%";
}
// ---- weather
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.
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) { return w == null ? DASH : temp(w.feelsLikeTemperature, c); }
if (id == 305) { return w == null ? DASH : num(w.relativeHumidity) + "%"; }
if (id == 300) {
return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c);
}
if (id == 305) {
return (w == null || !(w has :relativeHumidity)) ? DASH
: num(w.relativeHumidity) + "%";
}
if (id == 304) {
if (w == null || !(w has :precipitationChance)) { return DASH; }
return num(w.precipitationChance) + "%";
}
if (id == 308) {
if (w == null || w.windSpeed == null) { return DASH; }
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) { return w == null ? DASH : num(w.windBearing); }
if (id == 310) { return w == null ? DASH : windDir(w.windBearing); }
if (id == 307) {
return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
}
if (id == 310) {
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
}
if (id == 309) {
if (w == null || w.observationTime == null) { return DASH; }
if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH;
}
return clock(w.observationTime, c);
}
if (id == 301 || id == 302 || id == 639) {
var f = c.forecast();
if (f == null) { return DASH; }
if (id == 301) { return temp(f.highTemperature, c); }
if (id == 302) { return temp(f.lowTemperature, c); }
return temp(f.highTemperature, c) + "/" + temp(f.lowTemperature, c);
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); }
return temp(hi, c) + "/" + temp(lo, c);
}
// ---- custom
if (id == 703) { return settingText("Custom1"); }
if (id == 704) { return settingText("Custom2"); }
if (id == 705) { return settingText("Custom3"); }
return null;
}
return "";
// 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.
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"); }
return null;
}
// Text for one data slot. Categories are tried in turn rather
// than dispatched on an id range, because the ids interleave.
function value(id as Number, c as Ctx) as String {
var v = dateTime(id, c);
if (v == null) { v = activity(id, c); }
if (v == null) { v = body(id, c); }
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 = custom(id, c); }
return v == null ? "" : v;
}
}

142
source/Icons.mc Normal file
View File

@@ -0,0 +1,142 @@
import Toybox.Lang;
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.
module Icons {
var mSunrise = null;
var mSunset = null;
var mWeather = null;
function load() as Void {
mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise);
mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset);
mWeather = WatchUi.loadResource(Rez.Drawables.IconWeather);
}
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.
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,
Layout.px(Layout.ICON_CY) - bmp.getHeight() / 2,
bmp, {:tintColor => col});
}
// Outline, charge bar and terminal. The bar tracks the real level, the
// way the comp draws it at 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);
var y0 = Layout.px(cyDesign - Layout.BATT_H * k / 2);
var w = Layout.px(Layout.BATT_W * k);
var h = Layout.px(Layout.BATT_H * k);
var wall = Layout.px(Layout.BATT_WALL * k);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRoundedRectangle(x0, y0, w, h, Layout.px(3.4 * k));
dc.fillRectangle(x0 + w, Layout.px(cyDesign - Layout.BATT_TH * k / 2),
Layout.px(Layout.BATT_TW * k), Layout.px(Layout.BATT_TH * k));
var iw = w - 2 * wall;
var ih = h - 2 * wall;
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(x0 + wall, y0 + wall, iw, ih);
var fill = (iw * level).toNumber();
if (fill > 0) {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(x0 + wall, y0 + wall, fill, ih);
}
}
// 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));
var l = Layout.BT_CX - Layout.BT_HW;
var r = Layout.BT_CX + Layout.BT_HW;
var t = Layout.BT_CY - Layout.BT_HH;
var b = Layout.BT_CY + Layout.BT_HH;
var qt = Layout.BT_CY - Layout.BT_HH / 2;
var qb = Layout.BT_CY + Layout.BT_HH / 2;
Layout.line(dc, l, qt, r, qb);
Layout.line(dc, r, qb, Layout.BT_CX, b);
Layout.line(dc, Layout.BT_CX, b, Layout.BT_CX, t);
Layout.line(dc, Layout.BT_CX, t, r, qt);
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) {
blit(dc, mSunrise, xDesign, col);
return;
}
if (id == 717 || id == 731) {
blit(dc, mSunset, xDesign, col);
return;
}
if (id == 306 || id == 639 || id == 300 || id == 301 || id == 302
|| id == 304 || id == 305 || id == 307 || id == 308
|| id == 309 || id == 310) {
blit(dc, mWeather, xDesign, col);
return;
}
if (id == 250 || id == 251 || id == 257) {
battery(dc, xDesign - 14.4, Layout.ICON_CY, 1.0, col, level);
return;
}
var r = 13.0;
var cyd = Layout.ICON_CY;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.0));
if (id == 2 || id == 603) { // calories: flame
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
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
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
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),
Layout.px(r * 0.44));
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
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),
Layout.px(r * 0.62), Layout.px(r * 0.4));
}
}
}
}

235
source/Layout.mc Normal file
View File

@@ -0,0 +1,235 @@
import Toybox.Lang;
import Toybox.Graphics;
import Toybox.Math;
// Geometry and text plumbing for the face.
//
// 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).
//
// Call init() from the view's onLayout before anything else touches this.
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
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 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_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_H = 17.81;
const BATT_WALL = 2.20; // outline thickness
const BATT_TW = 2.53; // terminal
const BATT_TH = 9.20;
const BATT_VX = 232.00; // "40%" left edge
const BATT_CY = 53.20;
const LEFT_X = 157.00; // top data slots, centred
const RIGHT_X = 343.70;
const ICON_CY = 89.50; // matches the icon bitmaps' own centre
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 COLON_X = 237.88;
const COLON_W = 24.24;
const COLON_H = 21.30;
const COLON_Y1 = 183.68;
const COLON_Y2 = 234.57;
const BAND_Y = 286.30;
const BAND_H = 44.19;
const BAND_CY = 308.10;
const BT_CX = 36.88; // bluetooth rune
const BT_CY = 306.99;
const BT_HW = 8.89;
const BT_HH = 12.92;
const DOW_CX = 178.05;
const MD_CX = 298.28;
const AMPM_R = 465.89;
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
// 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
// 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.
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).
const CAP_OF_TEXT = 0.76;
const CAP_OF_NUM = 0.71;
// --------------------------------------------------------------- state
var scale = 1.0; // device px per design px
var width = 0;
var height = 0;
var cx = 0;
var cy = 0;
var fontBand = Graphics.FONT_SMALL;
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];
function init(dc as Graphics.Dc) as Void {
width = dc.getWidth();
height = dc.getHeight();
scale = width / 500.0;
cx = width / 2;
cy = height / 2;
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
fontBand = pickFont(textFonts, CAP_BAND * scale, CAP_OF_TEXT);
fontComp = pickFont(textFonts, CAP_COMP * scale, CAP_OF_TEXT);
fontTime = pickFont(nums, CAP_TIME * scale, CAP_OF_NUM);
}
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();
}
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
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];
var bestErr = -1.0;
for (var i = 0; i < cands.size(); i++) {
var err = capHeight(cands[i], ratio) - targetCap;
if (err < 0) { err = -err; }
if (bestErr < 0 || err < bestErr) { bestErr = err; best = cands[i]; }
}
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.
function fitFont(dc as Graphics.Dc, str as String,
font as Graphics.FontDefinition, maxDesignW as Float)
as Graphics.FontDefinition {
var limit = px(maxDesignW);
var f = font;
var i = textFonts.size() - 1;
while (i >= 0) {
if (textFonts[i] == f) { break; }
i--;
}
if (i < 0) { return f; } // not one of the text fonts
while (i >= 0) {
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
return textFonts[i];
}
i--;
}
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.
function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition,
cyDesign as Numeric, ratio as Float) as Number {
var fh = dc.getFontHeight(font);
var asc = Graphics.getFontAscent(font);
return px(cyDesign) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
}
function text(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String,
just as Number, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), inkY(dc, font, cyDesign, CAP_OF_TEXT), font, str,
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.
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 {
var n = ramp.size();
var y = inkY(dc, font, cyDesign, CAP_OF_NUM);
var ink = capHeight(font, CAP_OF_NUM).toNumber();
var top = px(cyDesign) - ink / 2;
for (var i = 0; i < n; i++) {
var y0 = top + (ink * i) / n;
var y1 = top + (ink * (i + 1)) / n;
if (i == 0) { y0 = 0; }
if (i == n - 1) { y1 = height; }
if (y1 <= y0) { continue; }
dc.setClip(0, y0, width, y1 - y0);
dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER);
}
dc.clearClip();
}
// Design angles run clockwise from 12 o'clock; screen y grows downward.
function polarX(ang as Float, r as Float) as Number {
return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber();
}
function polarY(ang as Float, r as Float) as Number {
return Math.round(cy - Math.cos(Math.toRadians(ang)) * r).toNumber();
}
function line(dc as Graphics.Dc, x1 as Numeric, y1 as Numeric,
x2 as Numeric, y2 as Numeric) as Void {
dc.drawLine(px(x1), px(y1), px(x2), px(y2));
}
// Pen widths round to zero on small screens; never let one vanish.
function pen(v as Numeric) as Number {
var w = px(v);
return w < 2 ? 2 : w;
}
}

59
source/Settings.mc Normal file
View File

@@ -0,0 +1,59 @@
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.
//
// 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.
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
function number(key as String, def as Number) as Number {
var v = Application.Properties.getValue(key);
if (v == null) { return def; }
return v as Number;
}
function bool(key as String, def as Boolean) as Boolean {
var v = Application.Properties.getValue(key);
if (v == null) { return def; }
return v as Boolean;
}
function text(key as String) as String {
var v = Application.Properties.getValue(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.
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); }
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); }
}