diff --git a/README.md b/README.md index 7213e0e..97abbea 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,12 @@ fenix8v3-watchface/ │ ├── gen_icons.py # design/*.svg -> 各尺寸图标位图 + 启动图标 │ └── gen_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html ├── source/ -│ ├── Fenix8V3App.mc # 应用入口 -│ ├── Fenix8V3View.mc # 渲染逻辑(含常亮模式) +│ ├── Fenix8V3App.mc # 应用入口(12 行壳子) +│ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式 +│ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制 +│ ├── Icons.mc # 图标:位图三件套 + 其余图元 │ ├── Fields.mc # 75 项数据字段的取值与格式化 +│ ├── Settings.mc # 所有设置项的读取与默认值 │ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表 │ └── Themes.mc # ⚠️ 自动生成:七套主题配色 ├── resources/ # 英文文案 + 设置项定义(部分自动生成) @@ -50,6 +53,23 @@ python3 tools/gen_themes.py && python3 tools/gen_fields.py \ --- +## 1.5 代码结构 + +一句话:**位置归 `Layout`,内容归 `Fields`,配色归 `Themes`,设置归 `Settings`,`Fenix8V3View` 只剩画的顺序。** + +| 模块 | 负责 | 不负责 | +|------|------|--------| +| `Layout` | 500 基准画布的全部坐标常量、`px()` 缩放、极坐标换算、字号选择与自动缩字、渐变文字的分带绘制 | 具体画什么 | +| `Icons` | 三个位图图标的加载与染色、电池、蓝牙、其余数据项的图元 | 图标该出现在哪个数据位(由字段 id 决定,但查表在这里) | +| `Fields` | 75 项字段的取值、单位换算、本地化文案(星期/月份/标签) | 排版 | +| `Settings` | 每个设置项的读取与默认值,包含空值回退 | 设置项的 UI 定义(在 `settings.xml`) | +| `Fenix8V3View` | `onUpdate` 的绘制顺序、进度环、常亮模式 | 以上任何一项的细节 | + +`Fields.value()` 按分类拆成 7 个函数(`dateTime` / `activity` / `body` / `system` / `environment` / `weather` / `custom`),每个在不认识 id 时返回 `null`,`value()` 依次尝试。id 是交错的(1、2、3 是活动,8、11、12 是身体,14 又是活动),所以按区间派发行不通。 + +`Fields.Ctx` 每次绘制构造一个,把 `ActivityMonitor` / `Weather` / `UserProfile` / `SensorHistory` 的查询**懒加载并缓存**——四个数据位可能都要天气,但只查一次;没有数据位用到 `UserProfile` 时就完全不查。 + + ## 2. 布局(500 基准,取自设计稿 SVG 路径的墨迹包围盒) | 元素 | 位置 | 说明 | @@ -61,7 +81,7 @@ python3 tools/gen_themes.py && python3 tools/gen_fields.py \ | 时间 | `cy=221.10`;小时右对齐 `x=211.40`,分钟左对齐 `x=276.02` | 竖向渐变,见 §4 | | 冒号 | `x=237.88`,`24.24×21.30`,`y=183.68 / 234.57` | 两块矩形 | | 日期带 | `y=286.30`,高 `44.19`,文字 `cy=308.10` | 蓝牙 `cx=36.88`、星期 `cx=178.05`、月日 `cx=298.28`、AM/PM 右对齐 `465.89` | -| 底部数值 | `cy=366.76`;距离右对齐 `234.30`,步数左对齐 `267.40` | | +| 底部数值 | `cy=366.76`;左格右对齐 `234.30`,右格左对齐 `267.40` | 过宽时自动降一档字号 | | 底部标签 | `cy=410.88`,同上对齐 | 跟随所选数据(`DST` / `STEP` / `距离` …) | 字号按设计稿的**字面高度(cap height)**选取:日期带与电量 `25.34`、上方数据与底部行 `31.50`、时间 `98.70`。 diff --git a/source/Fenix8V3App.mc b/source/Fenix8V3App.mc index 409d16d..b9bfa66 100644 --- a/source/Fenix8V3App.mc +++ b/source/Fenix8V3App.mc @@ -2,6 +2,9 @@ import Toybox.Application; import Toybox.Lang; import Toybox.WatchUi; +// Application shell. A watch face has no menus or input of its own, so this +// only hands back the view and forces a repaint when the wearer changes +// something in the Connect IQ settings. class Fenix8V3App extends Application.AppBase { function initialize() { @@ -18,6 +21,8 @@ class Fenix8V3App extends Application.AppBase { return [new Fenix8V3View()]; } + // Fires when the phone pushes new settings down. The view reads every + // preference fresh on each draw, so a repaint is all that is needed. function onSettingsChanged() as Void { WatchUi.requestUpdate(); } diff --git a/source/Fenix8V3View.mc b/source/Fenix8V3View.mc index a2d6500..72013ec 100644 --- a/source/Fenix8V3View.mc +++ b/source/Fenix8V3View.mc @@ -1,103 +1,30 @@ -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.SensorHistory; import Toybox.System; import Toybox.Time; import Toybox.Time.Gregorian; import Toybox.WatchUi; import Themes; import Fields; -import Toybox.SensorHistory; +import Layout; +import Icons; +import Settings; -// 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. +// The face itself: the progress ring, the elements laid over it, and the +// stripped-down variant drawn while an AMOLED watch is asleep. +// +// Anything about *where* things go lives in Layout, anything about *what* a +// data slot says lives in Fields, and the colours come from Themes. What is +// left here is the drawing order. 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. + // Always-on display state. AMOLED panels have to cut their lit pixel + // count while asleep; MIP panels do not care and are left alone. private var mSleeping = false; private var mBurnIn = false; - private var mIconSunrise = null; - private var mIconSunset = null; - private var mIconWeather = null; function initialize() { WatchFace.initialize(); @@ -113,157 +40,49 @@ class Fenix8V3View extends WatchUi.WatchFace { WatchUi.requestUpdate(); } + // Called once when the face is loaded, and again by onUpdate if anything + // it caches has gone stale (a resolution change, or resources not yet in). function onLayout(dc as Dc) as Void { - mW = dc.getWidth(); - mH = dc.getHeight(); - mS = mW / 500.0; - mCx = mW / 2; - mCy = mH / 2; + Layout.init(dc); + Fields.loadStrings(); + Icons.load(); 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, 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) 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 { + // ------------------------------------------------------------- ring + // Progress for one of the four arcs, 0..1. These ids are the ring's own + // short list, not the data field catalogue. + function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float { if (info == null) { return 0.0f; } var p = 0.0f; - if (idx == 0) { // Steps + 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 + } else if (idx == 1) { // calories if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; } - } else if (idx == 2) { // Distance (cm -> 10 km cap) + } else if (idx == 2) { // distance, 10 km ring if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; } - } else if (idx == 3) { // Floors + } 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 + } else if (idx == 5) { // active minutes vs week goal + if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null + && (info has :activeMinutesWeekGoal) + && info.activeMinutesWeekGoal != null && info.activeMinutesWeekGoal > 0) { p = info.activeMinutesWeek.total.toFloat() / info.activeMinutesWeekGoal.toFloat(); } - } else if (idx == 6) { // Body battery is already 0..100 + } 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 + } else if (idx == 4) { // move bar: a level, not a ratio if (info.moveBarLevel != null) { var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX - ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat(); @@ -277,14 +96,17 @@ class Fenix8V3View extends WatchUi.WatchFace { return p; } - // ----------------------------------------------------------------- ring + // One tick: an annular sector drawn as a quad. At three degrees the chord + // sagitta is under a tenth of a pixel, so straight edges are exact enough. 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)]]); + var ro = Layout.R_OUT * Layout.scale; + var ri = Layout.R_IN * Layout.scale; + dc.fillPolygon([[Layout.polarX(a0, ro), Layout.polarY(a0, ro)], + [Layout.polarX(a1, ro), Layout.polarY(a1, ro)], + [Layout.polarX(a1, ri), Layout.polarY(a1, ri)], + [Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]); } // Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs @@ -306,178 +128,104 @@ class Fenix8V3View extends WatchUi.WatchFace { var bot = Themes.RINGBOTTOM[ti] as Array; 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); + arc(dc, top, off, (pTR * Layout.TOP_N).toNumber(), Layout.TOP_PH, + Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, false, true); + arc(dc, top, off, (pTL * Layout.TOP_N).toNumber(), Layout.TOP_PH, + Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, true, true); + arc(dc, bot, off, (pBL * Layout.BOT_N).toNumber(), Layout.BOT_PH, + Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, false, false); + arc(dc, bot, off, (pBR * Layout.BOT_N).toNumber(), Layout.BOT_PH, + Layout.BOT_PITCH, Layout.BOT_HW, Layout.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); + var r = Layout.px(Layout.DOT_R); + var rad = Layout.DOT_RAD * Layout.scale; + for (var i = 0; i < Layout.DOT_ANG.size(); i++) { + var a = Layout.DOT_ANG[i]; + dc.fillCircle(Layout.polarX(a, rad), Layout.polarY(a, rad), 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); - } + // ---------------------------------------------------------- elements + function drawBattery(dc as Dc, ti as Number, level as Float) as Void { + Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0, + Themes.ACCENT[ti] as Number, level); + Layout.text(dc, Layout.BATT_VX, Layout.BATT_CY, Layout.fontBand, + (level * 100.0).toNumber().format("%d") + "%", + Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number); } - 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)); + function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean, + ctx as Fields.Ctx, level as Float) as Void { + var x = isLeft ? Layout.LEFT_X : Layout.RIGHT_X; + Icons.forField(dc, id, x, Themes.ACCENT[ti] as Number, level); + Layout.fittedText(dc, x, Layout.VALUE_CY, Layout.fontComp, + Fields.value(id, ctx), Graphics.TEXT_JUSTIFY_CENTER, + Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W); } - // 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 { + 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)); + dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width, + Layout.px(Layout.BAND_H)); var col = Themes.BANDTEXT[ti] as Number; - drawBluetooth(dc, (ds != null && !ds.phoneConnected) - ? blend(col, fill, 0.6) : col); + // Dim the rune rather than hide it, so the band does not change shape + // every time the phone wanders out of range. + Icons.bluetooth(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); + Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand, + Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col); + Layout.text(dc, Layout.MD_CX, Layout.BAND_CY, Layout.fontBand, + 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); + Layout.text(dc, Layout.AMPM_R, Layout.BAND_CY, Layout.fontBand, + Fields.meridiem(g), Graphics.TEXT_JUSTIFY_RIGHT, col); } } + 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; } + } + Layout.gradText(dc, Layout.HOURS_R, Layout.TIME_CY, Layout.fontTime, + h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT, + Themes.HOURS[ti] as Array); + Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime, + g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT, + Themes.MINUTES[ti] as Array); + dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y1), + Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); + dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y2), + Layout.px(Layout.COLON_W), Layout.px(Layout.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 = Settings.bottomLeft(); + var right = Settings.bottomRight(); + Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_VAL_CY, Layout.fontComp, + Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT, + value, Layout.BOT_MAX_W); + Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_LAB_CY, Layout.fontComp, + Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT, + label, Layout.BOT_MAX_W); + Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_VAL_CY, Layout.fontComp, + Fields.value(right, ctx), Graphics.TEXT_JUSTIFY_LEFT, + value, Layout.BOT_MAX_W); + Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_LAB_CY, Layout.fontComp, + Fields.label(right), Graphics.TEXT_JUSTIFY_LEFT, + label, Layout.BOT_MAX_W); + } + + // ------------------------------------------------------------ colour function blend(c1 as Number, c2 as Number, f as Float) as Number { var r1 = (c1 >> 16) & 0xFF; var g1 = (c1 >> 8) & 0xFF; @@ -488,37 +236,6 @@ class Fenix8V3View extends WatchUi.WatchFace { 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); - gradText(dc, MINUTES_L, TIME_CY, mFontTime, g.min.format("%02d"), - Graphics.TEXT_JUSTIFY_LEFT, Themes.MINUTES[ti] as Array); - 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(); @@ -526,41 +243,42 @@ class Fenix8V3View extends WatchUi.WatchFace { return (r << 16) | (g << 8) | b; } - // Low-power face: no band fill, no unlit ticks, no complications -- just a + // --------------------------------------------------------------- AOD + // Low-power face: no band fill, no unlit ticks, no data slots -- 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, + 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 ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // +/- 4 device px + var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale; var top = Themes.RINGTOP[ti] as Array; var bot = Themes.RINGBOTTOM[ti] as Array; - 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) { + var litTR = (pTR * Layout.TOP_N).toNumber(); + var litTL = (pTL * Layout.TOP_N).toNumber(); + var litBR = (pBR * Layout.BOT_N).toNumber(); + var litBL = (pBL * Layout.BOT_N).toNumber(); + for (var i = 0; i < Layout.TOP_N; i++) { + var a = Layout.TOP_PH + Layout.TOP_PITCH * i; + if (i >= Layout.TOP_N - litTR) { dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); - tick(dc, a, TOP_HW); + tick(dc, a, Layout.TOP_HW); } - if (i >= TOP_N - litTL) { + if (i >= Layout.TOP_N - litTL) { dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); - tick(dc, -a, TOP_HW); + tick(dc, -a, Layout.TOP_HW); } } - for (var i = 0; i < BOT_N; i++) { - var a = BOT_PH + BOT_PITCH * i; + for (var i = 0; i < Layout.BOT_N; i++) { + var a = Layout.BOT_PH + Layout.BOT_PITCH * i; if (i < litBL) { dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); - tick(dc, 180.0 + a, BOT_HW); + tick(dc, 180.0 + a, Layout.BOT_HW); } if (i < litBR) { dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); - tick(dc, 180.0 - a, BOT_HW); + tick(dc, 180.0 - a, Layout.BOT_HW); } } @@ -570,39 +288,50 @@ class Fenix8V3View extends WatchUi.WatchFace { } var hours = Themes.HOURS[ti] as Array; var mins = Themes.MINUTES[ti] as Array; - 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)); + Layout.text(dc, Layout.HOURS_R + ox, Layout.TIME_CY + oy, Layout.fontTime, + h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT, + dim(hours[hours.size() / 2], 0.55)); + Layout.text(dc, Layout.MINUTES_L + ox, Layout.TIME_CY + oy, Layout.fontTime, + 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)); + dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y1 + oy), + Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H)); + dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y2 + oy), + Layout.px(Layout.COLON_W), Layout.px(Layout.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); + Layout.text(dc, Layout.DOW_CX + ox, Layout.BAND_CY + oy, Layout.fontBand, + Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, band); + Layout.text(dc, Layout.MD_CX + ox, Layout.BAND_CY + oy, Layout.fontBand, + Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band); } - // ----------------------------------------------------------------- main + // -------------------------------------------------------------- main function onUpdate(dc as Dc) as Void { - if (mW != dc.getWidth() || !Fields.ready()) { onLayout(dc); } + if (!Layout.ready() || Layout.width != dc.getWidth() + || !Fields.ready() || !Icons.ready()) { + onLayout(dc); + } if (dc has :setAntiAlias) { dc.setAntiAlias(true); } - var ti = themeIndex(); + var ti = Settings.themeIndex(Themes.NAMES.size()); var ds = System.getDeviceSettings(); - var ctx = new Fields.Ctx(ds, getSetting("TempUnit", 0)); + var ctx = new Fields.Ctx(ds, Settings.tempUnit()); var info = ctx.info(); var stats = ctx.stats(); + 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; } 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); + var pTR = ringProgress(Settings.ringTR(), info); + var pTL = ringProgress(Settings.ringTL(), info); + var pBR = ringProgress(Settings.ringBR(), info); + var pBL = ringProgress(Settings.ringBL(), info); if (mSleeping && mBurnIn) { drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL); @@ -611,21 +340,19 @@ class Fenix8V3View extends WatchUi.WatchFace { 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); + if (Settings.showTop()) { + drawBattery(dc, ti, level); + drawTopSlot(dc, ti, Settings.leftTop(), true, ctx, level); + drawTopSlot(dc, ti, Settings.rightTop(), false, ctx, level); } drawTime(dc, ti, ds); - if (getBool("ShowBand", true)) { + if (Settings.showBand()) { drawDateBand(dc, ti, ds); } - if (getBool("ShowBottom", true)) { + if (Settings.showBottom()) { drawBottom(dc, ti, ctx); } } diff --git a/source/Fields.mc b/source/Fields.mc index 3cca859..96cac87 100644 --- a/source/Fields.mc +++ b/source/Fields.mc @@ -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; } } diff --git a/source/Icons.mc b/source/Icons.mc new file mode 100644 index 0000000..7a20921 --- /dev/null +++ b/source/Icons.mc @@ -0,0 +1,142 @@ +import Toybox.Lang; +import Toybox.Graphics; +import Toybox.WatchUi; +import Layout; + +// The glyphs that sit above the two top data slots, plus the battery and the +// bluetooth rune. +// +// The comps contain three icons -- sunrise, weather, battery. The first two +// are traced outlines whose finest features are two or three design pixels +// across; rebuilding them from circles and lines looked right at 454 px and +// turned to mush at 280. They ship instead as bitmaps rasterised from the +// design paths once per screen width (tools/gen_icons.py) and are tinted with +// the theme's accent at draw time. +// +// Everything else -- steps, calories, stairs, heart -- has no counterpart in +// the comps, so those stay as primitives sized off the same design canvas. +module Icons { + + var mSunrise = null; + var mSunset = null; + var mWeather = null; + + function load() as Void { + mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise); + mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset); + mWeather = WatchUi.loadResource(Rez.Drawables.IconWeather); + } + + function ready() as Boolean { return mSunrise != null; } + + // drawBitmap2 refuses a palettised source, which is why the generated + // drawables carry packingFormat="png" to keep their alpha channel. + function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void { + if (bmp == null) { return; } + dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2, + Layout.px(Layout.ICON_CY) - bmp.getHeight() / 2, + bmp, {:tintColor => col}); + } + + // Outline, charge bar and terminal. The bar tracks the real level, the + // way the comp draws it at 40%. + function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, + k as Float, col as Number, level as Float) as Void { + var x0 = Layout.px(x); + var y0 = Layout.px(cyDesign - Layout.BATT_H * k / 2); + var w = Layout.px(Layout.BATT_W * k); + var h = Layout.px(Layout.BATT_H * k); + var wall = Layout.px(Layout.BATT_WALL * k); + dc.setColor(col, Graphics.COLOR_TRANSPARENT); + dc.fillRoundedRectangle(x0, y0, w, h, Layout.px(3.4 * k)); + dc.fillRectangle(x0 + w, Layout.px(cyDesign - Layout.BATT_TH * k / 2), + Layout.px(Layout.BATT_TW * k), Layout.px(Layout.BATT_TH * k)); + var iw = w - 2 * wall; + var ih = h - 2 * wall; + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(x0 + wall, y0 + wall, iw, ih); + var fill = (iw * level).toNumber(); + if (fill > 0) { + dc.setColor(col, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(x0 + wall, y0 + wall, fill, ih); + } + } + + // The standard bluetooth rune, drawn as one polyline. + function bluetooth(dc as Graphics.Dc, col as Number) as Void { + dc.setColor(col, Graphics.COLOR_TRANSPARENT); + dc.setPenWidth(Layout.pen(3.2)); + var l = Layout.BT_CX - Layout.BT_HW; + var r = Layout.BT_CX + Layout.BT_HW; + var t = Layout.BT_CY - Layout.BT_HH; + var b = Layout.BT_CY + Layout.BT_HH; + var qt = Layout.BT_CY - Layout.BT_HH / 2; + var qb = Layout.BT_CY + Layout.BT_HH / 2; + Layout.line(dc, l, qt, r, qb); + Layout.line(dc, r, qb, Layout.BT_CX, b); + Layout.line(dc, Layout.BT_CX, b, Layout.BT_CX, t); + Layout.line(dc, Layout.BT_CX, t, r, qt); + Layout.line(dc, r, qt, l, qb); + } + + // Which glyph, if any, belongs above a given field id. Fields with no + // natural icon -- dates, week numbers, custom text -- read fine bare. + function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric, + col as Number, level as Float) as Void { + if (id == 712 || id == 700) { + blit(dc, mSunrise, xDesign, col); + return; + } + if (id == 717 || id == 731) { + blit(dc, mSunset, xDesign, col); + return; + } + if (id == 306 || id == 639 || id == 300 || id == 301 || id == 302 + || id == 304 || id == 305 || id == 307 || id == 308 + || id == 309 || id == 310) { + blit(dc, mWeather, xDesign, col); + return; + } + if (id == 250 || id == 251 || id == 257) { + battery(dc, xDesign - 14.4, Layout.ICON_CY, 1.0, col, level); + return; + } + var r = 13.0; + var cyd = Layout.ICON_CY; + dc.setColor(col, Graphics.COLOR_TRANSPARENT); + dc.setPenWidth(Layout.pen(3.0)); + if (id == 2 || id == 603) { // calories: flame + dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)], + [Layout.px(xDesign + r * 0.72), Layout.px(cyd + r * 0.25)], + [Layout.px(xDesign + r * 0.40), Layout.px(cyd + r * 0.85)], + [Layout.px(xDesign - r * 0.40), Layout.px(cyd + r * 0.85)], + [Layout.px(xDesign - r * 0.72), Layout.px(cyd + r * 0.25)]]); + } else if (id == 1 || id == 14 || id == 707) { // steps: two prints + dc.fillRoundedRectangle(Layout.px(xDesign - r * 0.85), Layout.px(cyd - r * 0.75), + Layout.px(r * 0.62), Layout.px(r * 1.15), + Layout.px(r * 0.3)); + dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35), + Layout.px(r * 0.62), Layout.px(r * 1.15), + Layout.px(r * 0.3)); + } else if (id == 3 || id == 9 || id == 615) { // distance: arrow + dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)], + [Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)], + [Layout.px(xDesign), Layout.px(cyd + r * 0.35)], + [Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]); + } else if (id == 618 || id == 276 || id == 623) { // heart + dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28), + Layout.px(r * 0.44)); + dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28), + Layout.px(r * 0.44)); + dc.fillPolygon([[Layout.px(xDesign - r * 0.78), Layout.px(cyd - r * 0.12)], + [Layout.px(xDesign + r * 0.78), Layout.px(cyd - r * 0.12)], + [Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]); + } else if (id == 4 || id == 5 || id == 6) { // floors: stairs + for (var i = 0; i < 3; i++) { + dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i), + Layout.px(cyd + r * 0.6 - r * 0.55 * i), + Layout.px(r * 0.62), Layout.px(r * 0.4)); + } + } + } +} diff --git a/source/Layout.mc b/source/Layout.mc new file mode 100644 index 0000000..e910132 --- /dev/null +++ b/source/Layout.mc @@ -0,0 +1,235 @@ +import Toybox.Lang; +import Toybox.Graphics; +import Toybox.Math; + +// Geometry and text plumbing for the face. +// +// Every literal here is a coordinate on the design's 500x500 canvas, measured +// from design/watchface-*.svg -- ring numbers come from design/rebuild.py, the +// element positions are the ink bounding boxes of the traced SVG paths. At +// draw time everything is multiplied by s = screenWidth / 500, so one set of +// numbers serves screens from 240 px (fenix 7S) to 454 px (venu 3). +// +// Call init() from the view's onLayout before anything else touches this. +module Layout { + + // ---------------------------------------------------------------- ring + // Ticks are annular sectors of constant angular width, not radial lines, + // which is why they are drawn as four-point polygons rather than strokes. + const R_IN = 231.09; // inner edge of the tick band + const R_OUT = 249.10; // outer edge + const TOP_N = 21; // ticks per top arc + 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; // ticks per bottom arc + 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; // anchor dots sit on the band's mid-radius + const DOT_R = 9.00; + const DOT_ANG = [0.0, 93.15, 180.0, 266.85]; + + // ------------------------------------------------------------- elements + const BATT_X = 192.70; // battery body, left edge + const BATT_W = 26.20; // body only; the terminal sits to its right + const BATT_H = 17.81; + const BATT_WALL = 2.20; // outline thickness + 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 data slots, centred + const RIGHT_X = 343.70; + const ICON_CY = 89.50; // matches the icon bitmaps' own centre + 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; // bluetooth rune + const BT_CY = 306.99; + const BT_HW = 8.89; + 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 left-hand bottom lines + const STEP_L = 267.40; // left edge of both right-hand bottom lines + + // Cap heights of the design's three text sizes. + const CAP_BAND = 25.34; // date band, battery percentage + const CAP_COMP = 31.50; // data slots, bottom row + const CAP_TIME = 98.70; // time digits + + // How much room a value may take before it is stepped down a size. The + // top slots are centred, so this is the full width they may span; the + // bottom pair are edge-aligned and share the middle of the dial. + const TOP_MAX_W = 185.0; + const BOT_MAX_W = 150.0; + + // Connect IQ exposes ascent and descent but not cap height, and the ascent + // reserves room for diacritics that no digit or capital ever uses. These + // two ratios were measured by rendering into the simulator and reading the + // ink back off the screenshot (fenix847mm: FONT_XTINY ascent 29 -> caps + // 22 px; FONT_NUMBER_MILD ascent 82 -> digits 58 px). + const CAP_OF_TEXT = 0.76; + const CAP_OF_NUM = 0.71; + + // --------------------------------------------------------------- state + var scale = 1.0; // device px per design px + var width = 0; + var height = 0; + var cx = 0; + var cy = 0; + var fontBand = Graphics.FONT_SMALL; + var fontComp = Graphics.FONT_MEDIUM; + var fontTime = Graphics.FONT_NUMBER_HOT; + + // Text sizes in ascending order, so a value that will not fit can walk + // down the list until it does. + var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL, + Graphics.FONT_MEDIUM, Graphics.FONT_LARGE]; + + function init(dc as Graphics.Dc) as Void { + width = dc.getWidth(); + height = dc.getHeight(); + scale = width / 500.0; + cx = width / 2; + cy = height / 2; + var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM, + Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT]; + fontBand = pickFont(textFonts, CAP_BAND * scale, CAP_OF_TEXT); + fontComp = pickFont(textFonts, CAP_COMP * scale, CAP_OF_TEXT); + fontTime = pickFont(nums, CAP_TIME * scale, CAP_OF_NUM); + } + + function ready() as Boolean { return width > 0; } + + // Design pixels to device pixels. + function px(v as Numeric) as Number { + return Math.round(v * scale).toNumber(); + } + + function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float { + return Graphics.getFontAscent(font) * ratio; + } + + // Closest cap height wins -- not "largest that still fits", which throws + // away a whole size whenever the design falls between two fonts. + function pickFont(cands as Array, 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; + } + + // A long value -- twelve characters of custom text, say -- would run off + // the dial at the design's size. Step down until it fits the slot. + function fitFont(dc as Graphics.Dc, str as String, + font as Graphics.FontDefinition, maxDesignW as Float) + as Graphics.FontDefinition { + var limit = px(maxDesignW); + var f = font; + var i = textFonts.size() - 1; + while (i >= 0) { + if (textFonts[i] == f) { break; } + i--; + } + if (i < 0) { return f; } // not one of the text fonts + while (i >= 0) { + if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) { + return textFonts[i]; + } + i--; + } + return textFonts[0]; + } + + // TEXT_JUSTIFY_VCENTER centres the font box, but capitals 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(dc as Graphics.Dc, font as Graphics.FontDefinition, + cyDesign as Numeric, ratio as Float) as Number { + var fh = dc.getFontHeight(font); + var asc = Graphics.getFontAscent(font); + return px(cyDesign) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber(); + } + + function text(dc as Graphics.Dc, x as Numeric, cyDesign 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(dc, font, cyDesign, CAP_OF_TEXT), font, str, + just | Graphics.TEXT_JUSTIFY_VCENTER); + } + + // Same, but shrinks the font if the string is too wide for its slot. + function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, + font as Graphics.FontDefinition, str as String, + just as Number, col as Number, maxDesignW as Float) as Void { + text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col); + } + + // Monkey C cannot fill glyphs with a gradient, so the string is drawn once + // per stop with dc.setClip holding each pass to its own horizontal band. + // The first and last band are extended off-glyph so no sliver of the + // digits is ever left undrawn. + function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric, + font as Graphics.FontDefinition, str as String, + just as Number, ramp as Array) as Void { + var n = ramp.size(); + var y = inkY(dc, font, cyDesign, CAP_OF_NUM); + var ink = capHeight(font, CAP_OF_NUM).toNumber(); + var top = px(cyDesign) - 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 = height; } + if (y1 <= y0) { continue; } + dc.setClip(0, y0, width, 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; screen y grows downward. + function polarX(ang as Float, r as Float) as Number { + return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber(); + } + + function polarY(ang as Float, r as Float) as Number { + return Math.round(cy - Math.cos(Math.toRadians(ang)) * r).toNumber(); + } + + function line(dc as Graphics.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)); + } + + // Pen widths round to zero on small screens; never let one vanish. + function pen(v as Numeric) as Number { + var w = px(v); + return w < 2 ? 2 : w; + } +} diff --git a/source/Settings.mc b/source/Settings.mc new file mode 100644 index 0000000..c7553cb --- /dev/null +++ b/source/Settings.mc @@ -0,0 +1,59 @@ +import Toybox.Lang; +import Toybox.Application; + +// Every stored preference in one place, with the defaults that reproduce the +// design comps. Keys match resources/settings/properties.xml. +// +// Properties.getValue returns null for a key the watch has not stored yet -- +// a fresh install, or a setting added by an update -- so every read falls back +// to the default rather than trusting the stored value. +module Settings { + + // Field ids follow https://watchface.io/docs/datafields. + const DEF_LEFT_TOP = 712; // sunrise + const DEF_RIGHT_TOP = 639; // forecast high / low + const DEF_BOTTOM_LEFT = 3; // distance + const DEF_BOTTOM_RIGHT = 1; // steps + + function number(key as String, def as Number) as Number { + var v = Application.Properties.getValue(key); + if (v == null) { return def; } + return v as Number; + } + + function bool(key as String, def as Boolean) as Boolean { + var v = Application.Properties.getValue(key); + if (v == null) { return def; } + return v as Boolean; + } + + function text(key as String) as String { + var v = Application.Properties.getValue(key); + if (v == null) { return ""; } + return v.toString(); + } + + // Theme index, clamped into range in case a stored value outlives a + // release that removed a theme. + function themeIndex(count as Number) as Number { + var i = number("Theme", 1) - 1; + if (i < 0 || i >= count) { return 0; } + return i; + } + + function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); } + function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); } + function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); } + function bottomRight() as Number { return number("BottomRight", DEF_BOTTOM_RIGHT); } + + function ringTR() as Number { return number("RingTR", 0); } + function ringTL() as Number { return number("RingTL", 1); } + function ringBR() as Number { return number("RingBR", 4); } + function ringBL() as Number { return number("RingBL", 3); } + + function tempUnit() as Number { return number("TempUnit", 0); } + + function showTop() as Boolean { return bool("ShowTop", true); } + function showBand() as Boolean { return bool("ShowBand", true); } + function showBottom() as Boolean { return bool("ShowBottom", true); } +}