// Author: cirakg & claude ai // Summary: The home screen: entries in your order with their current codes, a focus row, and the empty state. // // Layout (README 13.1, 13.3): a title, then rows of two lines each (name; // group and account) with the code and a countdown on the right for unlocked // OTP entries, a padlock for locked entries and a barcode icon for codes. // The focused row is outlined; the list scrolls to keep it visible and // centres short lists. On round screens each row is as wide as the circle // allows at its height. // // Performance (PERFORMANCE.md, M3): headers are read only for the visible // rows and cached while the screen is shown. Drawing never computes codes: // missing ones show as dots and are computed in a separate timer event, at // most CodeCache.BUDGET_PER_EVENT cost units per event, so a page of SHA-512 // entries is spread over several events (watchdog). import Toybox.Graphics; import Toybox.Lang; import Toybox.System; import Toybox.Timer; import Toybox.WatchUi; class ListView extends WatchUi.View { // Pause before continuing code computation that didn't fit in one draw. static const CONTINUE_MS = 50; var focus as Number = 0; private var _ids as Array = [] as Array; private var _headers as Dictionary = {} as Dictionary; private var _damaged as Dictionary = {} as Dictionary; private var _top as Number = 0; // Hit testing: the first visible index and each visible row's top edge. private var _rowTops as Array = [] as Array; private var _rowHeight as Number = 1; private var _ticker as Timer.Timer? = null; private var _continue as Timer.Timer? = null; private var _firstFrameDone as Boolean = false; private var _shownLogged as Boolean = false; private var _startupDone as Boolean = false; private var _loggedFocusY as Number = -1; function initialize() { View.initialize(); } function onShow() as Void { reload(); _shownLogged = false; var ticker = new Timer.Timer(); ticker.start(method(:onTick), 1000, true); _ticker = ticker; } function onHide() as Void { var ticker = _ticker; if (ticker != null) { ticker.stop(); _ticker = null; } } // Re-read the entry order (after changes) and drop cached headers. function reload() as Void { _ids = AppState.theVault().ids(); _headers = {} as Dictionary; _damaged = {} as Dictionary; if (focus >= _ids.size()) { focus = _ids.size() > 0 ? _ids.size() - 1 : 0; } } function count() as Number { return _ids.size(); } // Move the focus by delta rows, wrapping at both ends. function move(delta as Number) as Void { var n = _ids.size(); if (n == 0) { return; } focus = ((focus + delta) % n + n) % n; WatchUi.requestUpdate(); } function focusedId() as String? { return focus < _ids.size() ? _ids[focus] : null; } // The entry index drawn at screen position y, or -1. function rowAt(y as Number) as Number { for (var k = 0; k < _rowTops.size(); k++) { var top = _rowTops[k]; if (y >= top && y < top + _rowHeight) { return _top + k; } } return -1; } function onTick() as Void { AppState.checkAutoLock(); WatchUi.requestUpdate(); } // After the first frame: the self-test, then the PIN prompt for pending // locked entries (README 7.7). private function startupChecks() as Void { _startupDone = true; if (!AppState.runSelfTestIfDue()) { WatchUi.switchToView(new InfoView("OTP Wallet", [AppState.startError as String], "error"), new InfoDelegate(), WatchUi.SLIDE_IMMEDIATE); return; } var vault = AppState.theVault(); if (vault.pendingIds().size() > 0 && !vault.session.isUnlocked()) { var mode = vault.hasPin() ? PinView.MODE_UNLOCK : PinView.MODE_CREATE; WatchUi.pushView(new PinView(mode, null), new PinDelegate(), WatchUi.SLIDE_LEFT); } } // One-shot timer: start-up checks after the first frame, and further // code computation that didn't fit in the previous draw. function onContinue() as Void { _continue = null; if (!_startupDone) { startupChecks(); } else { computeMissing(); } WatchUi.requestUpdate(); } private function scheduleContinue() as Void { if (_continue == null) { var t = new Timer.Timer(); t.start(method(:onContinue), CONTINUE_MS, false); _continue = t; } } function onUpdate(dc as Graphics.Dc) as Void { var frameStart = System.getTimer(); Ui.clear(dc); var w = dc.getWidth(); var h = dc.getHeight(); var titleFont = Graphics.FONT_XTINY; var titleY = Ui.isRound() ? h / 12 : 4; dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); dc.drawText(w / 2, titleY, titleFont, "OTP Wallet", Graphics.TEXT_JUSTIFY_CENTER); var listTop = titleY + dc.getFontHeight(titleFont) + 2; var listBottom = Ui.isRound() ? h - h / 12 : h - 4; var complete = true; if (_ids.size() == 0) { drawEmpty(dc, listTop, listBottom); } else { complete = drawRows(dc, listTop, listBottom); } Diag.overlay(dc, frameStart); if (!_firstFrameDone) { _firstFrameDone = true; Diag.firstFrame(); Diag.data("device", "touch=" + (Ui.isTouch() ? 1 : 0)); scheduleContinue(); } if (!complete) { scheduleContinue(); } else if (!_shownLogged && _startupDone) { // UI scripts screenshot the list only once every code is drawn. _shownLogged = true; Diag.shown("list"); } } private function drawEmpty(dc as Graphics.Dc, top as Number, bottom as Number) as Void { var w = dc.getWidth(); var y = (top + bottom) / 2 - dc.getFontHeight(Graphics.FONT_SMALL); dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); dc.drawText(w / 2, y, Graphics.FONT_SMALL, "No entries yet", Graphics.TEXT_JUSTIFY_CENTER); y += dc.getFontHeight(Graphics.FONT_SMALL) + 4; dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); var lines = Ui.wrap(dc, "Add them in the app's settings in Garmin Connect on your phone.", Graphics.FONT_XTINY, 2 * Ui.halfWidthAt(dc, y + dc.getFontHeight(Graphics.FONT_XTINY) * 2)); for (var i = 0; i < lines.size(); i++) { dc.drawText(w / 2, y, Graphics.FONT_XTINY, lines[i], Graphics.TEXT_JUSTIFY_CENTER); y += dc.getFontHeight(Graphics.FONT_XTINY); } } // Returns false while some visible code is still to be computed. private function drawRows(dc as Graphics.Dc, top as Number, bottom as Number) as Boolean { var n = _ids.size(); var tinyH = dc.getFontHeight(Graphics.FONT_TINY); var xtinyH = dc.getFontHeight(Graphics.FONT_XTINY); _rowHeight = dc.getFontHeight(Graphics.FONT_SMALL) + (tinyH > xtinyH ? tinyH : xtinyH) + 4; var visible = (bottom - top) / _rowHeight; if (visible < 1) { visible = 1; } if (focus < _top) { _top = focus; } else if (focus >= _top + visible) { _top = focus - visible + 1; } if (_top > n - visible) { _top = n - visible > 0 ? n - visible : 0; } var shown = n - _top < visible ? n - _top : visible; // Centre a short list in the available space. var y = top + (bottom - top - shown * _rowHeight) / 2; var incomplete = false; _rowTops = [] as Array; for (var k = 0; k < shown; k++) { _rowTops.add(y); if (_top + k == focus && y + _rowHeight / 2 != _loggedFocusY) { // Where UI scripts tap to open the focused row. _loggedFocusY = y + _rowHeight / 2; Diag.data("list_focus", "y=" + _loggedFocusY); } if (!drawRow(dc, _top + k, y)) { incomplete = true; } y += _rowHeight; } drawScrollMarks(dc, top, bottom, n, shown); return !incomplete; } // Draws one row. Returns false when its code is still to be computed. // // Two layouts: when the name and the code fit on one line, the code sits // right of the name and the second line (group, account) has the full // width; on narrow rows the code moves to the second line, right of the // account, so the name keeps its width. private function drawRow(dc as Graphics.Dc, index as Number, y as Number) as Boolean { var id = _ids[index]; var header = headerFor(id); var w = dc.getWidth(); var hw = Ui.halfWidthBetween(dc, y, y + _rowHeight); var x0 = w / 2 - hw; var x1 = w / 2 + hw; var nameFont = Graphics.FONT_SMALL; var smallFont = Graphics.FONT_XTINY; var nameH = dc.getFontHeight(nameFont); var lineY = y + 2; var line2Y = lineY + nameH; var line2H = _rowHeight - nameH - 4; if (index == focus) { dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); dc.setPenWidth(2); dc.drawRoundedRectangle(x0, y + 1, x1 - x0, _rowHeight - 2, 8); dc.setPenWidth(1); } var left = x0 + 8; var right = x1 - 8; if (header == null) { dc.setColor(Ui.WARN_COLOR, Graphics.COLOR_TRANSPARENT); dc.drawText(left, lineY, nameFont, Ui.fit(dc, "Damaged " + id, nameFont, right - left), Graphics.TEXT_JUSTIFY_LEFT); return true; } var name = header.name.length() > 0 ? header.name : (header.locked ? "Locked entry " + id : id); var second = header.account; if (AppState.options.showGroups && header.group.length() > 0) { second = second.length() > 0 ? header.group + " · " + second : header.group; } var nameRight = right; var secondRight = right; var complete = true; var iconSize = nameH * 2 / 3; if (header.kind == Entry.KIND_CODE) { Ui.drawBarcode(dc, right - iconSize, lineY + (nameH - iconSize) / 2, iconSize, Ui.dim()); nameRight -= iconSize + 6; } else if (header.locked) { Ui.drawLock(dc, right - iconSize, lineY + (nameH - iconSize) / 2, iconSize, Ui.dim()); nameRight -= iconSize + 6; } else { var now = AppState.now(); var text = AppState.cryptoReady ? AppState.theCodes().cached(header.id, now) : null; if (text == null) { complete = false; text = "···"; } var parts = CodeCache.split(text); var display = parts.size() == 2 ? parts[0] + " " + parts[1] : parts[0]; var oneLine = dc.getTextWidthInPixels(name, nameFont) + codeWidth(dc, header, display, nameFont) + 8 <= right - left; if (oneLine) { nameRight = drawCode(dc, header, display, nameFont, right, lineY, nameH, now) - 8; } else { secondRight = drawCode(dc, header, display, Graphics.FONT_TINY, right, line2Y, line2H, now) - 8; } } dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); dc.drawText(left, lineY, nameFont, Ui.fit(dc, name, nameFont, nameRight - left), Graphics.TEXT_JUSTIFY_LEFT); if (second.length() > 0) { dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); dc.drawText(left, line2Y + (line2H - dc.getFontHeight(smallFont)) / 2, smallFont, Ui.fit(dc, second, smallFont, secondRight - left), Graphics.TEXT_JUSTIFY_LEFT); } return complete; } // Width of a code with its countdown (TOTP and Steam) in `font`. private function codeWidth(dc as Graphics.Dc, header as Entry, display as String, font as Graphics.FontType) as Number { var countdown = header.type == Entry.OTP_HOTP ? 0 : 2 * (dc.getFontHeight(Graphics.FONT_XTINY) / 3) + 6; return dc.getTextWidthInPixels(display, font) + countdown; } // Draws the code and countdown right-aligned at `right`, vertically // centred in the line from y to y + lineH. Returns the left edge used. private function drawCode(dc as Graphics.Dc, header as Entry, display as String, font as Graphics.FontType, right as Number, y as Number, lineH as Number, now as Long) as Number { var x = right; if (header.type != Entry.OTP_HOTP) { var r = dc.getFontHeight(Graphics.FONT_XTINY) / 3; var period = header.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : periodOf(header); Ui.drawCountdown(dc, x - r, y + lineH / 2, r, Otp.secondsRemaining(now, period), period); x -= 2 * r + 6; } dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); dc.drawText(x, y + (lineH - dc.getFontHeight(font)) / 2, font, display, Graphics.TEXT_JUSTIFY_RIGHT); return x - dc.getTextWidthInPixels(display, font); } // Computes the codes the last draw was missing, within the per-event // budget (onContinue, its own event: never together with drawing and // header reading, for the watchdog). private function computeMissing() as Void { if (!AppState.cryptoReady) { return; } var codes = AppState.theCodes(); var now = AppState.now(); var budget = CodeCache.BUDGET_PER_EVENT; var n = _ids.size(); for (var k = 0; k < _rowTops.size() && _top + k < n; k++) { var header = headerFor(_ids[_top + k]); if (header == null || header.kind != Entry.KIND_OTP || header.locked || codes.cached(header.id, now) != null) { continue; } var cost = codes.costOf(header); if (cost > budget) { return; } budget -= cost; try { codes.code(header.id, now, 0); } catch (e instanceof VaultError) { return; } } } // The period is encrypted with the secret; the code cache learns it when // it computes the code. private function periodOf(header as Entry) as Number { return AppState.theCodes().periodOf(header.id); } private function drawScrollMarks(dc as Graphics.Dc, top as Number, bottom as Number, n as Number, shown as Number) as Void { var cx = dc.getWidth() / 2; dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); if (_top > 0) { dc.fillPolygon([[cx - 6, top + 6], [cx + 6, top + 6], [cx, top]] as Array); } if (_top + shown < n) { dc.fillPolygon([[cx - 6, bottom - 6], [cx + 6, bottom - 6], [cx, bottom]] as Array); } } // The cached header, reading it on first use; null when damaged. private function headerFor(id as String) as Entry? { var cached = _headers[id]; if (cached != null || _damaged.hasKey(id)) { return cached; } var header = AppState.theVault().header(id); if (header == null) { _damaged[id] = true; } else { _headers[id] = header; } return header; } }