mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 14:47:50 +01:00
The dismiss gate was an OR — NFC tag *or* QR *or* (25 steps + 2 min) —
followed by one challenge. QR never worked at ring time (no scanner
package, no CAMERA permission, `onQrScanned` had no caller), so the real
bar was "walk 25 steps, wait 2 minutes, answer one question" and the NFC
tag was already fully bypassable.
It is now a strict, latched sequence owned by `DismissGate`:
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
Every stage is required, in that order, and a stage once passed never
reverts. That last part also fixes a real bug: `_onNfcTag` and `_trySteps`
both assigned the room-proof result straight onto `_inRoom`, so a wrong
tag tapped after the gate had passed sent the user back to the start.
Two *distinct* tags are required; the same id in both slots is refused at
registration, since one tag satisfying both stages removes the walk that
is the whole point. The existing single-tag registration migrates into
slot 1 rather than being stranded.
Answers are cumulative, not consecutive: a wrong one costs the attempt and
nothing else.
The bypass is the only fallback and is deliberately narrow. After 5 minutes
of ringing, or immediately when NFC is off or fewer than two distinct tags
are registered, the two *tag* stages are skipped and the count rises to 6.
The walk is never skipped — jumping straight to the challenges would let
someone lie in bed for five minutes and answer questions without standing
up, which is weaker than the gate this replaces. Without some such hatch a
dead tag at 06:00 leaves an alarm that cannot be switched off at all.
Also deletes the `flash` challenge outright, along with the now-orphaned
`generateCode`/`kDismissCodeLength`/`_dismissChars`. It was the only kind
that removed its prompt from the screen, which is unsolvable for someone
who has just walked in from another room.
The ring screen takes an injectable clock: `Stopwatch` reads the wall
clock, which `tester.pump()` does not advance, so without that seam no
widget test could reach the two-minute floor or the five-minute bypass.
Verified on the Pixel 6a: the existing tag migrated into slot 1 and both
rows render. 102 Dart tests pass; analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
196 lines
5.5 KiB
Dart
196 lines
5.5 KiB
Dart
/// Dismiss-challenge types.
|
|
///
|
|
/// Every kind must keep its prompt **on screen until it is answered**. A
|
|
/// challenge that hides what it asked is unsolvable for someone who has just
|
|
/// walked in from another room, which is why there is no memorise-this kind.
|
|
library;
|
|
|
|
import 'dart:math';
|
|
|
|
/// Words for the reverse challenge — common, unambiguous when read half-asleep.
|
|
const List<String> _reverseWords = <String>[
|
|
'MORNING',
|
|
'COFFEE',
|
|
'PILLOW',
|
|
'BLANKET',
|
|
'ALARM',
|
|
'AWAKE',
|
|
'SHOWER',
|
|
'BREAKFAST',
|
|
'WORKOUT',
|
|
'WINDOW',
|
|
'MONDAY',
|
|
'TUESDAY',
|
|
'FRIDAY',
|
|
'SUNDAY',
|
|
'WEEKEND',
|
|
];
|
|
|
|
/// The four challenge kinds. All keep their prompt visible while answering.
|
|
enum ChallengeKind {
|
|
/// Solve an arithmetic problem.
|
|
math,
|
|
|
|
/// Type shuffled digits in ascending order.
|
|
sort,
|
|
|
|
/// Identify and continue a numeric pattern.
|
|
sequence,
|
|
|
|
/// Type a displayed word backwards.
|
|
reverse,
|
|
}
|
|
|
|
/// A dismiss challenge presented to the user to prove wakefulness.
|
|
class Challenge {
|
|
/// Creates a challenge with its display text, expected answer and hint.
|
|
const Challenge({
|
|
required this.kind,
|
|
required this.display,
|
|
required this.answer,
|
|
required this.hint,
|
|
});
|
|
|
|
/// Which of the five variants this is.
|
|
final ChallengeKind kind;
|
|
|
|
/// Text shown in the large code label.
|
|
final String display;
|
|
|
|
/// Expected typed answer, normalised upper-case.
|
|
final String answer;
|
|
|
|
/// Short instruction shown above the code label.
|
|
final String hint;
|
|
|
|
/// Whether [input] solves this challenge, ignoring case and
|
|
/// surrounding space.
|
|
bool accepts(String input) =>
|
|
input.trim().toUpperCase() == answer.trim().toUpperCase();
|
|
}
|
|
|
|
/// Generates challenges. The random source is injectable so tests are
|
|
/// deterministic.
|
|
class ChallengeGenerator {
|
|
/// Creates a generator; production uses a secure random source.
|
|
ChallengeGenerator({Random? random}) : _random = random ?? Random.secure();
|
|
|
|
final Random _random;
|
|
|
|
/// Picks a challenge kind at random and generates an instance.
|
|
Challenge next() {
|
|
final builders = <Challenge Function()>[math, sort, sequence, reverse];
|
|
return builders[_random.nextInt(builders.length)]();
|
|
}
|
|
|
|
/// An arithmetic problem: addition, subtraction, multiplication or division.
|
|
Challenge math() {
|
|
const hint = 'Solve and type the answer';
|
|
switch (_random.nextInt(4)) {
|
|
case 0:
|
|
final a = 100 + _random.nextInt(400);
|
|
final b = 100 + _random.nextInt(400);
|
|
return Challenge(
|
|
kind: ChallengeKind.math,
|
|
display: '$a + $b = ?',
|
|
answer: '${a + b}',
|
|
hint: hint,
|
|
);
|
|
case 1:
|
|
final a = 200 + _random.nextInt(300);
|
|
final b = 100 + _random.nextInt(a - 100);
|
|
return Challenge(
|
|
kind: ChallengeKind.math,
|
|
display: '$a - $b = ?',
|
|
answer: '${a - b}',
|
|
hint: hint,
|
|
);
|
|
case 2:
|
|
final a = 12 + _random.nextInt(14);
|
|
final b = 3 + _random.nextInt(7);
|
|
return Challenge(
|
|
kind: ChallengeKind.math,
|
|
display: '$a * $b = ?',
|
|
answer: '${a * b}',
|
|
hint: hint,
|
|
);
|
|
default:
|
|
// Division: build a from b first so the quotient is always an integer.
|
|
final b = 3 + _random.nextInt(8);
|
|
final a = (10 + _random.nextInt(20)) * b;
|
|
return Challenge(
|
|
kind: ChallengeKind.math,
|
|
display: '$a ÷ $b = ?',
|
|
answer: '${a ~/ b}',
|
|
hint: hint,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Eight shuffled digits the user must type in ascending order.
|
|
Challenge sort() {
|
|
final pool = List<int>.generate(9, (i) => i + 1);
|
|
for (var i = pool.length - 1; i > 0; i--) {
|
|
final j = _random.nextInt(i + 1);
|
|
final tmp = pool[i];
|
|
pool[i] = pool[j];
|
|
pool[j] = tmp;
|
|
}
|
|
final digits = pool.take(8).toList();
|
|
final ascending = List<int>.from(digits)..sort();
|
|
return Challenge(
|
|
kind: ChallengeKind.sort,
|
|
display: digits.join(' '),
|
|
answer: ascending.join(),
|
|
hint: 'Type digits sorted lowest → highest (no spaces)',
|
|
);
|
|
}
|
|
|
|
/// Four terms of an arithmetic or geometric sequence; the user gives
|
|
/// the fifth term.
|
|
Challenge sequence() {
|
|
const hint = 'Find the pattern and type the next number';
|
|
if (_random.nextInt(2) == 0) {
|
|
final start = 2 + _random.nextInt(14);
|
|
final step = 2 + _random.nextInt(8);
|
|
final terms = List<int>.generate(4, (i) => start + step * i);
|
|
return Challenge(
|
|
kind: ChallengeKind.sequence,
|
|
display: '${terms.join(', ')}, ?',
|
|
answer: '${start + step * 4}',
|
|
hint: hint,
|
|
);
|
|
}
|
|
final start = 2 + _random.nextInt(5);
|
|
final ratio = 2 + _random.nextInt(2);
|
|
final terms = List<int>.generate(4, (i) => start * _pow(ratio, i));
|
|
return Challenge(
|
|
kind: ChallengeKind.sequence,
|
|
display: '${terms.join(', ')}, ?',
|
|
answer: '${start * _pow(ratio, 4)}',
|
|
hint: hint,
|
|
);
|
|
}
|
|
|
|
/// A common word the user must type backwards.
|
|
Challenge reverse() {
|
|
final word = _reverseWords[_random.nextInt(_reverseWords.length)];
|
|
final backwards = word.split('').reversed.join();
|
|
return Challenge(
|
|
kind: ChallengeKind.reverse,
|
|
display: 'Type this word backwards:\n$word',
|
|
answer: backwards,
|
|
hint: 'Type the word backwards',
|
|
);
|
|
}
|
|
|
|
/// Integer exponentiation; `pow` returns a `num` and would need a cast.
|
|
static int _pow(int base, int exponent) {
|
|
var result = 1;
|
|
for (var i = 0; i < exponent; i++) {
|
|
result *= base;
|
|
}
|
|
return result;
|
|
}
|
|
}
|