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>
633 lines
28 KiB
MonkeyC
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);
|
|
}
|
|
}
|
|
}
|