aboutsummaryrefslogtreecommitdiff
path: root/source/ui
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 06:56:58 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 06:56:58 +0200
commit2df98d1ba129f95a10c6b81665671d3b12560c79 (patch)
treebc40d8bea0679cde90f0b2e84306116ee6aaba2f /source/ui
parent3548312586aeafbfca0d0b87db90f9bdd972c831 (diff)
feat(ui): show QR codes, encoded once with progress and cached sealed per entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/ui')
-rw-r--r--source/ui/CodeView.mc177
1 files changed, 152 insertions, 25 deletions
diff --git a/source/ui/CodeView.mc b/source/ui/CodeView.mc
index fc1c889..2687d6c 100644
--- a/source/ui/CodeView.mc
+++ b/source/ui/CodeView.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: Shows a barcode entry: black modules on a white screen at the largest whole module size that fits, with text.
+// Summary: Shows a barcode entry: black modules on white at the largest whole module size that fits, linear or 2D.
//
// README 12.2:
// * Always black on white (whatever the theme), quiet zone included.
@@ -8,26 +8,38 @@
// whole number of pixels that fits the code plus 10 quiet modules on
// each side; 1 px is allowed for linear codes (EAN-13 on 218 px screens
// needs it, and the screenshot decode test covers it).
+// * 2D codes use the largest centred square (round screens) or the
+// screen's shorter side, at least 2 px per module.
+// * 2D codes are encoded once, as a job on a timer ("Encoding... 40%"),
+// and cached sealed with the entry's key (Vault.saveCodeCache), so the
+// next opening is instant.
// * Human-readable text under linear codes when enabled and there's room.
// * Brightness: the backlight is turned on when shown and on every tap.
// * Too big: a message with the size needed.
-// The payload is decrypted when the screen opens and wiped once encoded;
-// the module row stays in memory only while the screen is shown.
+// The payload is decrypted when the screen opens and wiped once encoded.
import Toybox.Attention;
import Toybox.Graphics;
import Toybox.Lang;
+import Toybox.System;
+import Toybox.Timer;
import Toybox.WatchUi;
class CodeView extends WatchUi.View {
static const QUIET_MODULES = 10;
static const MIN_LINEAR_PX = 1;
+ static const MIN_MATRIX_PX = 2;
+ static const QR_QUIET = 4;
var header as Entry;
- private var _code as Codes.LinearCode or Codes.CodeError or Null = null;
+ private var _code as Codes.LinearCode or Codes.Matrix or Codes.CodeError or Null = null;
private var _showText as Boolean = true;
private var _shownLogged as Boolean = false;
+ private var _job as Qr.QrJob? = null;
+ private var _runner as Jobs.JobRunner? = null;
+ private var _ticker as Timer.Timer? = null;
+ private var _startedAt as Number = 0;
function initialize(entryHeader as Entry) {
View.initialize();
@@ -41,21 +53,90 @@ class CodeView extends WatchUi.View {
}
function onHide() as Void {
+ stopJob();
_code = null;
}
private function encode() as Void {
+ var vault = AppState.theVault();
try {
- var entry = AppState.theVault().open(header.id);
+ if (Codes.isMatrix(header.type)) {
+ var cached = vault.loadCodeCache(header.id);
+ var matrix = cached != null ? Codes.Matrix.fromBytes(cached) : null;
+ if (matrix != null) {
+ _code = matrix;
+ return;
+ }
+ }
+ var entry = vault.open(header.id);
var payload = Bytes.toUtf8(entry.payload as ByteArray);
_showText = (entry.options & Codes.OPT_SHOW_TEXT) != 0;
- _code = payload != null ? Codes.encode(entry.type, payload, entry.options) : new Codes.CodeError("damaged text");
+ if (payload == null) {
+ _code = new Codes.CodeError("damaged text");
+ } else if (entry.type == Codes.QR) {
+ startQr(payload, Codes.ecLevel(entry.options));
+ } else {
+ _code = Codes.encode(entry.type, payload, entry.options);
+ }
entry.wipeSecrets();
} catch (e instanceof VaultError) {
_code = new Codes.CodeError(e.code == VaultError.LOCKED ? "locked" : "damaged entry");
}
}
+ private function startQr(payload as String, ec as Number) as Void {
+ _startedAt = System.getTimer();
+ var job = new Qr.QrJob(payload, ec, 0, -1);
+ _job = job;
+ var runner = new Jobs.JobRunner(job, 1, method(:onEncoded));
+ _runner = runner;
+ runner.start();
+ var ticker = new Timer.Timer();
+ ticker.start(method(:onTick), 250, true);
+ _ticker = ticker;
+ }
+
+ function onTick() as Void {
+ WatchUi.requestUpdate();
+ }
+
+ function onEncoded() as Void {
+ var job = _job;
+ stopJob();
+ if (job == null) {
+ return;
+ }
+ var err = job.error;
+ var result = job.result;
+ if (err != null || result == null) {
+ _code = new Codes.CodeError(err != null ? err : "could not encode");
+ } else {
+ Diag.data("qr_encoded", "version=" + result.version + " ms=" + (System.getTimer() - _startedAt));
+ var matrix = new Codes.Matrix(result.size, result.size, QR_QUIET, result.modules);
+ _code = matrix;
+ try {
+ AppState.theVault().saveCodeCache(header.id, matrix.toBytes());
+ } catch (e instanceof VaultError) {
+ // Locked meanwhile: no cache this time.
+ }
+ }
+ WatchUi.requestUpdate();
+ }
+
+ private function stopJob() as Void {
+ var runner = _runner;
+ if (runner != null) {
+ runner.cancel();
+ }
+ var ticker = _ticker;
+ if (ticker != null) {
+ ticker.stop();
+ }
+ _runner = null;
+ _ticker = null;
+ _job = null;
+ }
+
// Full backlight while a code is shown (AMOLED watches limit how long;
// the system then dims it, and a tap asks again).
function boostBrightness() as Void {
@@ -77,27 +158,39 @@ class CodeView extends WatchUi.View {
var smallFont = Graphics.FONT_XTINY;
var smallH = dc.getFontHeight(smallFont);
var code = _code;
+ var job = _job;
if (code instanceof Codes.LinearCode) {
drawLinear(dc, code, smallFont, smallH);
+ } else if (code instanceof Codes.Matrix) {
+ drawMatrix(dc, code, smallFont, smallH);
+ } else if (job != null) {
+ dc.drawText(w / 2, h / 2 - smallH / 2, smallFont, "Encoding… " + job.percent() + "%", Graphics.TEXT_JUSTIFY_CENTER);
} else {
- var message = code instanceof Codes.CodeError ? code.message : "…";
- var lines = Ui.wrap(dc, message, smallFont, 2 * Ui.halfWidthAt(dc, h / 2) - 16);
- var y = h / 2 - lines.size() * smallH / 2;
- for (var i = 0; i < lines.size(); i++) {
- dc.drawText(w / 2, y, smallFont, lines[i], Graphics.TEXT_JUSTIFY_CENTER);
- y += smallH;
- }
+ drawMessage(dc, code instanceof Codes.CodeError ? code.message : "…", smallFont, smallH);
}
- // Name at the top and ID at the bottom, in small print.
- var top = Ui.isRound() ? h / 8 : 4;
+ // Name at the top and type and ID at the bottom, in small print.
+ var top = Ui.isRound() ? h / 10 : 2;
dc.setColor(Graphics.COLOR_DK_GRAY, Graphics.COLOR_TRANSPARENT);
dc.drawText(w / 2, top, smallFont, Ui.fit(dc, header.name, smallFont, 2 * Ui.halfWidthAt(dc, top + smallH)),
Graphics.TEXT_JUSTIFY_CENTER);
- var bottom = Ui.isRound() ? h - h / 8 - smallH : h - smallH - 4;
- dc.drawText(w / 2, bottom, smallFont, Codes.name(header.type) + " · " + header.id, Graphics.TEXT_JUSTIFY_CENTER);
+ var bottom = Ui.isRound() ? h - h / 10 - smallH : h - smallH - 2;
+ dc.drawText(w / 2, bottom, smallFont, Ui.fit(dc, Codes.name(header.type) + " · " + header.id, smallFont,
+ 2 * Ui.halfWidthAt(dc, bottom)), Graphics.TEXT_JUSTIFY_CENTER);
if (!_shownLogged && code != null) {
_shownLogged = true;
- Diag.shown(code instanceof Codes.LinearCode ? "code" : "code_error");
+ Diag.shown(code instanceof Codes.CodeError ? "code_error" : "code");
+ }
+ }
+
+ private function drawMessage(dc as Graphics.Dc, message as String, font as Graphics.FontType, fontH as Number) as Void {
+ var w = dc.getWidth();
+ var h = dc.getHeight();
+ dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
+ var lines = Ui.wrap(dc, message, font, 2 * Ui.halfWidthAt(dc, h / 2) - 16);
+ var y = h / 2 - lines.size() * fontH / 2;
+ for (var i = 0; i < lines.size(); i++) {
+ dc.drawText(w / 2, y, font, lines[i], Graphics.TEXT_JUSTIFY_CENTER);
+ y += fontH;
}
}
@@ -113,13 +206,7 @@ class CodeView extends WatchUi.View {
var width = 2 * Ui.halfWidthBetween(dc, bandTop, bandTop + barH + textH) + (Ui.isRound() ? 12 : 8);
var px = width / total;
if (px < MIN_LINEAR_PX) {
- var lines = Ui.wrap(dc, "Too large for this screen: needs " + total + " px, " + width + " px available",
- font, 2 * Ui.halfWidthAt(dc, h / 2) - 16);
- var y = h / 2 - lines.size() * fontH / 2;
- for (var i = 0; i < lines.size(); i++) {
- dc.drawText(w / 2, y, font, lines[i], Graphics.TEXT_JUSTIFY_CENTER);
- y += fontH;
- }
+ drawMessage(dc, "Too large for this screen: needs " + total + " px, " + width + " px available", font, fontH);
return;
}
var x0 = (w - modules.size() * px) / 2;
@@ -141,6 +228,46 @@ class CodeView extends WatchUi.View {
dc.drawText(w / 2, bandTop + barH + 2, font, code.text, Graphics.TEXT_JUSTIFY_CENTER);
}
}
+
+ // A 2D code in the largest centred square (or rectangle) that fits.
+ private function drawMatrix(dc as Graphics.Dc, code as Codes.Matrix, font as Graphics.FontType, fontH as Number) as Void {
+ var w = dc.getWidth();
+ var h = dc.getHeight();
+ // Round screens: the largest square inside the circle (side w/sqrt 2);
+ // others: the shorter side, minus room for the name and ID lines.
+ var areaW = Ui.isRound() ? w * 707 / 1000 : w - 4;
+ var areaH = Ui.isRound() ? h * 707 / 1000 : h - 2 * fontH - 8;
+ var totalW = code.width + 2 * code.quiet;
+ var totalH = code.height + 2 * code.quiet;
+ var px = areaW / totalW;
+ if (areaH / totalH < px) {
+ px = areaH / totalH;
+ }
+ if (px < MIN_MATRIX_PX) {
+ drawMessage(dc, "Too large for this screen: needs " + MIN_MATRIX_PX + " px per module, a "
+ + totalW * MIN_MATRIX_PX + " px square", font, fontH);
+ return;
+ }
+ var x0 = (w - code.width * px) / 2;
+ var y0 = (h - code.height * px) / 2;
+ dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE);
+ var m = code.modules;
+ for (var y = 0; y < code.height; y++) {
+ var row = y * code.width;
+ var x = 0;
+ while (x < code.width) {
+ if (m[row + x] == 1) {
+ var start = x;
+ while (x < code.width && m[row + x] == 1) {
+ x++;
+ }
+ dc.fillRectangle(x0 + start * px, y0 + y * px, (x - start) * px, px);
+ } else {
+ x++;
+ }
+ }
+ }
+ }
}
// Input on a code: a tap asks for full brightness again; BACK closes.