Files
fenix8v3-watchface/source/Fenix8V3View.mc
ericwyuan d457f8244e Add the on-device data field catalogue, always-on mode, and real icons
Fields
  75 selectable fields across date/time, activity, body, system,
  environment, weather and custom text, numbered to match
  watchface.io/docs/datafields so the two catalogues line up.  All four
  slots (both top, both bottom) pick from the same table and the bottom
  captions follow the selection.  tools/gen_fields.py owns the table and
  emits FieldTable.mc, settings.xml and both languages' strings, so those
  cannot drift apart.

  Fields.Ctx fetches each API at most once per draw, and only if some slot
  asks -- the forecast, user profile and sensor history lookups are lazy.

  The rest of that catalogue is deliberately absent: OpenWeatherMap and
  StormGlass need network calls and user API keys, the per-sport weekly and
  28-day aggregates come from a phone-side Garmin Connect integration rather
  than any on-device API, and the app complications need those apps
  installed.  README section 7 lists what was left out and why.

Permissions
  UserProfile and SensorHistory are now declared; without them resting heart
  rate, VO2 max, pulse ox and body battery throw at runtime.  Sensor turns
  out to be rejected outright for type="watchface", so altitude and pressure
  read from Activity.Info instead.

Always-on display
  AMOLED devices need the lit-pixel count cut while asleep.  drawAmbient
  drops the band fill and the unlit ticks, dims what remains, hides the
  complications, and walks the layout within +/-4 px per minute so nothing
  burns in.  Gated on requiresBurnInProtection, so MIP devices are untouched.

Icons
  The comps' sunrise and weather icons have two- and three-pixel features.
  Rebuilding them from circles and lines looked right at 454 px and turned to
  mush at 280.  They are now rasterised from the design paths once per screen
  width and tinted with drawBitmap2(:tintColor) -- which needs
  packingFormat="png", since a palettised source is refused at runtime.
  Launcher icons are generated at the five sizes the devices ask for.

All 17 devices build; checked on fenix847mm and enduro3 in the simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:53:18 +08:00

633 lines
28 KiB
MonkeyC

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.System;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.WatchUi;
import Themes;
import Fields;
import Toybox.SensorHistory;
// 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.
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.
private var mSleeping = false;
private var mBurnIn = false;
private var mIconSunrise = null;
private var mIconSunset = null;
private var mIconWeather = null;
function initialize() {
WatchFace.initialize();
}
function onEnterSleep() as Void {
mSleeping = true;
WatchUi.requestUpdate();
}
function onExitSleep() as Void {
mSleeping = false;
WatchUi.requestUpdate();
}
function onLayout(dc as Dc) as Void {
mW = dc.getWidth();
mH = dc.getHeight();
mS = mW / 500.0;
mCx = mW / 2;
mCy = mH / 2;
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 {
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 (cm -> 10 km cap)
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 the weekly goal
if (info.activeMinutesWeek != null && 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: an integer 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;
}
// ----------------------------------------------------------------- ring
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)]]);
}
// 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 * 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);
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);
}
}
// ------------------------------------------------------------ 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);
}
}
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));
}
// 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 {
var fill = Themes.BANDFILL[ti] as Number;
dc.setColor(fill, fill);
dc.fillRectangle(0, px(BAND_Y), mW, px(BAND_H));
var col = Themes.BANDTEXT[ti] as Number;
drawBluetooth(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);
if (ds == null || !ds.is24Hour) {
text(dc, AMPM_R, BAND_CY, mFontBand, Fields.meridiem(g),
Graphics.TEXT_JUSTIFY_RIGHT, col);
}
}
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 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();
var b = ((col & 0xFF) * f).toNumber();
return (r << 16) | (g << 8) | b;
}
// Low-power face: no band fill, no unlit ticks, no complications -- 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 / mS; // +/- 4 device px, in design units
var oy = ((g.min / 5) % 5 - 2) * 2.0 / mS;
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) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, a, TOP_HW);
}
if (i >= TOP_N - litTL) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, -a, TOP_HW);
}
}
for (var i = 0; i < BOT_N; i++) {
var a = BOT_PH + BOT_PITCH * i;
if (i < litBL) {
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, 180.0 + a, BOT_HW);
}
if (i < litBR) {
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, 180.0 - a, 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>;
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));
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));
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);
}
// ----------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void {
if (mW != dc.getWidth() || !Fields.ready()) { onLayout(dc); }
if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
var ti = themeIndex();
var ds = System.getDeviceSettings();
var ctx = new Fields.Ctx(ds, getSetting("TempUnit", 0));
var info = ctx.info();
var stats = ctx.stats();
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);
if (mSleeping && mBurnIn) {
drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL);
return;
}
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);
}
drawTime(dc, ti, ds);
if (getBool("ShowBand", true)) {
drawDateBand(dc, ti, ds);
}
if (getBool("ShowBottom", true)) {
drawBottom(dc, ti, ctx);
}
}
}