From 35042ff04ffc50e55ff59a385849442be4429d6b Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 22:28:09 +0200 Subject: feat(ui): add the list, OTP detail, PIN entry, menu, info screens and glance List with live codes and countdowns, adaptive rows and lazy headers; OTP detail with ring or bar, next code and HOTP next; PIN keypad or button selector with create, unlock, change, lockout display and pending promotion; native menu; glance with the entry count; self-test after the first frame; option properties with defaults. Codes are computed one per timer event, apart from drawing, for the watchdog. A UI-test build (uitest.jungle) starts with fake entries, PIN 123456 and a frozen clock. Co-Authored-By: Claude Opus 5.5 --- source/ui/ListView.mc | 391 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 source/ui/ListView.mc (limited to 'source/ui/ListView.mc') diff --git a/source/ui/ListView.mc b/source/ui/ListView.mc new file mode 100644 index 0000000..ab0e594 --- /dev/null +++ b/source/ui/ListView.mc @@ -0,0 +1,391 @@ +// 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; + } +} -- cgit v1.2.3