Files
fenix8v3-watchface/source/Fenix8V3View.mc
ericwyuan 398018b51e Rebuild the face against the design kit; make fields configurable
Vendor the watchface-kit into design/ and derive everything from it instead
of from hand-transcribed constants.

Geometry, from design/rebuild.py and the ink boxes of the traced SVG paths:
  - ring ticks are annular sectors (fillPolygon), not radial lines
  - top arc is 21 ticks at 6.5+4k, not 22 at 2+4k
  - exact radii, tick widths, anchor dots, band and colon rectangles
  - element positions taken from measured ink bounding boxes

Colour, generated by tools/gen_themes.py straight out of the comps:
  - per-tick colour tables (21+21) replace 9/7 interpolated stops
  - 11-stop vertical gradients on the time digits, drawn with clip banding
  - per-element accent / value colours and per-theme unlit tick colour

Data: the design's sunrise and hi/lo fields turn out to be available after
all -- Weather.getSunrise and getDailyForecast both exist in SDK 9.1.0 --
so both are restored, and heart rate is added alongside them.

Bugs found on the way:
  - Gregorian FORMAT_MEDIUM returns day_of_week/month as strings, so the
    date band crashed on every update (caught in the simulator)
  - Stats.battery is already 0-100, multiplying by 100 gave "4000%"
  - moveBarLevel is an integer level 0..5, not a 0..1 ratio, so the move
    bar arc was all-or-nothing

Also: all four field slots are configurable with labels that follow the
selection, temperature unit is a setting, on-face wording moved into
resources with a Simplified Chinese variant, and 24h/distance/temperature
now follow the watch. Fonts are chosen by measured cap height rather than
by ascent.

Verified in the Connect IQ simulator on fenix847mm and by compiling all
17 target devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:04:27 +08:00

