summaryrefslogtreecommitdiff
path: root/source/ui/ListView.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 22:28:09 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 22:28:09 +0200
commit35042ff04ffc50e55ff59a385849442be4429d6b (patch)
tree3c142dd9bfb995b7aacdf39128e591dd3f9370de /source/ui/ListView.mc
parent871aba901501302586895d266fe4569665d9fe70 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'source/ui/ListView.mc')
-rw-r--r--source/ui/ListView.mc391
1 files changed, 391 insertions, 0 deletions
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<String> = [] as Array<String>;
+ private var _headers as Dictionary<String, Entry> = {} as Dictionary<String, Entry>;
+ private var _damaged as Dictionary<String, Boolean> = {} as Dictionary<String, Boolean>;
+ private var _top as Number = 0;
+ // Hit testing: the first visible index and each visible row's top edge.
+ private var _rowTops as Array<Number> = [] as Array<Number>;
+ 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<String, Entry>;
+ _damaged = {} as Dictionary<String, Boolean>;
+ 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<Number>;
+ 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<Graphics.Point2D>);
+ }
+ if (_top + shown < n) {
+ dc.fillPolygon([[cx - 6, bottom - 6], [cx + 6, bottom - 6], [cx, bottom]] as Array<Graphics.Point2D>);
+ }
+ }
+
+ // 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;
+ }
+}