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

@@ -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;
}
}