712 lines
32 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;
// 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 = 90.00;
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;
private var mDow = null;
private var mMon = null;
private var mLabels = null;
private var mMeridiem = null;
private var mDateFmt = " ";
function initialize() {
WatchFace.initialize();
}
function onLayout(dc as Dc) as Void {
mW = dc.getWidth();
mH = dc.getHeight();
mS = mW / 500.0;
mCx = mW / 2;
mCy = mH / 2;
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();
}
// Every piece of on-face wording lives in resources so resources-chn can
// replace it. loadResource is not cheap, so pull it all once per layout.
function loadStrings() as Void {
mDow = splitCsv(WatchUi.loadResource(Rez.Strings.DowNames) as String);
mMon = splitCsv(WatchUi.loadResource(Rez.Strings.MonNames) as String);
mLabels = splitCsv(WatchUi.loadResource(Rez.Strings.FieldLabels) as String);
mMeridiem = splitCsv(WatchUi.loadResource(Rez.Strings.Meridiem) as String);
mDateFmt = WatchUi.loadResource(Rez.Strings.DateFormat) as String;
}
// Monkey C has no String.split.
function splitCsv(str as String) as Array<String> {
var out = [] as Array<String>;
var rest = str;
var cut = rest.find(",");
while (cut != null) {
out.add(rest.substring(0, cut) as String);
rest = rest.substring(cut + 1, rest.length()) as String;
cut = rest.find(",");
}
out.add(rest);
return out;
}
// 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 == 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;
}
// ------------------------------------------------------------ data bits
function distanceString(info as ActivityMonitor.Info, ds as System.DeviceSettings) as String {
if (info == null || info.distance == null) { return "0.0"; }
var km = info.distance.toFloat() / 100000.0;
if (ds != null && ds.distanceUnits == System.UNIT_STATUTE) { km = km * 0.621371; }
return km.format("%.1f");
}
// TempUnit: 0 follow the watch, 1 force Celsius, 2 force Fahrenheit.
function tempString(c as Numeric?, ds as System.DeviceSettings) as String {
if (c == null) { return "--"; }
var t = c.toFloat();
var mode = getSetting("TempUnit", 0);
var fahrenheit = (mode == 2)
|| (mode == 0 && ds != null && ds.temperatureUnits == System.UNIT_STATUTE);
if (fahrenheit) { t = t * 9.0 / 5.0 + 32.0; }
return t.format("%d") + "°";
}
function clockString(m as Time.Moment?, ds as System.DeviceSettings) as String {
if (m == null) { return "--:--"; }
var g = Gregorian.info(m, Time.FORMAT_SHORT);
var h = g.hour;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
return h.format("%d") + ":" + g.min.format("%02d");
}
function here(weather as Weather.CurrentConditions?) as Position.Location? {
if (weather != null && weather.observationLocationPosition != null) {
return weather.observationLocationPosition;
}
var act = Activity.getActivityInfo();
if (act != null && act.currentLocation != null) { return act.currentLocation; }
return null;
}
// value string for a complication slot
function complicationValue(typeIdx as Number, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions?, stats as System.Stats,
ds as System.DeviceSettings) as String {
if (typeIdx == 1) { // Calories
return info != null && info.calories != null ? info.calories.format("%d") : "0";
} else if (typeIdx == 2) { // Steps
return info != null && info.steps != null ? info.steps.format("%d") : "0";
} else if (typeIdx == 3) { // Distance
return distanceString(info, ds);
} else if (typeIdx == 4) { // Floors
return info != null && info.floorsClimbed != null
? info.floorsClimbed.format("%d") : "0";
} else if (typeIdx == 5) { // Battery
var pct = 0;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
return pct.format("%d") + "%";
} else if (typeIdx == 6 || typeIdx == 7) { // Sunrise / sunset
var loc = here(weather);
if (loc == null) { return "--:--"; }
var m = typeIdx == 6 ? Weather.getSunrise(loc, Time.now())
: Weather.getSunset(loc, Time.now());
return clockString(m, ds);
} else if (typeIdx == 9) { // Heart rate
var act = Activity.getActivityInfo();
if (act != null && act.currentHeartRate != null) {
return act.currentHeartRate.format("%d");
}
var hist = ActivityMonitor.getHeartRateHistory(1, true);
if (hist != null) {
var sample = hist.next();
if (sample != null && sample.heartRate != null
&& sample.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) {
return sample.heartRate.format("%d");
}
}
return "--";
} else if (typeIdx == 8) { // Forecast high / low
var fc = Weather.getDailyForecast();
if (fc != null && fc.size() > 0) {
return tempString(fc[0].highTemperature, ds) + "/"
+ tempString(fc[0].lowTemperature, ds);
}
return "--/--";
}
// 0: current temperature
if (weather != null && weather.temperature != null) {
return tempString(weather.temperature, ds);
}
return "--";
}
// Short caption under a bottom-row value. Indexed like the field types, so
// the label always follows whatever the slot is set to.
function fieldLabel(typeIdx as Number) as String {
if (mLabels == null || typeIdx < 0 || typeIdx >= mLabels.size()) { return ""; }
return mLabels[typeIdx];
}
// ----------------------------------------------------------------- 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);
}
}
// Filled half-ellipse, flat side down. The comp's dome is 14.90 wide but
// 8.93 tall, so it is not a plain semicircle.
function dome(dc as Dc, cx as Numeric, baseY as Numeric,
rx as Numeric, ry as Numeric) as Void {
var pts = [];
for (var i = 0; i <= 12; i++) {
var rad = Math.toRadians(180.0 - 15.0 * i);
pts.add([Math.round(px(cx) + Math.cos(rad) * px(rx)).toNumber(),
Math.round(px(baseY) - Math.sin(rad) * px(ry)).toNumber()]);
}
dc.fillPolygon(pts);
}
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));
}
// Sun over a horizon bar with a chevron. Every ray in the comp is a short
// stub roughly as long as the pen is wide -- drawing them longer is what
// made this icon read taller than the weather one.
// dir = -1 flips the chevron up, which is what the sunset slot uses.
function iconSun(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number, dir as Number) as Void {
var k = scale;
var ox = cx - 156.32 * k; // design-space offset of this icon
var oy = cy - 90.34 * k;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dome(dc, ox + 156.39 * k, oy + 94.02 * k, 7.45 * k, 8.93 * k);
dc.setPenWidth(maxNum(2, px(2.96 * k)));
stub(dc, ox + 156.39 * k, oy + 79.4 * k, ox + 156.39 * k, oy + 81.1 * k);
stub(dc, ox + 148.2 * k, oy + 83.3 * k, ox + 146.5 * k, oy + 84.5 * k);
stub(dc, ox + 164.6 * k, oy + 84.5 * k, ox + 166.3 * k, oy + 83.3 * k);
stub(dc, ox + 142.6 * k, oy + 92.5 * k, ox + 144.7 * k, oy + 92.5 * k);
stub(dc, ox + 168.0 * k, oy + 92.5 * k, ox + 170.1 * k, oy + 92.5 * k);
dc.setPenWidth(maxNum(2, px(2.6 * k)));
var by = oy + 98.45 * k;
stub(dc, ox + 142.3 * k, by, ox + 170.3 * k, by);
stub(dc, ox + 152.6 * k, by, ox + 156.3 * k, by + dir * 3.05 * k);
stub(dc, ox + 156.3 * k, by + dir * 3.05 * k, ox + 160.0 * k, by);
}
// Sun peeking out from behind a cloud. The comp keeps a dark notch where
// the cloud crosses the sun; without it the two shapes merge into one blob
// and the icon reads much heavier than the sunrise one next to it. So the
// cloud is stamped twice: once inflated in black to cut the notch, then at
// true size in the accent colour.
function cloudBody(dc as Dc, ox as Numeric, oy as Numeric, k as Float,
grow as Float) as Void {
dc.fillCircle(px(ox + 336.6 * k), px(oy + 94.4 * k), px(8.7 * k + grow));
dc.fillCircle(px(ox + 346.4 * k), px(oy + 96.9 * k), px(6.3 * k + grow));
var g = px(grow);
dc.fillRoundedRectangle(px(ox + 328.0 * k) - g, px(oy + 95.5 * k) - g,
px(24.6 * k) + 2 * g, px(7.75 * k) + g,
px(3.4 * k));
}
function iconCloud(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number) as Void {
var k = scale;
var ox = cx - 343.55 * k;
var oy = cy - 89.25 * k;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillCircle(px(ox + 347.0 * k), px(oy + 88.9 * k), px(7.8 * k));
dc.setPenWidth(maxNum(2, px(2.3 * k)));
stub(dc, ox + 346.2 * k, oy + 76.0 * k, ox + 346.2 * k, oy + 78.7 * k);
stub(dc, ox + 354.9 * k, oy + 87.7 * k, ox + 357.5 * k, oy + 87.7 * k);
dc.setPenWidth(maxNum(2, px(2.7 * k)));
stub(dc, ox + 339.5 * k, oy + 79.5 * k, ox + 338.1 * k, oy + 80.9 * k);
stub(dc, ox + 353.5 * k, oy + 80.9 * k, ox + 354.6 * k, oy + 79.8 * k);
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
cloudBody(dc, ox, oy, k, 1.7 * k);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
cloudBody(dc, ox, oy, k, 0.0);
}
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 == 6) {
iconSun(dc, cx, cy, scale, col, 1);
} else if (typeIdx == 7) {
iconSun(dc, cx, cy, scale, col, -1);
} else if (typeIdx == 0 || typeIdx == 8) {
iconCloud(dc, cx, cy, scale, col);
} else if (typeIdx == 5) {
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 == 1) { // 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 == 2) { // steps: two prints
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) { // distance: arrow
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 == 9) { // heart rate
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 { // floors: 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));
}
}
}
}
// ------------------------------------------------------------ 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,
info as ActivityMonitor.Info, weather as Weather.CurrentConditions?,
stats as System.Stats, ds as System.DeviceSettings) as Void {
var x = isLeft ? LEFT_X : RIGHT_X;
var level = 0.0f;
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
drawComplicationIcon(dc, x, ICON_CY, 1.0, Themes.ACCENT[ti] as Number, typeIdx, level);
text(dc, x, VALUE_CY, mFontComp,
complicationValue(typeIdx, info, weather, stats, ds),
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, mDow[g.day_of_week - 1],
Graphics.TEXT_JUSTIFY_CENTER, col);
// DateFormat is "$1$ $2$" in English and "$1$$2$日" in Chinese, where
// the Chinese month names already carry their own 月.
text(dc, MD_CX, BAND_CY, mFontBand,
Lang.format(mDateFmt, [mMon[g.month - 1], g.day.format("%d")]),
Graphics.TEXT_JUSTIFY_CENTER, col);
if (ds == null || !ds.is24Hour) {
text(dc, AMPM_R, BAND_CY, mFontBand, mMeridiem[g.hour >= 12 ? 1 : 0],
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, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions?, stats as System.Stats,
ds as System.DeviceSettings) 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", 2);
text(dc, DIST_R, BOT_VAL_CY, mFontComp,
complicationValue(left, info, weather, stats, ds),
Graphics.TEXT_JUSTIFY_RIGHT, value);
text(dc, DIST_R, BOT_LAB_CY, mFontComp, fieldLabel(left),
Graphics.TEXT_JUSTIFY_RIGHT, label);
text(dc, STEP_L, BOT_VAL_CY, mFontComp,
complicationValue(right, info, weather, stats, ds),
Graphics.TEXT_JUSTIFY_LEFT, value);
text(dc, STEP_L, BOT_LAB_CY, mFontComp, fieldLabel(right),
Graphics.TEXT_JUSTIFY_LEFT, label);
}
// ----------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void {
if (mW != dc.getWidth() || mDow == null) { onLayout(dc); }
if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
var ti = themeIndex();
var info = ActivityMonitor.getInfo();
var stats = System.getSystemStats();
var ds = System.getDeviceSettings();
var weather = Weather.getCurrentConditions();
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
dc.clear();
drawRing(dc, ti,
metricProgress(getSetting("RingTR", 0), info),
metricProgress(getSetting("RingTL", 1), info),
metricProgress(getSetting("RingBR", 4), info),
metricProgress(getSetting("RingBL", 3), info));
if (getBool("ShowTop", true)) {
drawBattery(dc, ti, stats);
drawTopComplication(dc, ti, getSetting("LeftTop", 6), true, info, weather, stats, ds);
drawTopComplication(dc, ti, getSetting("RightTop", 8), false, info, weather, stats, ds);
}
drawTime(dc, ti, ds);
if (getBool("ShowBand", true)) {
drawDateBand(dc, ti, ds);
}
if (getBool("ShowBottom", true)) {
drawBottom(dc, ti, info, weather, stats, ds);
}
}
}