diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-19 22:28:09 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-19 22:28:09 +0200 |
| commit | 35042ff04ffc50e55ff59a385849442be4429d6b (patch) | |
| tree | 3c142dd9bfb995b7aacdf39128e591dd3f9370de /source/ui/MainMenu.mc | |
| parent | 871aba901501302586895d266fe4569665d9fe70 (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/MainMenu.mc')
| -rw-r--r-- | source/ui/MainMenu.mc | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc new file mode 100644 index 0000000..b827784 --- /dev/null +++ b/source/ui/MainMenu.mc @@ -0,0 +1,172 @@ +// Author: cirakg & claude ai +// Summary: The menu (README 13.1): lock now, PIN actions, settings results, entry IDs, clock status, about. +// +// A native Menu2, so scrolling, touch and buttons behave like every other +// Garmin menu. Debug builds add the items from DebugMenu. + +import Toybox.Application; +import Toybox.Lang; +import Toybox.Time; +import Toybox.Time.Gregorian; +import Toybox.Timer; +import Toybox.WatchUi; + +module MainMenu { + + function build() as WatchUi.Menu2 { + var vault = AppState.theVault(); + var menu = new WatchUi.Menu2({:title => "OTP Wallet"}); + if (vault.session.isUnlocked()) { + menu.addItem(new WatchUi.MenuItem("Lock now", null, :lock, null)); + } + menu.addItem(new WatchUi.MenuItem(vault.hasPin() ? "Change PIN" : "Set PIN", null, :pin, null)); + if (vault.hasPin()) { + menu.addItem(new WatchUi.MenuItem("Reset PIN", null, :reset, null)); // the confirmation says what it erases + } + menu.addItem(new WatchUi.MenuItem("Settings results", null, :results, null)); + menu.addItem(new WatchUi.MenuItem("Entry IDs", null, :ids, null)); + menu.addItem(new WatchUi.MenuItem("Clock status", null, :clock, null)); + menu.addItem(new WatchUi.MenuItem("About", null, :about, null)); + DebugMenu.addItems(menu); + return menu; + } + + function entryIdLines() as Array<String> { + var vault = AppState.theVault(); + var ids = vault.ids(); + var lines = [] as Array<String>; + for (var i = 0; i < ids.size(); i++) { + var header = vault.header(ids[i]); + var name = header == null ? "damaged" : (header.name.length() > 0 ? header.name : "Locked entry"); + lines.add(ids[i] + " " + name); + } + if (lines.size() == 0) { + lines.add("No entries yet."); + } + var pending = vault.pendingIds().size(); + if (pending > 0) { + lines.add(pending + " locked entries wait for your PIN."); + } + return lines; + } + + function clockLines() as Array<String> { + var lines = [] as Array<String>; + var watch = Clock.nowUnix(0); + var info = Gregorian.utcInfo(new Time.Moment(watch.toNumber()), Time.FORMAT_SHORT); + lines.add("Watch: " + info.hour.format("%02d") + ":" + info.min.format("%02d") + ":" + info.sec.format("%02d") + " UTC"); + var trusted = Clock.trustedUnix(); + if (trusted == null) { + lines.add("GPS time: not synced yet. Codes use the watch clock."); + } else { + var diff = (watch - trusted).toNumber(); + lines.add("GPS time: synced"); + lines.add("Difference: " + diff + " s" + (diff.abs() > 15 ? " (codes may be wrong)" : "")); + } + lines.add("Time offset setting: " + AppState.options.timeOffset + " s"); + return lines; + } + + function aboutLines() as Array<String> { + var vault = AppState.theVault(); + var lines = ["OTP Wallet " + AppInfo.VERSION] as Array<String>; + lines.add("Entries: " + vault.ids().size() + (vault.pendingIds().size() > 0 ? " (+" + vault.pendingIds().size() + " pending)" : "")); + lines.add("Storage: " + (storageBytes(vault) + 1023) / 1024 + " KB"); + lines.add("Self-test: " + AppState.selfTestSummary); + lines.add("No network access: your data stays on this watch."); + return lines; + } + + // Bytes the entry records take (the largest part of the store). + function storageBytes(vault as Vault) as Number { + var total = 0; + var ids = vault.ids(); + for (var i = 0; i < ids.size(); i++) { + var record = Application.Storage.getValue("e." + ids[i]); + if (record instanceof Lang.ByteArray) { + total += record.size(); + } + } + return total; + } +} + +class MainMenuDelegate extends WatchUi.Menu2InputDelegate { + + function initialize() { + Menu2InputDelegate.initialize(); + } + + function onSelect(item as WatchUi.MenuItem) as Void { + AppState.activity(); + var id = item.getId(); + var vault = AppState.theVault(); + if (id == :lock) { + AppState.lock(); + WatchUi.popView(WatchUi.SLIDE_DOWN); + } else if (id == :pin) { + if (!vault.hasPin()) { + WatchUi.switchToView(new PinView(PinView.MODE_CREATE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } else if (vault.session.isUnlocked()) { + WatchUi.switchToView(new PinView(PinView.MODE_CHANGE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } else { + var then = new ChangePinAfterUnlock(); + WatchUi.switchToView(new PinView(PinView.MODE_UNLOCK, then.method(:run)), new PinDelegate(), WatchUi.SLIDE_LEFT); + } + } else if (id == :reset) { + WatchUi.pushView(new WatchUi.Confirmation("Erase locked entries and set a new PIN?"), new ResetPinConfirmation(), + WatchUi.SLIDE_LEFT); + } else if (id == :results) { + WatchUi.switchToView(new InfoView("Settings results", + ["Nothing yet. Results appear here after you add or change entries in the phone settings."], "results"), + new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else if (id == :ids) { + WatchUi.switchToView(new InfoView("Entry IDs", MainMenu.entryIdLines(), "ids"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else if (id == :clock) { + WatchUi.switchToView(new InfoView("Clock status", MainMenu.clockLines(), "clock"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else if (id == :about) { + WatchUi.switchToView(new InfoView("About", MainMenu.aboutLines(), "about"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else { + DebugMenu.handle(id); + } + } +} + +// After unlocking from the menu's "Change PIN": ask for the new PIN. +class ChangePinAfterUnlock { + + function initialize() { + } + + function run() as Void { + WatchUi.switchToView(new PinView(PinView.MODE_CHANGE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } +} + +// "Reset PIN" after the user confirmed. The confirmation closes itself after +// onResponse, so the PIN screen opens from a short timer, replacing the menu. +class ResetPinConfirmation extends WatchUi.ConfirmationDelegate { + + // Kept so the one-shot timer isn't collected before it fires. + var timer as Timer.Timer? = null; + + function initialize() { + ConfirmationDelegate.initialize(); + } + + function onResponse(response as WatchUi.Confirm) as Boolean { + if (response == WatchUi.CONFIRM_YES) { + AppState.theVault().resetPin(); + AppState.theCodes().clear(); + var t = new Timer.Timer(); + t.start(method(:openCreatePin), 100, false); + timer = t; + } + return true; + } + + function openCreatePin() as Void { + timer = null; + WatchUi.switchToView(new PinView(PinView.MODE_CREATE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } +} |
