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/OtpView.mc | 305 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 source/ui/OtpView.mc (limited to 'source/ui/OtpView.mc') diff --git a/source/ui/OtpView.mc b/source/ui/OtpView.mc new file mode 100644 index 0000000..faf94d0 --- /dev/null +++ b/source/ui/OtpView.mc @@ -0,0 +1,305 @@ +// Author: cirakg & claude ai +// Summary: OTP detail: issuer and account, the code in large grouped digits, the countdown ring or bar, next code, HOTP next. +// +// README 13.1: TOTP and Steam show the seconds left as a ring along the bezel +// (a bar on non-round screens), turning red and showing the next code in the +// last 5 seconds. HOTP shows its counter and how to get the next code. The +// entry ID is in small print at the bottom. +// +// The code font follows the code-size option and steps down until the code +// fits the screen. A locked entry closes back to the list when auto-lock +// fires while it is open. + +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.System; +import Toybox.Timer; +import Toybox.WatchUi; + +class OtpView extends WatchUi.View { + + static const NEXT_CODE_SECONDS = 5; + static const RING_WIDTH = 8; + + var header as Entry; + + private var _ticker as Timer.Timer? = null; + private var _compute as Timer.Timer? = null; + private var _shownLogged as Boolean = false; + + function initialize(entryHeader as Entry) { + View.initialize(); + header = entryHeader; + } + + function onShow() as Void { + _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; + } + } + + function onTick() as Void { + if (AppState.checkAutoLock() && header.locked) { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return; + } + WatchUi.requestUpdate(); + } + + // Re-read the header (the HOTP counter changed). + function refresh() as Void { + var fresh = AppState.theVault().header(header.id); + if (fresh != null) { + header = fresh; + } + _shownLogged = false; + WatchUi.requestUpdate(); + } + + function isHotp() as Boolean { + return header.type == Entry.OTP_HOTP; + } + + function onUpdate(dc as Graphics.Dc) as Void { + var frameStart = System.getTimer(); + Ui.clear(dc); + var w = dc.getWidth(); + var h = dc.getHeight(); + var cx = w / 2; + var now = AppState.now(); + var codes = AppState.theCodes(); + // Codes are computed in their own timer events (computeNext), one + // per event: an SHA-512 code costs a large share of the watchdog's + // per-event budget on some watches. + var cached = AppState.cryptoReady ? codes.cached(header.id, now) : null; + var complete = cached != null; + var code = cached != null ? cached : "------"; + var period = header.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : codes.periodOf(header.id); + var remaining = Otp.secondsRemaining(now, period); + var round = Ui.isRound(); + if (!isHotp()) { + drawTimeLeft(dc, remaining, period); + } + + // Layout: a vertical stack of name, account, code, two info lines and + // the ID, centred; the code font is the largest that fits both the + // width and the height left for it. + var nameFont = Graphics.FONT_SMALL; + var smallFont = Graphics.FONT_XTINY; + var infoFont = isHotp() ? smallFont : Graphics.FONT_TINY; + var nameH = dc.getFontHeight(nameFont); + var smallH = dc.getFontHeight(smallFont); + var accountH = header.account.length() > 0 ? smallH : 0; + var infoH = dc.getFontHeight(infoFont) + smallH; + var margin = round ? h / 10 : RING_WIDTH; + var fixedH = nameH + accountH + infoH + smallH + 8; + var font = chooseCodeFont(dc, code, 2 * Ui.halfWidthAt(dc, h / 2) - 2 * RING_WIDTH - 8, h - 2 * margin - fixedH); + var codeH = dc.getFontHeight(font); + var y = (h - fixedH - codeH) / 2; + + var title = header.name.length() > 0 ? header.name : "Locked entry " + header.id; + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, nameFont, Ui.fit(dc, title, nameFont, 2 * Ui.halfWidthAt(dc, y) - 2 * RING_WIDTH), + Graphics.TEXT_JUSTIFY_CENTER); + y += nameH; + if (accountH > 0) { + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, Ui.fit(dc, header.account, smallFont, 2 * Ui.halfWidthAt(dc, y) - 2 * RING_WIDTH), + Graphics.TEXT_JUSTIFY_CENTER); + y += accountH; + } + y += 2; + drawCode(dc, code, font, y); + y += codeH + 2; + + // Below the code: seconds left and the next code, or the HOTP hint. + if (isHotp()) { + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, "Counter " + header.counter, Graphics.TEXT_JUSTIFY_CENTER); + y += smallH; + dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); + var hint = Ui.isTouch() ? "Tap for the next code" : "START: next code"; + dc.drawText(cx, y, smallFont, Ui.fit(dc, hint, smallFont, 2 * Ui.halfWidthAt(dc, y)), Graphics.TEXT_JUSTIFY_CENTER); + y += smallH; + } else { + dc.setColor(remaining <= NEXT_CODE_SECONDS ? Ui.WARN_COLOR : Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, infoFont, remaining + " s", Graphics.TEXT_JUSTIFY_CENTER); + y += dc.getFontHeight(infoFont); + if (remaining <= NEXT_CODE_SECONDS && cached != null) { + var next = codes.cached(header.id + "+", now); + if (next == null) { + complete = false; + next = "······"; + } + var parts = CodeCache.split(next); + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, "next " + (parts.size() == 2 ? parts[0] + " " + parts[1] : parts[0]), + Graphics.TEXT_JUSTIFY_CENTER); + } + y += smallH; + } + + // Entry ID in small print. + y += 4; + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, (header.locked ? "Locked · " : "") + header.id, Graphics.TEXT_JUSTIFY_CENTER); + + Diag.overlay(dc, frameStart); + if (!complete) { + scheduleCompute(); + } else if (!_shownLogged) { + // UI scripts screenshot the screen once its codes are drawn. + _shownLogged = true; + Diag.shown(isHotp() ? "otp_hotp" : "otp"); + } + } + + private function scheduleCompute() as Void { + if (_compute == null && AppState.cryptoReady) { + var t = new Timer.Timer(); + t.start(method(:computeNext), 50, false); + _compute = t; + } + } + + // Computes the first missing code (current, then next) and redraws. + function computeNext() as Void { + _compute = null; + var codes = AppState.theCodes(); + var now = AppState.now(); + try { + if (codes.cached(header.id, now) == null) { + codes.code(header.id, now, 0); + } else { + codes.code(header.id, now, 1); + } + } catch (e instanceof VaultError) { + // Locked meanwhile: the next tick closes the screen. + return; + } + WatchUi.requestUpdate(); + } + + // The largest allowed code font whose code fits maxWidth and maxHeight + // (the code-size option gives the largest allowed). + private function chooseCodeFont(dc as Graphics.Dc, code as String, maxWidth as Number, maxHeight as Number) as Graphics.FontType { + // Number fonts only have digits: Steam codes and placeholders use text fonts. + var letters = header.type == Entry.OTP_STEAM || !Ui.isDigits(code); + var fonts = letters ? [Ui.codeFont(true), Graphics.FONT_MEDIUM, Graphics.FONT_SMALL] + : [Ui.codeFont(false), Graphics.FONT_NUMBER_MEDIUM, Graphics.FONT_NUMBER_MILD, Graphics.FONT_MEDIUM]; + for (var i = 0; i < fonts.size(); i++) { + var f = fonts[i] as Graphics.FontType; + if (codeWidth(dc, code, f) <= maxWidth && dc.getFontHeight(f) <= maxHeight) { + return f; + } + } + return fonts[fonts.size() - 1] as Graphics.FontType; + } + + // Width of the code as drawn: its groups with a gap of a quarter of the + // font height between them. + private function codeWidth(dc as Graphics.Dc, code as String, font as Graphics.FontType) as Number { + var parts = CodeCache.split(code); + var total = parts.size() == 2 ? dc.getFontHeight(font) / 4 : 0; + for (var k = 0; k < parts.size(); k++) { + total += dc.getTextWidthInPixels(parts[k], font); + } + return total; + } + + // The code, centred horizontally, with its top at y. + private function drawCode(dc as Graphics.Dc, code as String, font as Graphics.FontType, y as Number) as Void { + var parts = CodeCache.split(code); + var gap = parts.size() == 2 ? dc.getFontHeight(font) / 4 : 0; + var x = dc.getWidth() / 2 - codeWidth(dc, code, font) / 2; + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + for (var k = 0; k < parts.size(); k++) { + dc.drawText(x, y, font, parts[k], Graphics.TEXT_JUSTIFY_LEFT); + x += dc.getTextWidthInPixels(parts[k], font) + gap; + } + } + + // The ring along the bezel, or a bar at the top of non-round screens. + private function drawTimeLeft(dc as Graphics.Dc, remaining as Number, period as Number) as Void { + var w = dc.getWidth(); + var h = dc.getHeight(); + var color = remaining <= NEXT_CODE_SECONDS ? Ui.WARN_COLOR : Ui.accent(); + if (Ui.isRound()) { + var r = w / 2 - RING_WIDTH / 2 - 1; + dc.setPenWidth(RING_WIDTH); + dc.setColor(Ui.dark() ? 0x333333 : Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT); + dc.drawCircle(w / 2, h / 2, r); + var sweep = period > 0 ? 360 * remaining / period : 0; + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + if (sweep >= 360) { + dc.drawCircle(w / 2, h / 2, r); + } else if (sweep > 0) { + dc.drawArc(w / 2, h / 2, r, Graphics.ARC_CLOCKWISE, 90, (450 - sweep) % 360); + } + dc.setPenWidth(1); + } else { + dc.setColor(Ui.dark() ? 0x333333 : Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(0, 0, w, RING_WIDTH / 2 + 2); + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(0, 0, period > 0 ? w * remaining / period : 0, RING_WIDTH / 2 + 2); + } + } +} + +// Input on the OTP detail: START or a tap gives the next HOTP code; BACK closes. +class OtpDelegate extends WatchUi.BehaviorDelegate { + + function initialize() { + BehaviorDelegate.initialize(); + } + + private function view() as OtpView? { + var current = WatchUi.getCurrentView()[0]; + return current instanceof OtpView ? current : null; + } + + function onSelect() as Boolean { + AppState.activity(); + nextHotp(); + return true; + } + + function onTap(event as WatchUi.ClickEvent) as Boolean { + AppState.activity(); + nextHotp(); + return true; + } + + function onBack() as Boolean { + var v = view(); + if (v != null && v.header.locked && AppState.theVault().session.autoLockMs == 0) { + // Auto-lock "Immediately": leaving a locked entry locks. + AppState.lock(); + } + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } + + private function nextHotp() as Void { + var v = view(); + if (v == null || !v.isHotp()) { + return; + } + try { + AppState.theVault().setCounter(v.header.id, v.header.counter + 1); + } catch (e instanceof VaultError) { + return; + } + AppState.theCodes().invalidate(v.header.id); + v.refresh(); + } +} -- cgit v1.2.3