import Toybox.Lang; import Toybox.Math; import Toybox.System; import Toybox.Time; import Toybox.Time.Gregorian; import Toybox.Application; import Toybox.ActivityMonitor; import Toybox.Activity; import Toybox.UserProfile; import Toybox.SensorHistory; 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 // tools/gen_fields.py for the table that drives the settings and the labels. // // Only fields Connect IQ can answer from the watch itself are here. The // week-to-date and 7/28-day per-sport aggregates in that catalogue come from a // phone-side Garmin Connect integration, and the OpenWeatherMap / StormGlass / // third-party-app fields need network calls and API keys; neither is something // a self-contained watch face can produce. module Fields { const DASH = "--"; var mDow as Array?; var mMon as Array?; var mLabels as Array?; var mMeridiem as Array?; var mDateFmt as String = "$1$ $2$"; 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; } function ready() as Boolean { return mDow != null; } // Monkey C has no String.split. function splitCsv(str as String) as Array { var out = [] as Array; 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; } function dow(g as Gregorian.Info) as String { return mDow == null ? "" : mDow[g.day_of_week - 1]; } function month(g as Gregorian.Info) as String { return mMon == null ? "" : mMon[g.month - 1]; } function monthDay(g as Gregorian.Info) as String { 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]; } // Short caption printed under a bottom-row value. function label(id as Number) as String { if (mLabels == null) { return ""; } var i = FieldTable.indexOf(id); return i < mLabels.size() ? mLabels[i] : ""; } // One of these is built per onUpdate and handed to every slot, so the // expensive lookups (forecast, sensor history, user profile) happen at most // once per draw and only if some slot actually asks for them. class Ctx { var greg as Gregorian.Info; var is24 as Boolean = true; var metric as Boolean = true; var fahrenheit as Boolean = false; private var mInfo as ActivityMonitor.Info?; private var mStats as System.Stats?; private var mDs as System.DeviceSettings?; private var mAct as Activity.Info?; private var mWx as Weather.CurrentConditions?; private var mFc as Array?; private var mProf as UserProfile.Profile?; private var mGotAct = false; private var mGotWx = false; private var mGotFc = false; private var mGotProf = false; function initialize(ds as System.DeviceSettings?, tempUnit as Number) { mDs = ds; greg = Gregorian.info(Time.now(), Time.FORMAT_SHORT); if (ds != null) { is24 = ds.is24Hour; metric = (ds.distanceUnits != System.UNIT_STATUTE); fahrenheit = (tempUnit == 2) || (tempUnit == 0 && ds.temperatureUnits == System.UNIT_STATUTE); } if (tempUnit == 1) { fahrenheit = false; } } function ds() as System.DeviceSettings? { return mDs; } function info() as ActivityMonitor.Info? { if (mInfo == null) { mInfo = ActivityMonitor.getInfo(); } return mInfo; } function stats() as System.Stats? { if (mStats == null) { mStats = System.getSystemStats(); } return mStats; } function act() as Activity.Info? { if (!mGotAct) { mAct = Activity.getActivityInfo(); mGotAct = true; } return mAct; } function wx() as Weather.CurrentConditions? { if (!mGotWx) { mWx = Weather.getCurrentConditions(); mGotWx = true; } return mWx; } function forecast() as Weather.DailyForecast? { if (!mGotFc) { mFc = Weather.getDailyForecast(); mGotFc = true; } if (mFc != null && mFc.size() > 0) { return mFc[0]; } return null; } function profile() as UserProfile.Profile? { if (!mGotProf) { mProf = UserProfile.getProfile(); mGotProf = true; } return mProf; } function where() as Position.Location? { var w = wx(); if (w != null && w.observationLocationPosition != null) { return w.observationLocationPosition; } var a = act(); if (a != null && a.currentLocation != null) { return a.currentLocation; } return null; } } // ------------------------------------------------------------- helpers function num(v as Numeric?) as String { return v == null ? DASH : v.format("%d"); } function one(v as Float?) as String { return v == null ? DASH : v.format("%.1f"); } function clock(m as Time.Moment?, c as Ctx) as String { if (m == null) { return DASH; } return clockInfo(Gregorian.info(m, Time.FORMAT_SHORT), c); } function clockInfo(g as Gregorian.Info, c as Ctx) as String { var h = g.hour; if (!c.is24) { if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; } } return h.format("%d") + ":" + g.min.format("%02d"); } function offsetClock(minutes as Number, c as Ctx) as String { var m = Time.now().add(new Time.Duration(minutes * 60)); return clock(m, c); } function temp(celsius as Numeric?, c as Ctx) as String { if (celsius == null) { return DASH; } var t = celsius.toFloat(); if (c.fahrenheit) { t = t * 9.0 / 5.0 + 32.0; } return t.format("%d") + "°"; } // ActivityMonitor distances are centimetres. function dist(cm as Numeric?, c as Ctx) as String { if (cm == null) { return DASH; } var km = cm.toFloat() / 100000.0; return (c.metric ? km : km * 0.621371).format("%.1f"); } function metres(m as Numeric?, c as Ctx) as String { if (m == null) { return DASH; } var v = m.toFloat(); return (c.metric ? v : v * 3.28084).format("%d"); } function rounded(v as Numeric?) as String { if (v == null) { return DASH; } var n = v.toNumber(); if (n < 1000) { return n.format("%d"); } return (n / 1000.0).format("%.1f") + "k"; } // Newest reading out of a SensorHistory iterator, or null. function latest(iter) as Numeric? { if (iter == null) { return null; } var s = iter.next(); if (s == null) { return null; } return s.data; } function dayOfYear(g as Gregorian.Info) as Number { var cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; var d = cum[g.month - 1] + g.day; var y = g.year; var leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); if (leap && g.month > 2) { d = d + 1; } return d; } // ISO 8601 week: weeks start Monday, week 1 holds the first Thursday. function isoWeek(g as Gregorian.Info) as Number { var dow = g.day_of_week - 1; // 0 = Sunday var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday var w = (dayOfYear(g) - iso + 10) / 7; if (w < 1) { return 52; } if (w > 52) { return 1; } return w; } // Days since the new moon of 2000-01-06, folded into one synodic month. function moonAge(g as Gregorian.Info) as Float { var y = g.year; var m = g.month; if (m <= 2) { y = y - 1; m = m + 12; } var a = y / 100; var b = 2 - a + a / 4; var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber() + g.day + b - 1524.5 + g.hour / 24.0; // Monkey C has no float modulo. var syn = 29.530588853; var x = jd - 2451550.1; var age = x - (x / syn).toNumber() * syn; if (age < 0) { age = age + syn; } return age.toFloat(); } function windDir(bearingDeg as Numeric?) as String { if (bearingDeg == null) { return DASH; } var pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; var b = bearingDeg.toFloat(); while (b < 0) { b = b + 360.0; } var i = ((b + 11.25) / 22.5).toNumber() % 16; return pts[i]; } // --------------------------------------------------------------- value // 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(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"); } if (id == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); } if (id == 870) { return dow(c.greg); } 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) + " " + dayOnly(c.greg); } if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } if (id == 858) { return meridiem(c.greg); } 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); } if (id == 707) { if (i == null || i.steps == null || i.stepGoal == null) { return DASH; } var left = i.stepGoal - i.steps; return left > 0 ? left.format("%d") : "0"; } if (id == 2) { return i == null ? DASH : num(i.calories); } if (id == 603) { if (i == null || i.calories == null) { return DASH; } var p = c.profile(); if (p == null || p.weight == null || p.height == null) { return num(i.calories); } return num(i.calories); // device reports total only } if (id == 150 || id == 152 || id == 154) { 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 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); } // 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 || !(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); } 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); } var h = ActivityMonitor.getHeartRateHistory(1, true); if (h != null) { var s = h.next(); if (s != null && s.heartRate != null && s.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) { return num(s.heartRate); } } return DASH; } if (id == 276) { var p = c.profile(); return (p == null || !(p has :restingHeartRate)) ? DASH : num(p.restingHeartRate); } if (id == 635) { if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; } return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1}))); } if (id == 623) { if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; } return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1}))); } if (id == 281 || id == 742) { var p = c.profile(); 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 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; } if (id == 278) { return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH; } return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH; } 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 : st.battery.format("%d") + "%"; } if (id == 251) { if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) { return DASH; } return st.batteryInDays.format("%d") + "d"; } if (id == 257) { var pct = (st == null || st.battery == null) ? DASH : st.battery.format("%d"); var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null) ? DASH : st.batteryInDays.format("%d"); return pct + "/" + d; } if (id == 252) { if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) { return DASH; } return st.solarIntensity.format("%d") + "%"; } var ds = c.ds(); if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); } if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); } if (id == 254) { return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF"); } if (id == 256) { return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF"); } 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 || !(a has :altitude)) ? DASH : metres(a.altitude, c); } if (id == 616 || id == 733) { var a = c.act(); if (a == null) { return DASH; } 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"); } if (id == 712 || id == 717 || id == 700 || id == 731) { var loc = c.where(); if (loc == null) { return DASH; } var now = Time.now(); var rise = Weather.getSunrise(loc, now); var set = Weather.getSunset(loc, now); if (id == 712) { return clock(rise, c); } if (id == 717) { return clock(set, c); } if (rise == null || set == null) { return DASH; } var next = (now.compare(rise) < 0) ? rise : ((now.compare(set) < 0) ? set : rise); if (id == 700) { return clock(next, c); } var secs = next.compare(now); if (secs < 0) { secs = secs + 86400; } return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d"); } if (id == 706) { return moonAge(c.greg).format("%.1f"); } if (id == 732) { var age = moonAge(c.greg); var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0; return (lit * 100.0).format("%d") + "%"; } 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 || !(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 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 || !(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 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; } 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); } return null; } // 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; } }