mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Require two tags, the walk, and three answers to dismiss
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
This commit is contained in:
parent
af07f52af1
commit
150a8cd7e5
@ -18,8 +18,9 @@ intent) · `AlarmActivity` (dismiss screen over the lockscreen) ·
|
||||
**Never replace any of this with a pub plugin.** The stack is AGP 9.0.1 /
|
||||
Gradle 9.1 / Kotlin 2.3.20; a plugin that will not compile against AGP 9 breaks
|
||||
the build outright rather than degrading, and this is the one path that must
|
||||
not break. Third-party plugins are acceptable only for droppable extras (QR
|
||||
scanning) — never for the ring path.
|
||||
not break. Third-party plugins are acceptable only for droppable extras —
|
||||
never for the ring path, and never for NFC or the step counter, both of which
|
||||
the dismiss sequence now hard-depends on.
|
||||
|
||||
Things that are a hard crash or a silent no-ring, not a warning:
|
||||
|
||||
@ -71,15 +72,37 @@ once and then silently stops.
|
||||
The two are deliberately not synced — a shared day set is item 8's shape, and
|
||||
diverging defaults would have the halves firing on different days.
|
||||
|
||||
`lib/services/challenge.dart` is a port of `_challenges.py`'s five kinds so
|
||||
both halves behave identically; `kDismissCodeLength`/`kDismissFlashSeconds`
|
||||
are **duplicated** from `_constants.py`, not synced. Change one, change both.
|
||||
`lib/services/challenge.dart` owns the four challenge kinds. **Every kind
|
||||
must keep its prompt on screen until it is answered.** A memorise-then-hide
|
||||
kind existed once and was deleted outright: the ring screen is reached by
|
||||
walking in from another room, so anything already gone by the time the user
|
||||
looks at it is unsolvable, not merely hard.
|
||||
|
||||
Room proof (item 3) is `lib/services/room_proof.dart`: **NFC or QR satisfies
|
||||
on its own; steps alone never does** — the phone is stationary while you walk
|
||||
to it, so steps only accrue after pickup and can be faked by shaking it in
|
||||
bed. Steps are accepted only together with elapsed time, as the fallback for
|
||||
when both other mechanisms fail.
|
||||
Room proof is now a **strict, latched sequence**, owned by
|
||||
`lib/services/dismiss_gate.dart`:
|
||||
|
||||
tag 1 -> 25 steps + 2 min -> tag 2 -> 3 challenges
|
||||
|
||||
Every stage is required, they must happen **in that order**, and a stage once
|
||||
passed **never reverts** — an unknown or out-of-order tag is ignored rather
|
||||
than clearing progress. `room_proof.dart` answers only "did this one check
|
||||
pass just now"; the ordering lives in the gate. Two *distinct* tags are
|
||||
required: the same id in both slots is refused at registration, because one
|
||||
tag satisfying both stages removes the walk that is the entire point.
|
||||
|
||||
Steps still require elapsed time as well as a count — the phone is stationary
|
||||
while you walk to it, so a bare count can be faked by shaking it in bed. That
|
||||
pair is unchanged from when steps were a fallback; it is only its role that
|
||||
changed, from alternative to prerequisite.
|
||||
|
||||
**The bypass is the only fallback, and it is deliberately narrow.** After
|
||||
`DismissGate.bypassAfter` (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 challenge 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 replaced. Without some such hatch a dead tag at 06:00 leaves an
|
||||
alarm that literally cannot be switched off.
|
||||
|
||||
**`ringNow` + `lib/main_ring_probe.dart` are a deliberate manual test
|
||||
handle**, the phone-side equivalent of `wake-alarm.service` on the PC: the
|
||||
|
||||
63
phone_app/lib/screens/ring_challenge.dart
Normal file
63
phone_app/lib/screens/ring_challenge.dart
Normal file
@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||
|
||||
/// The question half of the ring screen, shown once the walk is proven.
|
||||
class RingChallenge extends StatelessWidget {
|
||||
/// Creates the challenge view for [challenge].
|
||||
const RingChallenge({
|
||||
required this.challenge,
|
||||
required this.controller,
|
||||
required this.passed,
|
||||
required this.total,
|
||||
required this.onSubmit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The question currently being asked.
|
||||
final Challenge challenge;
|
||||
|
||||
/// Controller for the answer field, owned by the ring screen.
|
||||
final TextEditingController controller;
|
||||
|
||||
/// How many questions have been answered correctly so far.
|
||||
final int passed;
|
||||
|
||||
/// How many are needed in total; higher once the bypass is active.
|
||||
final int total;
|
||||
|
||||
/// Called when the user submits an answer.
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Question ${passed + 1} of $total',
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(challenge.hint, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
// Always visible: a challenge that hides itself is unsolvable when
|
||||
// the user reaches the screen having walked in from another room.
|
||||
challenge.display,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(labelText: 'Answer'),
|
||||
onSubmitted: (_) => onSubmit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onSubmit, child: const Text('Submit')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
67
phone_app/lib/screens/ring_checklist.dart
Normal file
67
phone_app/lib/screens/ring_checklist.dart
Normal file
@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||
|
||||
/// The three-step progress list shown while the room proof is incomplete.
|
||||
///
|
||||
/// Showing every stage up front — including the ones not yet live — is
|
||||
/// deliberate: a half-asleep user needs to see that a second tag is coming,
|
||||
/// not discover it after walking back to bed.
|
||||
class RingChecklist extends StatelessWidget {
|
||||
/// Creates a checklist reflecting [gate]'s current stage.
|
||||
const RingChecklist({required this.gate, super.key});
|
||||
|
||||
/// The sequence being displayed.
|
||||
final DismissGate gate;
|
||||
|
||||
static const List<(DismissStage, String)> _rows = <(DismissStage, String)>[
|
||||
(DismissStage.tag1, 'Tap tag 1'),
|
||||
(DismissStage.steps, 'Walk 25 steps'),
|
||||
(DismissStage.tag2, 'Tap tag 2'),
|
||||
];
|
||||
|
||||
/// Whether [stage] has already been passed.
|
||||
bool _isDone(DismissStage stage) => stage.index < gate.stage.index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (final (stage, label) in _rows)
|
||||
if (!(gate.bypassActive && stage != DismissStage.steps))
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
_isDone(stage)
|
||||
? Icons.check_circle
|
||||
: Icons.radio_button_unchecked,
|
||||
color: _isDone(stage)
|
||||
? theme.colorScheme.primary
|
||||
: theme.disabledColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: gate.stage == stage
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (gate.bypassActive)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'Tags unavailable — walk, then answer '
|
||||
'${gate.challengesRequired} questions instead.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,19 +2,30 @@ import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:wake_alarm_sync/screens/ring_challenge.dart';
|
||||
import 'package:wake_alarm_sync/screens/ring_checklist.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_policy.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
/// How often the live steps stage re-reads the step counter.
|
||||
const Duration kStepPollInterval = Duration(seconds: 2);
|
||||
|
||||
/// The dismiss screen shown over the lockscreen while the alarm rings.
|
||||
///
|
||||
/// Two gates, in order: prove you are in the other room, then solve a
|
||||
/// challenge. Only then does the sound stop.
|
||||
/// A strict sequence: tag 1, then the walk, then tag 2, then the challenges.
|
||||
/// `DismissGate` owns the ordering; this widget owns the clocks and the UI.
|
||||
class RingScreen extends StatefulWidget {
|
||||
/// Creates the ring screen; both dependencies are injectable for tests.
|
||||
const RingScreen({super.key, this.scheduler, this.generator});
|
||||
/// Creates the ring screen; every dependency is injectable for tests.
|
||||
const RingScreen({
|
||||
super.key,
|
||||
this.scheduler,
|
||||
this.generator,
|
||||
this.clock,
|
||||
});
|
||||
|
||||
/// Platform bridge; defaults to the real MethodChannel.
|
||||
final AlarmScheduler? scheduler;
|
||||
@ -22,6 +33,13 @@ class RingScreen extends StatefulWidget {
|
||||
/// Challenge source; defaults to a secure-random generator.
|
||||
final ChallengeGenerator? generator;
|
||||
|
||||
/// Time since the ring started; defaults to a real `Stopwatch`.
|
||||
///
|
||||
/// Injectable because `Stopwatch` reads the wall clock, which
|
||||
/// `tester.pump()` does not advance — without this seam no widget test can
|
||||
/// reach the two-minute steps floor or the five-minute bypass.
|
||||
final Duration Function()? clock;
|
||||
|
||||
@override
|
||||
State<RingScreen> createState() => _RingScreenState();
|
||||
}
|
||||
@ -30,55 +48,91 @@ class _RingScreenState extends State<RingScreen> {
|
||||
late final AlarmScheduler _scheduler = widget.scheduler ?? AlarmScheduler();
|
||||
late final ChallengeGenerator _generator =
|
||||
widget.generator ?? ChallengeGenerator();
|
||||
RoomProof _proof = RoomProof(scheduler: AlarmScheduler());
|
||||
RoomProofSettings _settings = const RoomProofSettings();
|
||||
bool _nfcAvailable = false;
|
||||
late RoomProof _proof = RoomProof(scheduler: _scheduler);
|
||||
final DismissGate _gate = DismissGate();
|
||||
late Challenge _challenge = _generator.next();
|
||||
|
||||
final TextEditingController _input = TextEditingController();
|
||||
final Stopwatch _elapsed = Stopwatch()..start();
|
||||
final Stopwatch _stopwatch = Stopwatch()..start();
|
||||
late final Duration Function() _elapsed =
|
||||
widget.clock ?? () => _stopwatch.elapsed;
|
||||
Timer? _bypassTimer;
|
||||
Timer? _stepTimer;
|
||||
|
||||
/// Whether the room proof has been satisfied; the challenge is gated on it.
|
||||
bool _inRoom = false;
|
||||
String _status = '';
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_proof = RoomProof(scheduler: _scheduler);
|
||||
unawaited(_scheduler.beginStepCount());
|
||||
unawaited(_loadRoomProof());
|
||||
// The escape hatch. Set up before anything can fail, so a crash in the
|
||||
// settings load cannot leave an alarm with no way out.
|
||||
_bypassTimer = Timer(DismissGate.bypassAfter, _onBypassDue);
|
||||
_stepTimer = Timer.periodic(kStepPollInterval, (_) => _pollSteps());
|
||||
}
|
||||
|
||||
/// Loads the registered tag/QR and starts listening for NFC taps.
|
||||
/// Loads the registered tags and starts listening for NFC taps.
|
||||
Future<void> _loadRoomProof() async {
|
||||
final settings = await RoomProofSettings.load();
|
||||
final hasNfc = await _scheduler.hasNfc();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_settings = settings;
|
||||
_nfcAvailable = hasNfc;
|
||||
_proof = RoomProof(
|
||||
scheduler: _scheduler,
|
||||
registeredTagId: settings.nfcTagId,
|
||||
);
|
||||
_proof = RoomProof(scheduler: _scheduler, settings: settings);
|
||||
// Nothing to wait for: without NFC or without two distinct tags the
|
||||
// tag stages can never be satisfied, so making the user sit through
|
||||
// the full timer would be pure punishment for a config problem.
|
||||
if (!hasNfc || !settings.isComplete) {
|
||||
_gate.activateBypass();
|
||||
_status = hasNfc
|
||||
? 'Register two NFC tags in Settings.'
|
||||
: 'NFC is off or unavailable.';
|
||||
}
|
||||
});
|
||||
_scheduler.onNfcTag(_onNfcTag);
|
||||
}
|
||||
|
||||
void _onBypassDue() {
|
||||
if (!mounted) return;
|
||||
if (!_gate.activateBypass()) return;
|
||||
developer.log('bypass activated', name: 'WakeAlarmRing');
|
||||
setState(() => _status = 'Taking too long — tags skipped.');
|
||||
}
|
||||
|
||||
void _onNfcTag(String tagId) {
|
||||
final result = _proof.checkNfc(tagId);
|
||||
final slot = _gate.expectedTagSlot;
|
||||
if (slot == null) return;
|
||||
final result = _proof.checkNfc(tagId, slot: slot);
|
||||
developer.log(
|
||||
'nfc tag=$tagId satisfied=${result.satisfied} detail=${result.detail}',
|
||||
'nfc tag=$tagId slot=$slot satisfied=${result.satisfied} '
|
||||
'detail=${result.detail}',
|
||||
name: 'WakeAlarmRing',
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_inRoom = result.satisfied;
|
||||
// Only a pass moves the sequence. A wrong tag reports itself and
|
||||
// changes nothing — stages never un-latch.
|
||||
if (result.satisfied) _gate.passTagStage();
|
||||
_status = result.detail;
|
||||
});
|
||||
if (result.satisfied) _logChallenge();
|
||||
if (_gate.stage == DismissStage.challenges) _logChallenge();
|
||||
}
|
||||
|
||||
/// Re-reads the step counter while the steps stage is live.
|
||||
///
|
||||
/// Polled rather than button-driven: the walk to tag 1 has usually already
|
||||
/// banked the steps, so the only thing still binding is the elapsed-time
|
||||
/// floor. Making the user hunt for a button to discover that is friction
|
||||
/// without difficulty.
|
||||
Future<void> _pollSteps() async {
|
||||
if (_gate.stage != DismissStage.steps) return;
|
||||
final result = await _proof.checkSteps(_elapsed());
|
||||
if (!mounted || _gate.stage != DismissStage.steps) return;
|
||||
setState(() {
|
||||
if (result.satisfied) _gate.passStepsStage();
|
||||
_status = result.detail;
|
||||
});
|
||||
if (_gate.stage == DismissStage.challenges) _logChallenge();
|
||||
}
|
||||
|
||||
/// Logs the current challenge so a hardware test can be scripted.
|
||||
@ -88,54 +142,42 @@ class _RingScreenState extends State<RingScreen> {
|
||||
void _logChallenge() {
|
||||
developer.log(
|
||||
'challenge kind=${_challenge.kind.name} display="${_challenge.display}" '
|
||||
'answer=${_challenge.answer}',
|
||||
'answer=${_challenge.answer} '
|
||||
'${_gate.challengesPassed}/${_gate.challengesRequired}',
|
||||
name: 'WakeAlarmRing',
|
||||
);
|
||||
}
|
||||
|
||||
/// Called by the QR scanner when a code is decoded.
|
||||
void onQrScanned(String payload) {
|
||||
final result = _proof.checkQr(payload, expected: _settings.qrPayload ?? '');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_inRoom = result.satisfied;
|
||||
_status = result.detail;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bypassTimer?.cancel();
|
||||
_stepTimer?.cancel();
|
||||
_scheduler.clearNfcListener();
|
||||
_input.dispose();
|
||||
_elapsed.stop();
|
||||
_stopwatch.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _trySteps() async {
|
||||
final result = await _proof.checkSteps(_elapsed.elapsed);
|
||||
developer.log(
|
||||
'steps satisfied=${result.satisfied} detail=${result.detail}',
|
||||
name: 'WakeAlarmRing',
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_inRoom = result.satisfied;
|
||||
_status = result.detail;
|
||||
});
|
||||
if (result.satisfied) _logChallenge();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_challenge.accepts(_input.text)) {
|
||||
setState(() {
|
||||
_status = 'Wrong — here is another one';
|
||||
final correct = _challenge.accepts(_input.text);
|
||||
setState(() {
|
||||
_gate.recordChallenge(correct: correct);
|
||||
_input.clear();
|
||||
// A wrong answer costs the attempt only; the tally never falls back.
|
||||
if (_gate.isDone) {
|
||||
_status = 'Dismissed';
|
||||
} else {
|
||||
_status = correct
|
||||
? 'Correct — next one'
|
||||
: 'Wrong — here is another one';
|
||||
_challenge = _generator.next();
|
||||
_input.clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (_gate.isDone) {
|
||||
unawaited(_finishDismiss());
|
||||
} else {
|
||||
_logChallenge();
|
||||
}
|
||||
setState(() => _status = 'Dismissed');
|
||||
unawaited(_finishDismiss());
|
||||
}
|
||||
|
||||
/// Stops the ring, then closes this screen.
|
||||
@ -156,6 +198,7 @@ class _RingScreenState extends State<RingScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final inChallenges = _gate.stage == DismissStage.challenges;
|
||||
return PopScope(
|
||||
// The alarm cannot be escaped with back; dismissing is the only way out.
|
||||
canPop: false,
|
||||
@ -168,7 +211,16 @@ class _RingScreenState extends State<RingScreen> {
|
||||
children: <Widget>[
|
||||
Text('Wake up', style: theme.textTheme.headlineMedium),
|
||||
const SizedBox(height: 24),
|
||||
if (!_inRoom) ..._roomGate(theme) else ..._challengeGate(theme),
|
||||
if (inChallenges)
|
||||
RingChallenge(
|
||||
challenge: _challenge,
|
||||
controller: _input,
|
||||
passed: _gate.challengesPassed,
|
||||
total: _gate.challengesRequired,
|
||||
onSubmit: _submit,
|
||||
)
|
||||
else
|
||||
RingChecklist(gate: _gate),
|
||||
const SizedBox(height: 16),
|
||||
Text(_status, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
@ -179,41 +231,4 @@ class _RingScreenState extends State<RingScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _roomGate(ThemeData theme) => <Widget>[
|
||||
Text(
|
||||
_nfcAvailable && _settings.nfcTagId != null
|
||||
? 'Go to the other room and tap the NFC tag.'
|
||||
: 'Go to the other room: scan the QR code, or walk '
|
||||
'${RoomProof.requiredSteps} steps.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// The steps fallback stays reachable even when NFC is registered: a
|
||||
// single failing mechanism at 06:00 must never make the alarm
|
||||
// undismissable.
|
||||
FilledButton(onPressed: _trySteps, child: const Text('Check steps')),
|
||||
];
|
||||
|
||||
List<Widget> _challengeGate(ThemeData theme) => <Widget>[
|
||||
Text(_challenge.hint, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
// Always visible: a challenge that hides itself is unsolvable when the
|
||||
// user reaches the screen seconds later, having walked to another room.
|
||||
_challenge.display,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _input,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(labelText: 'Answer'),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _submit, child: const Text('Dismiss')),
|
||||
];
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
/// Registers the separate-room proofs: the NFC tag and the printed QR code.
|
||||
/// Registers the two NFC tags the dismiss sequence walks between.
|
||||
///
|
||||
/// Without a registered tag, NFC can never satisfy the room proof and the
|
||||
/// steps fallback becomes the only way to dismiss — which is exactly the
|
||||
/// single-mechanism dependency the redundancy exists to avoid.
|
||||
/// Both slots must hold **different** tags. With fewer than two distinct tags
|
||||
/// the tag stages are unsatisfiable, so the ring screen falls straight through
|
||||
/// to the bypass — a weaker morning, not a locked-out one.
|
||||
class RoomProofScreen extends StatefulWidget {
|
||||
/// Creates the screen; [scheduler] is injectable for tests.
|
||||
const RoomProofScreen({super.key, this.scheduler});
|
||||
@ -20,10 +22,9 @@ class RoomProofScreen extends StatefulWidget {
|
||||
|
||||
class _RoomProofScreenState extends State<RoomProofScreen> {
|
||||
late final AlarmScheduler _scheduler = widget.scheduler ?? AlarmScheduler();
|
||||
final TextEditingController _qr = TextEditingController();
|
||||
|
||||
String? _tagId;
|
||||
bool _scanning = false;
|
||||
RoomProofSettings _settings = const RoomProofSettings();
|
||||
int? _scanningSlot;
|
||||
String _status = '';
|
||||
|
||||
@override
|
||||
@ -34,48 +35,75 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_scanning) unawaited(_scheduler.stopTagRegistration());
|
||||
if (_scanningSlot != null) unawaited(_scheduler.stopTagRegistration());
|
||||
_scheduler.clearNfcListener();
|
||||
_qr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final settings = await RoomProofSettings.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tagId = settings.nfcTagId;
|
||||
_qr.text = settings.qrPayload ?? '';
|
||||
});
|
||||
setState(() => _settings = settings);
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
_scheduler.onNfcTag(_onTag);
|
||||
Future<void> _startScan(int slot) async {
|
||||
_scheduler.onNfcTag((tagId) => unawaited(_onTag(slot, tagId)));
|
||||
final started = await _scheduler.startTagRegistration();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scanning = started;
|
||||
_scanningSlot = started ? slot : null;
|
||||
_status = started
|
||||
? 'Tap the tag against the phone…'
|
||||
? 'Tap tag ${slot + 1} against the phone…'
|
||||
: 'NFC is unavailable or switched off.';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onTag(String tagId) async {
|
||||
await RoomProofSettings.saveNfcTag(tagId);
|
||||
Future<void> _onTag(int slot, String tagId) async {
|
||||
// The same tag in both slots would let one tap satisfy both stages, which
|
||||
// removes the walk between them — the entire point of having two.
|
||||
final other = _settings.tagAt(slot == 0 ? 1 : 0);
|
||||
if (tagId == other) {
|
||||
await _scheduler.stopTagRegistration();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scanningSlot = null;
|
||||
_status = 'That is already tag ${slot == 0 ? 2 : 1}. Use a different '
|
||||
'tag, in a different room.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
await RoomProofSettings.saveNfcTagAt(slot, tagId);
|
||||
final settings = await RoomProofSettings.load();
|
||||
await _scheduler.stopTagRegistration();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tagId = tagId;
|
||||
_scanning = false;
|
||||
_status = 'Tag registered.';
|
||||
_settings = settings;
|
||||
_scanningSlot = null;
|
||||
_status = 'Tag ${slot + 1} registered.';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveQr() async {
|
||||
await RoomProofSettings.saveQrPayload(_qr.text.trim());
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'QR code saved.');
|
||||
Widget _slotRow(int slot) {
|
||||
final id = _settings.tagAt(slot);
|
||||
final scanning = _scanningSlot == slot;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text('Tag ${slot + 1}: ${id ?? "none registered"}'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: _scanningSlot != null ? null : () => _startScan(slot),
|
||||
child: Text(
|
||||
scanning
|
||||
? 'Waiting for tag…'
|
||||
: 'Register tag ${slot + 1}${id == null ? "" : " again"}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -87,23 +115,13 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text('NFC tag: ${_tagId ?? "none registered"}'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _scanning ? null : _startScan,
|
||||
child: Text(_scanning ? 'Waiting for tag…' : 'Register NFC tag'),
|
||||
const Text(
|
||||
'Put the two tags in different rooms. Dismissing the alarm '
|
||||
'means tapping tag 1, walking, then tapping tag 2.',
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _qr,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'QR code text',
|
||||
helperText: 'Text encoded in the code printed for that room',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _saveQr, child: const Text('Save QR code')),
|
||||
const SizedBox(height: 24),
|
||||
for (var i = 0; i < RoomProofSettings.requiredTagCount; i++)
|
||||
_slotRow(i),
|
||||
Text(_status),
|
||||
],
|
||||
),
|
||||
@ -111,6 +129,3 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local helper so this file need not import dart:async for fire-and-forget.
|
||||
void unawaited(Future<void> future) {}
|
||||
|
||||
@ -11,7 +11,7 @@ import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase,
|
||||
/// identical in shape to every other kuhy app's Sync settings screen).
|
||||
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]), and
|
||||
/// "Room proof" registers the NFC tag / QR code ([RoomProofScreen]) — without
|
||||
/// "Room proof" registers the two NFC tags ([RoomProofScreen]) — without
|
||||
/// a registered tag, NFC can never satisfy the dismiss room proof.
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
/// Creates a [SettingsScreen].
|
||||
@ -46,7 +46,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Room proof'),
|
||||
subtitle: const Text('NFC tag and QR code for the other room'),
|
||||
subtitle: const Text('Two NFC tags, at two locations'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const RoomProofScreen()),
|
||||
|
||||
@ -1,23 +1,12 @@
|
||||
/// Dismiss-challenge types, ported from the PC half's
|
||||
/// `wake_alarm/_challenges.py` so both halves behave identically.
|
||||
/// Dismiss-challenge types.
|
||||
///
|
||||
/// The two constants below are duplicated from `wake_alarm/_constants.py`
|
||||
/// (`DISMISS_CODE_LENGTH`, `DISMISS_FLASH_SECONDS`) rather than synced — the
|
||||
/// Dart side has no way to read Python. If they change there, change them here.
|
||||
/// 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';
|
||||
|
||||
/// Length of a generated dismiss code. Mirrors `DISMISS_CODE_LENGTH`.
|
||||
const int kDismissCodeLength = 10;
|
||||
|
||||
/// Seconds a flash challenge stays visible. Mirrors `DISMISS_FLASH_SECONDS`.
|
||||
const int kDismissFlashSeconds = 4;
|
||||
|
||||
/// Uppercase alphanumerics with visually ambiguous characters removed:
|
||||
/// O/0 and I/1 are excluded so the code is legible half-asleep.
|
||||
const String _dismissChars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
/// Words for the reverse challenge — common, unambiguous when read half-asleep.
|
||||
const List<String> _reverseWords = <String>[
|
||||
'MORNING',
|
||||
@ -37,7 +26,7 @@ const List<String> _reverseWords = <String>[
|
||||
'WEEKEND',
|
||||
];
|
||||
|
||||
/// The five challenge kinds, matching the PC half's `kind` strings exactly.
|
||||
/// The four challenge kinds. All keep their prompt visible while answering.
|
||||
enum ChallengeKind {
|
||||
/// Solve an arithmetic problem.
|
||||
math,
|
||||
@ -45,9 +34,6 @@ enum ChallengeKind {
|
||||
/// Type shuffled digits in ascending order.
|
||||
sort,
|
||||
|
||||
/// Memorise a code before it is hidden.
|
||||
flash,
|
||||
|
||||
/// Identify and continue a numeric pattern.
|
||||
sequence,
|
||||
|
||||
@ -92,26 +78,11 @@ class ChallengeGenerator {
|
||||
final Random _random;
|
||||
|
||||
/// Picks a challenge kind at random and generates an instance.
|
||||
///
|
||||
/// [flash] is deliberately **excluded**. It hides the code after
|
||||
/// [kDismissFlashSeconds], and on the phone that is unsolvable in practice:
|
||||
/// the ring screen is reached by walking to another room and tapping a tag,
|
||||
/// so the code has usually already vanished by the time the user is looking
|
||||
/// at the screen — reported from the phone on 2026-08-27 as "impossible to
|
||||
/// actually do". Every challenge the alarm shows must stay on screen until
|
||||
/// it is answered. [flash] is kept as a constructor for the PC half's
|
||||
/// parity tests, but nothing routes to it here.
|
||||
Challenge next() {
|
||||
final builders = <Challenge Function()>[math, sort, sequence, reverse];
|
||||
return builders[_random.nextInt(builders.length)]();
|
||||
}
|
||||
|
||||
/// Generates a random dismiss code with ambiguous characters excluded.
|
||||
String generateCode() => List<String>.generate(
|
||||
kDismissCodeLength,
|
||||
(_) => _dismissChars[_random.nextInt(_dismissChars.length)],
|
||||
).join();
|
||||
|
||||
/// An arithmetic problem: addition, subtraction, multiplication or division.
|
||||
Challenge math() {
|
||||
const hint = 'Solve and type the answer';
|
||||
@ -175,17 +146,6 @@ class ChallengeGenerator {
|
||||
);
|
||||
}
|
||||
|
||||
/// A code shown briefly, then hidden; the user types it from memory.
|
||||
Challenge flash() {
|
||||
final code = generateCode();
|
||||
return Challenge(
|
||||
kind: ChallengeKind.flash,
|
||||
display: code,
|
||||
answer: code,
|
||||
hint: 'Memorise this code — it disappears in ${kDismissFlashSeconds}s',
|
||||
);
|
||||
}
|
||||
|
||||
/// Four terms of an arithmetic or geometric sequence; the user gives
|
||||
/// the fifth term.
|
||||
Challenge sequence() {
|
||||
|
||||
146
phone_app/lib/services/dismiss_gate.dart
Normal file
146
phone_app/lib/services/dismiss_gate.dart
Normal file
@ -0,0 +1,146 @@
|
||||
/// The stages of the dismiss sequence, in the order they must be satisfied.
|
||||
enum DismissStage {
|
||||
/// Tap the first registered tag.
|
||||
tag1,
|
||||
|
||||
/// Walk far enough, for long enough.
|
||||
steps,
|
||||
|
||||
/// Tap the second registered tag, somewhere else.
|
||||
tag2,
|
||||
|
||||
/// Answer challenges until the required count is reached.
|
||||
challenges,
|
||||
|
||||
/// Everything satisfied; the ring may stop.
|
||||
done,
|
||||
}
|
||||
|
||||
/// The dismiss sequence: tag 1 -> steps -> tag 2 -> challenges.
|
||||
///
|
||||
/// **Strictly ordered and latched.** Every stage is required and each one,
|
||||
/// once passed, can never revert — an unknown or out-of-order tag is simply
|
||||
/// ignored. The previous gate was an OR that reverted on a wrong tap, so a
|
||||
/// mistimed tag could send a half-awake user back to the start.
|
||||
///
|
||||
/// This owns sequencing only. Whether a given tag or step count actually
|
||||
/// passes is `RoomProof`'s job, and generating questions is
|
||||
/// `ChallengeGenerator`'s.
|
||||
class DismissGate {
|
||||
/// Creates a gate at the first stage.
|
||||
DismissGate();
|
||||
|
||||
/// How long the alarm may ring before NFC stops being required.
|
||||
///
|
||||
/// The escape hatch for a dead tag, an NFC radio that will not read, or a
|
||||
/// tag that has fallen off the wall. Without it a hardware failure at 06:00
|
||||
/// leaves an alarm that literally cannot be switched off.
|
||||
static const Duration bypassAfter = Duration(minutes: 5);
|
||||
|
||||
/// Challenges required on the normal path.
|
||||
static const int challengesNormal = 3;
|
||||
|
||||
/// Challenges required once the NFC stages have been bypassed.
|
||||
///
|
||||
/// Higher than [challengesNormal] so skipping the walk is never the cheap
|
||||
/// option, even though it is always an available one.
|
||||
static const int challengesBypass = 6;
|
||||
|
||||
DismissStage _stage = DismissStage.tag1;
|
||||
int _challengesPassed = 0;
|
||||
int _challengesRequired = challengesNormal;
|
||||
bool _bypassed = false;
|
||||
|
||||
/// The stage currently awaiting satisfaction.
|
||||
DismissStage get stage => _stage;
|
||||
|
||||
/// Challenges answered correctly so far.
|
||||
int get challengesPassed => _challengesPassed;
|
||||
|
||||
/// Challenges needed in total; rises to [challengesBypass] under bypass.
|
||||
int get challengesRequired => _challengesRequired;
|
||||
|
||||
/// Whether the NFC stages have been skipped.
|
||||
bool get bypassActive => _bypassed;
|
||||
|
||||
/// Whether the alarm may now be dismissed.
|
||||
bool get isDone => _stage == DismissStage.done;
|
||||
|
||||
/// Whether a tag tap could advance anything right now.
|
||||
bool get awaitingTag =>
|
||||
_stage == DismissStage.tag1 || _stage == DismissStage.tag2;
|
||||
|
||||
/// Which registered tag slot the live stage expects, or null if none does.
|
||||
///
|
||||
/// The ring screen passes this to `RoomProof.checkNfc` so that tapping the
|
||||
/// *other* registered tag fails: two locations must stay two locations.
|
||||
int? get expectedTagSlot => switch (_stage) {
|
||||
DismissStage.tag1 => 0,
|
||||
DismissStage.tag2 => 1,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Records that the tag for the live stage was tapped.
|
||||
///
|
||||
/// Ignored unless a tag stage is live, so a tap arriving during the steps
|
||||
/// stage or after bypass changes nothing.
|
||||
bool passTagStage() {
|
||||
if (_stage == DismissStage.tag1) {
|
||||
_stage = DismissStage.steps;
|
||||
return true;
|
||||
}
|
||||
if (_stage == DismissStage.tag2) {
|
||||
_stage = DismissStage.challenges;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Records that the step requirement was met.
|
||||
///
|
||||
/// Ignored unless the steps stage is live: banking steps early must not
|
||||
/// carry over, or walking to tag 1 would satisfy the walk by itself.
|
||||
bool passStepsStage() {
|
||||
if (_stage != DismissStage.steps) return false;
|
||||
_stage = _bypassed ? DismissStage.challenges : DismissStage.tag2;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Records a challenge answer; only a correct one counts.
|
||||
///
|
||||
/// A wrong answer costs the attempt and nothing else — the count never
|
||||
/// decreases, so guessing cannot dig a hole a half-asleep user can't climb.
|
||||
bool recordChallenge({required bool correct}) {
|
||||
if (_stage != DismissStage.challenges) return false;
|
||||
if (!correct) return false;
|
||||
_challengesPassed++;
|
||||
if (_challengesPassed >= _challengesRequired) {
|
||||
_stage = DismissStage.done;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Drops the NFC requirement, raising the challenge count instead.
|
||||
///
|
||||
/// **Only the two tag stages are skipped — the walk still counts.** Jumping
|
||||
/// straight to the challenges would let someone lie in bed for
|
||||
/// [bypassAfter] and then answer questions without ever standing up, which
|
||||
/// is a weaker gate than the one this change replaced.
|
||||
///
|
||||
/// One-way and idempotent. Deliberately refused once the challenge stage is
|
||||
/// live: the user has already done the walk by then, and raising 3 to 6
|
||||
/// mid-answer would punish them for being slow at arithmetic.
|
||||
bool activateBypass() {
|
||||
if (_bypassed) return false;
|
||||
if (_stage == DismissStage.challenges || _stage == DismissStage.done) {
|
||||
return false;
|
||||
}
|
||||
_bypassed = true;
|
||||
_challengesRequired = challengesBypass;
|
||||
// Past tag 2 the steps are already banked; otherwise fall back to them.
|
||||
_stage = _stage == DismissStage.tag2
|
||||
? DismissStage.challenges
|
||||
: DismissStage.steps;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
/// Which mechanism satisfied the separate-room requirement.
|
||||
/// Which mechanism satisfied a stage of the separate-room requirement.
|
||||
enum RoomProofKind {
|
||||
/// Tapped the registered NFC tag in the other room.
|
||||
/// Tapped the registered NFC tag for the stage that was live.
|
||||
nfc,
|
||||
|
||||
/// Scanned the printed QR code in the other room.
|
||||
qr,
|
||||
|
||||
/// Walked far enough, for long enough, since the alarm started.
|
||||
steps,
|
||||
}
|
||||
@ -17,7 +15,7 @@ class RoomProofResult {
|
||||
/// Creates a result; [kind] is null when nothing has satisfied yet.
|
||||
const RoomProofResult({required this.satisfied, this.kind, this.detail = ''});
|
||||
|
||||
/// Whether the user has proven they are in the other room.
|
||||
/// Whether this particular check passed.
|
||||
final bool satisfied;
|
||||
|
||||
/// Which mechanism satisfied it, or null if none did.
|
||||
@ -27,66 +25,60 @@ class RoomProofResult {
|
||||
final String detail;
|
||||
}
|
||||
|
||||
/// Proof that the user physically walked to another room before dismissing.
|
||||
/// The individual checks behind the dismiss sequence.
|
||||
///
|
||||
/// **Any one of NFC or QR satisfies.** Steps alone deliberately does NOT: the
|
||||
/// phone is stationary in the other room while you walk *to* it, so steps only
|
||||
/// accrue after pickup and can be faked by shaking it in bed. Steps are only
|
||||
/// accepted together with elapsed time, as the fallback for when both other
|
||||
/// mechanisms fail — a single failing mechanism at 06:00 must never make the
|
||||
/// alarm undismissable.
|
||||
/// **Every check is required, in order** — see `DismissGate`, which owns the
|
||||
/// sequencing. This class only answers "did this one thing pass right now".
|
||||
///
|
||||
/// Steps deliberately require elapsed time as well as a count: the phone is
|
||||
/// stationary while you walk *to* it, so a bare step count can be faked by
|
||||
/// shaking it in bed. The pair is unchanged from when steps were a fallback.
|
||||
class RoomProof {
|
||||
/// Creates a room-proof checker over [scheduler]'s platform channel.
|
||||
RoomProof({required this.scheduler, this._registeredTagId});
|
||||
RoomProof({
|
||||
required this.scheduler,
|
||||
this.settings = const RoomProofSettings(),
|
||||
});
|
||||
|
||||
/// Platform bridge used to read the step counter.
|
||||
final AlarmScheduler scheduler;
|
||||
final String? _registeredTagId;
|
||||
|
||||
/// Steps required by the fallback, counted since the alarm started ringing.
|
||||
/// The registered tags, one per sequence stage.
|
||||
final RoomProofSettings settings;
|
||||
|
||||
/// Steps required, counted since the alarm started ringing.
|
||||
static const int requiredSteps = 25;
|
||||
|
||||
/// Elapsed time the fallback also requires, so shaking the phone cannot pass.
|
||||
/// Elapsed time also required, so shaking the phone cannot pass.
|
||||
static const Duration requiredElapsed = Duration(minutes: 2);
|
||||
|
||||
/// Whether [tagId] is the tag registered in Settings.
|
||||
/// Whether [tagId] is the tag registered for [slot].
|
||||
///
|
||||
/// Registering no tag means NFC cannot satisfy the proof — it does not mean
|
||||
/// every tag passes.
|
||||
RoomProofResult checkNfc(String tagId) {
|
||||
final registered = _registeredTagId;
|
||||
if (registered == null || registered.isEmpty) {
|
||||
return const RoomProofResult(
|
||||
/// A tag registered for a *different* slot fails here: tapping tag 2 while
|
||||
/// stage 1 is live must not advance anything, or the two locations collapse
|
||||
/// into one.
|
||||
RoomProofResult checkNfc(String tagId, {required int slot}) {
|
||||
final expected = settings.tagAt(slot);
|
||||
if (expected == null) {
|
||||
return RoomProofResult(
|
||||
satisfied: false,
|
||||
detail: 'No NFC tag registered in Settings',
|
||||
detail: 'No tag ${slot + 1} registered in Settings',
|
||||
);
|
||||
}
|
||||
if (tagId != registered) {
|
||||
return const RoomProofResult(satisfied: false, detail: 'Wrong NFC tag');
|
||||
if (tagId != expected) {
|
||||
return RoomProofResult(
|
||||
satisfied: false,
|
||||
detail: 'Not tag ${slot + 1}',
|
||||
);
|
||||
}
|
||||
return const RoomProofResult(
|
||||
return RoomProofResult(
|
||||
satisfied: true,
|
||||
kind: RoomProofKind.nfc,
|
||||
detail: 'NFC tag matched',
|
||||
detail: 'Tag ${slot + 1} matched',
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether [payload] is the expected printed QR code.
|
||||
///
|
||||
/// A printed code is photographable, which is exactly why it is one of two
|
||||
/// accepted proofs rather than the only one.
|
||||
RoomProofResult checkQr(String payload, {required String expected}) {
|
||||
if (expected.isEmpty || payload != expected) {
|
||||
return const RoomProofResult(satisfied: false, detail: 'Wrong QR code');
|
||||
}
|
||||
return const RoomProofResult(
|
||||
satisfied: true,
|
||||
kind: RoomProofKind.qr,
|
||||
detail: 'QR code matched',
|
||||
);
|
||||
}
|
||||
|
||||
/// The fallback: enough steps AND enough elapsed time since the ring started.
|
||||
/// Enough steps AND enough elapsed time since the ring started.
|
||||
///
|
||||
/// Both are required together. [elapsed] is passed in rather than measured
|
||||
/// here so the ring screen owns the single clock.
|
||||
|
||||
@ -1,43 +1,91 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persisted room-proof configuration: the registered NFC tag and QR payload.
|
||||
/// Persisted room-proof configuration: the two registered NFC tags.
|
||||
///
|
||||
/// Registering nothing means that mechanism cannot satisfy the proof — it does
|
||||
/// not mean everything passes. See `RoomProof` for why redundancy matters.
|
||||
/// Two tags, in two different places, are what force the walk. Registering
|
||||
/// fewer than [requiredTagCount] does not weaken the sequence into something
|
||||
/// easier — it makes the NFC stages unsatisfiable, which is why the ring
|
||||
/// screen drops straight to the bypass when [isComplete] is false.
|
||||
class RoomProofSettings {
|
||||
/// Creates settings with the registered values, if any.
|
||||
const RoomProofSettings({this.nfcTagId, this.qrPayload});
|
||||
/// Creates settings from the registered tag ids, one per slot.
|
||||
const RoomProofSettings({this.nfcTagIds = const <String>['', '']});
|
||||
|
||||
/// SharedPreferences key for the registered NFC tag id.
|
||||
static const String kNfcTagKey = 'roomProof.nfcTagId';
|
||||
/// SharedPreferences key holding the tag ids as a JSON list.
|
||||
static const String kNfcTagsKey = 'roomProof.nfcTagIds';
|
||||
|
||||
/// SharedPreferences key for the expected QR payload.
|
||||
static const String kQrKey = 'roomProof.qrPayload';
|
||||
/// The pre-two-tag key, migrated into slot 0 on first load.
|
||||
static const String kLegacyNfcTagKey = 'roomProof.nfcTagId';
|
||||
|
||||
/// Lower-case hex id of the tag taped up in the other room.
|
||||
final String? nfcTagId;
|
||||
/// How many tags the strict sequence needs.
|
||||
static const int requiredTagCount = 2;
|
||||
|
||||
/// Text encoded in the printed QR code in the other room.
|
||||
final String? qrPayload;
|
||||
/// Registered tag ids by slot; an empty string means that slot is unset.
|
||||
///
|
||||
/// Always [requiredTagCount] long, so slot writes never have to grow it.
|
||||
final List<String> nfcTagIds;
|
||||
|
||||
/// Loads the stored configuration.
|
||||
/// The tag registered for [slot], or null when that slot is unset.
|
||||
String? tagAt(int slot) {
|
||||
if (slot < 0 || slot >= nfcTagIds.length) return null;
|
||||
final id = nfcTagIds[slot];
|
||||
return id.isEmpty ? null : id;
|
||||
}
|
||||
|
||||
/// Whether every slot holds a distinct tag, so the sequence is satisfiable.
|
||||
///
|
||||
/// Distinctness matters: two slots holding the same id would let one tap
|
||||
/// satisfy both stages, collapsing the walk this whole gate exists to force.
|
||||
bool get isComplete {
|
||||
final ids = <String>{};
|
||||
for (var slot = 0; slot < requiredTagCount; slot++) {
|
||||
final id = tagAt(slot);
|
||||
if (id == null || !ids.add(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Normalises [raw] to exactly [requiredTagCount] slots.
|
||||
static List<String> _padded(List<String> raw) => List<String>.generate(
|
||||
requiredTagCount,
|
||||
(i) => i < raw.length ? raw[i] : '',
|
||||
);
|
||||
|
||||
/// Loads the stored configuration, migrating a single legacy tag if present.
|
||||
static Future<RoomProofSettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString(kNfcTagsKey);
|
||||
if (stored != null) {
|
||||
return RoomProofSettings(nfcTagIds: _padded(_decode(stored)));
|
||||
}
|
||||
// One-time migration: an existing install has a tag registered under the
|
||||
// old key, and silently losing it would leave the user unable to dismiss
|
||||
// by the normal path on the first morning after the update.
|
||||
final legacy = prefs.getString(kLegacyNfcTagKey);
|
||||
return RoomProofSettings(
|
||||
nfcTagId: prefs.getString(kNfcTagKey),
|
||||
qrPayload: prefs.getString(kQrKey),
|
||||
nfcTagIds: _padded(<String>[?legacy]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stores [tagId] as the registered NFC tag.
|
||||
static Future<void> saveNfcTag(String tagId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(kNfcTagKey, tagId);
|
||||
/// Decodes the stored JSON list, treating anything malformed as unset.
|
||||
static List<String> _decode(String stored) {
|
||||
try {
|
||||
final decoded = jsonDecode(stored);
|
||||
if (decoded is! List) return const <String>[];
|
||||
return decoded.whereType<String>().toList();
|
||||
} on FormatException {
|
||||
return const <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores [payload] as the expected QR code text.
|
||||
static Future<void> saveQrPayload(String payload) async {
|
||||
/// Stores [tagId] in [slot], leaving the other slot untouched.
|
||||
static Future<void> saveNfcTagAt(int slot, String tagId) async {
|
||||
final current = await load();
|
||||
final next = List<String>.of(current.nfcTagIds);
|
||||
if (slot < 0 || slot >= next.length) return;
|
||||
next[slot] = tagId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(kQrKey, payload);
|
||||
await prefs.setString(kNfcTagsKey, jsonEncode(next));
|
||||
}
|
||||
}
|
||||
|
||||
116
phone_app/test/ring_bypass_test.dart
Normal file
116
phone_app/test/ring_bypass_test.dart
Normal file
@ -0,0 +1,116 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wake_alarm_sync/screens/ring_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
const String _tag1 = '04a2b3c4';
|
||||
const String _tag2 = '04ffee11';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late List<String> calls;
|
||||
var steps = 0;
|
||||
var hasNfc = true;
|
||||
var elapsed = Duration.zero;
|
||||
|
||||
/// Installs the platform mock. Call after changing [steps] / [hasNfc].
|
||||
void mockChannel() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(AlarmScheduler.channelName),
|
||||
(call) async {
|
||||
calls.add(call.method);
|
||||
if (call.method == 'stepCount') return steps;
|
||||
if (call.method == 'hasNfc') return hasNfc;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Lets the periodic step poll run once.
|
||||
Future<void> pollSteps(WidgetTester tester) async {
|
||||
await tester.pump(kStepPollInterval);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
calls = <String>[];
|
||||
steps = 0;
|
||||
hasNfc = true;
|
||||
elapsed = Duration.zero;
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
mockChannel();
|
||||
});
|
||||
|
||||
/// A ring screen with both tags registered and a controllable clock.
|
||||
Future<ChallengeGenerator> pumpRing(WidgetTester tester) async {
|
||||
await RoomProofSettings.saveNfcTagAt(0, _tag1);
|
||||
await RoomProofSettings.saveNfcTagAt(1, _tag2);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: RingScreen(
|
||||
generator: ChallengeGenerator(random: Random(1)),
|
||||
clock: () => elapsed,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return ChallengeGenerator(random: Random(1));
|
||||
}
|
||||
|
||||
testWidgets('it is not offered before the timer elapses', (tester) async {
|
||||
await pumpRing(tester);
|
||||
await tester.pump(DismissGate.bypassAfter - const Duration(seconds: 1));
|
||||
expect(find.textContaining('Tags unavailable'), findsNothing);
|
||||
expect(find.text('Tap tag 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('after the timer it skips the tags but still needs the walk', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRing(tester);
|
||||
await tester.pump(DismissGate.bypassAfter);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Tags unavailable'), findsOneWidget);
|
||||
expect(find.text('Tap tag 1'), findsNothing);
|
||||
expect(find.text('Walk 25 steps'), findsOneWidget);
|
||||
expect(find.text('Submit'), findsNothing);
|
||||
|
||||
steps = 500;
|
||||
elapsed = const Duration(minutes: 6);
|
||||
await pollSteps(tester);
|
||||
expect(find.text('Question 1 of 6'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an unregistered second tag bypasses immediately', (
|
||||
tester,
|
||||
) async {
|
||||
await RoomProofSettings.saveNfcTagAt(0, _tag1);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: RingScreen(clock: () => elapsed)),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Register two NFC tags'), findsOneWidget);
|
||||
expect(find.textContaining('Tags unavailable'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no NFC hardware bypasses immediately', (tester) async {
|
||||
hasNfc = false;
|
||||
mockChannel();
|
||||
await pumpRing(tester);
|
||||
expect(find.textContaining('NFC is off'), findsOneWidget);
|
||||
expect(find.text('Tap tag 1'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
/// Local helper so the test need not import dart:async for one call.
|
||||
void unawaited(Future<void> future) {}
|
||||
@ -8,22 +8,78 @@ import 'package:wake_alarm_sync/main.dart';
|
||||
import 'package:wake_alarm_sync/screens/ring_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/challenge.dart';
|
||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
const String _tag1 = '04a2b3c4';
|
||||
const String _tag2 = '04ffee11';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
const channel = MethodChannel(AlarmScheduler.channelName);
|
||||
late List<String> calls;
|
||||
var steps = 0;
|
||||
var hasNfc = true;
|
||||
var elapsed = Duration.zero;
|
||||
|
||||
/// Installs the platform mock. Call after changing [steps] / [hasNfc].
|
||||
void mockChannel() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'stepCount') return 0;
|
||||
if (call.method == 'hasNfc') return false;
|
||||
return null;
|
||||
});
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(AlarmScheduler.channelName),
|
||||
(call) async {
|
||||
calls.add(call.method);
|
||||
if (call.method == 'stepCount') return steps;
|
||||
if (call.method == 'hasNfc') return hasNfc;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes a tag up from Kotlin exactly as `NfcReader` does.
|
||||
Future<void> tapTag(WidgetTester tester, String id) async {
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
AlarmScheduler.channelName,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall('onNfcTag', id),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Lets the periodic step poll run once.
|
||||
Future<void> pollSteps(WidgetTester tester) async {
|
||||
await tester.pump(kStepPollInterval);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
calls = <String>[];
|
||||
steps = 0;
|
||||
hasNfc = true;
|
||||
elapsed = Duration.zero;
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
mockChannel();
|
||||
});
|
||||
|
||||
/// A ring screen with both tags registered and a controllable clock.
|
||||
Future<ChallengeGenerator> pumpRing(WidgetTester tester) async {
|
||||
await RoomProofSettings.saveNfcTagAt(0, _tag1);
|
||||
await RoomProofSettings.saveNfcTagAt(1, _tag2);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: RingScreen(
|
||||
generator: ChallengeGenerator(random: Random(1)),
|
||||
clock: () => elapsed,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return ChallengeGenerator(random: Random(1));
|
||||
}
|
||||
|
||||
testWidgets('the /ring route resolves to the ring screen', (tester) async {
|
||||
// AlarmActivity hands "/ring" to the engine as its initial route; if the
|
||||
// app does not resolve it the full-screen intent opens the home screen and
|
||||
@ -36,148 +92,117 @@ void main() {
|
||||
expect(find.byType(RingScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the ring screen shows the room gate before the challenge', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RingScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
testWidgets('it opens on the checklist, not the challenge', (tester) async {
|
||||
await pumpRing(tester);
|
||||
expect(find.text('Wake up'), findsOneWidget);
|
||||
expect(find.text('Check steps'), findsOneWidget);
|
||||
// The challenge must not be reachable until the room proof is satisfied.
|
||||
expect(find.text('Dismiss'), findsNothing);
|
||||
expect(find.text('Tap tag 1'), findsOneWidget);
|
||||
expect(find.text('Walk 25 steps'), findsOneWidget);
|
||||
expect(find.text('Tap tag 2'), findsOneWidget);
|
||||
expect(find.text('Submit'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a registered NFC tag tap satisfies the proof and reveals '
|
||||
'the challenge', (tester) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
RoomProofSettings.kNfcTagKey: '04a2b3c4',
|
||||
});
|
||||
const channel = MethodChannel(AlarmScheduler.channelName);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'hasNfc') return true;
|
||||
if (call.method == 'stepCount') return 0;
|
||||
return null;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: RingScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('tap the NFC tag'), findsOneWidget);
|
||||
|
||||
// Simulate the tag arriving over the channel, as NfcReader pushes it.
|
||||
await TestDefaultBinaryMessengerBinding
|
||||
.instance
|
||||
.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
AlarmScheduler.channelName,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
const MethodCall('onNfcTag', '04a2b3c4'),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Dismiss'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a foreign NFC tag does not satisfy the proof', (tester) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
RoomProofSettings.kNfcTagKey: '04a2b3c4',
|
||||
});
|
||||
const channel = MethodChannel(AlarmScheduler.channelName);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'hasNfc') return true;
|
||||
if (call.method == 'stepCount') return 0;
|
||||
return null;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: RingScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await TestDefaultBinaryMessengerBinding
|
||||
.instance
|
||||
.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
AlarmScheduler.channelName,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
const MethodCall('onNfcTag', 'deadbeef'),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Dismiss'), findsNothing);
|
||||
expect(find.text('Wrong NFC tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a correct answer stops the ring AND closes the screen', (
|
||||
testWidgets('tag 2 tapped first does nothing; tag 1 then works', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRing(tester);
|
||||
await tapTag(tester, _tag2);
|
||||
expect(find.textContaining('Not tag 1'), findsOneWidget);
|
||||
|
||||
await tapTag(tester, _tag1);
|
||||
expect(find.textContaining('Tag 1 matched'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an unknown tag does not un-latch a passed stage', (
|
||||
tester,
|
||||
) async {
|
||||
// The regression this change exists to prevent: the old gate assigned the
|
||||
// room-proof result straight onto a bool, so a wrong tap after passing
|
||||
// sent the user back to the start.
|
||||
await pumpRing(tester);
|
||||
await tapTag(tester, _tag1);
|
||||
await tapTag(tester, 'deadbeef');
|
||||
// Still past tag 1: the steps row is live, not the tag 1 row.
|
||||
steps = 500;
|
||||
elapsed = const Duration(minutes: 3);
|
||||
await pollSteps(tester);
|
||||
expect(find.text('Tap tag 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('steps do not count before tag 1', (tester) async {
|
||||
await pumpRing(tester);
|
||||
steps = 500;
|
||||
elapsed = const Duration(minutes: 3);
|
||||
await pollSteps(tester);
|
||||
// Still on tag 1 — walking first must not skip the first location.
|
||||
expect(find.text('Submit'), findsNothing);
|
||||
await tapTag(tester, _tag2);
|
||||
expect(find.textContaining('Not tag 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the walk needs elapsed time, not just a step count', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpRing(tester);
|
||||
await tapTag(tester, _tag1);
|
||||
steps = 500;
|
||||
elapsed = const Duration(seconds: 5);
|
||||
await pollSteps(tester);
|
||||
expect(find.textContaining('Keep moving'), findsOneWidget);
|
||||
expect(find.text('Tap tag 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the full sequence dismisses: tag 1, walk, tag 2, 3 answers', (
|
||||
tester,
|
||||
) async {
|
||||
final expected = await pumpRing(tester);
|
||||
|
||||
await tapTag(tester, _tag1);
|
||||
steps = 500;
|
||||
elapsed = const Duration(minutes: 3);
|
||||
await pollSteps(tester);
|
||||
await tapTag(tester, _tag2);
|
||||
|
||||
expect(find.text('Question 1 of 3'), findsOneWidget);
|
||||
for (var i = 1; i <= DismissGate.challengesNormal; i++) {
|
||||
expect(find.text('Question $i of 3'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), expected.next().answer);
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// Closing is load-bearing: back is disabled in both halves, so a screen
|
||||
// left up after a correct answer is a dead end over the lockscreen, and
|
||||
// the live instance would carry the satisfied proof into the next ring.
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
RoomProofSettings.kNfcTagKey: '04a2b3c4',
|
||||
});
|
||||
final calls = <String>[];
|
||||
const channel = MethodChannel(AlarmScheduler.channelName);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call.method);
|
||||
if (call.method == 'hasNfc') return true;
|
||||
if (call.method == 'stepCount') return 0;
|
||||
return null;
|
||||
});
|
||||
|
||||
// A fixed generator so the expected answer is known.
|
||||
final generator = ChallengeGenerator(random: Random(1));
|
||||
final challenge = ChallengeGenerator(random: Random(1)).next();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: RingScreen(generator: generator)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await TestDefaultBinaryMessengerBinding
|
||||
.instance
|
||||
.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
AlarmScheduler.channelName,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
const MethodCall('onNfcTag', '04a2b3c4'),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), challenge.answer);
|
||||
await tester.tap(find.text('Dismiss'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// left up after the last answer is a dead end over the lockscreen.
|
||||
expect(calls, contains('dismiss'));
|
||||
expect(calls, contains('closeRingScreen'));
|
||||
});
|
||||
|
||||
testWidgets('satisfying the room proof reveals the challenge', (
|
||||
testWidgets('a wrong answer costs the attempt but not the tally', (
|
||||
tester,
|
||||
) async {
|
||||
const channel = MethodChannel(AlarmScheduler.channelName);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'stepCount') return 500;
|
||||
if (call.method == 'hasNfc') return false;
|
||||
return null;
|
||||
});
|
||||
final expected = await pumpRing(tester);
|
||||
await tapTag(tester, _tag1);
|
||||
steps = 500;
|
||||
elapsed = const Duration(minutes: 3);
|
||||
await pollSteps(tester);
|
||||
await tapTag(tester, _tag2);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: RingScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
// The stopwatch starts at zero, so steps alone must not be enough yet.
|
||||
await tester.tap(find.text('Check steps'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), expected.next().answer);
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
expect(find.text('Question 2 of 3'), findsOneWidget);
|
||||
expected.next(); // the screen regenerated after the correct answer
|
||||
|
||||
expect(find.text('Dismiss'), findsNothing);
|
||||
expect(find.textContaining('Keep moving'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'definitely wrong');
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text('Question 2 of 3'),
|
||||
findsOneWidget,
|
||||
reason: 'cumulative: a wrong answer must not reset the count',
|
||||
);
|
||||
expect(calls, isNot(contains('closeRingScreen')));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/// Local helper so the test need not import dart:async for one call.
|
||||
|
||||
@ -26,12 +26,14 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows that no tag is registered on a fresh install', (
|
||||
testWidgets('shows both slots unregistered on a fresh install', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('none registered'), findsOneWidget);
|
||||
expect(find.textContaining('none registered'), findsNWidgets(2));
|
||||
expect(find.text('Register tag 1'), findsOneWidget);
|
||||
expect(find.text('Register tag 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('starting a scan puts the reader into waiting state', (
|
||||
@ -39,11 +41,29 @@ void main() {
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Register NFC tag'));
|
||||
await tester.tap(find.text('Register tag 1'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, contains('startTagRegistration'));
|
||||
expect(find.textContaining('Tap the tag'), findsOneWidget);
|
||||
expect(find.textContaining('Tap tag 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('only the scanning row is disabled while waiting', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Register tag 2'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Waiting for tag…'), findsOneWidget);
|
||||
final other = tester.widget<FilledButton>(
|
||||
find.ancestor(
|
||||
of: find.text('Register tag 1'),
|
||||
matching: find.byType(FilledButton),
|
||||
),
|
||||
);
|
||||
expect(other.onPressed, isNull, reason: 'one scan at a time');
|
||||
});
|
||||
|
||||
testWidgets('says so when the device cannot scan, rather than waiting', (
|
||||
@ -52,29 +72,21 @@ void main() {
|
||||
nfcStarts = false;
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Register NFC tag'));
|
||||
await tester.tap(find.text('Register tag 1'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('unavailable'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('saving a QR payload persists it', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'kitchen');
|
||||
await tester.tap(find.text('Save QR code'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await RoomProofSettings.load()).qrPayload, 'kitchen');
|
||||
expect(find.text('QR code saved.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a registered tag is shown when reopening the screen', (
|
||||
testWidgets('each registered tag is shown against its own slot', (
|
||||
tester,
|
||||
) async {
|
||||
await RoomProofSettings.saveNfcTag('04a2b3c4');
|
||||
await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4');
|
||||
await RoomProofSettings.saveNfcTagAt(1, '04ffee11');
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('04a2b3c4'), findsOneWidget);
|
||||
expect(find.text('Tag 1: 04a2b3c4'), findsOneWidget);
|
||||
expect(find.text('Tag 2: 04ffee11'), findsOneWidget);
|
||||
expect(find.text('Register tag 1 again'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -53,12 +53,6 @@ void main() {
|
||||
});
|
||||
|
||||
group('ChallengeGenerator', () {
|
||||
test('generateCode has the mirrored length and no ambiguous chars', () {
|
||||
final code = ChallengeGenerator().generateCode();
|
||||
expect(code, hasLength(kDismissCodeLength));
|
||||
expect(RegExp(r'^[A-HJ-NP-Z2-9]+$').hasMatch(code), isTrue);
|
||||
});
|
||||
|
||||
test('math covers addition', () {
|
||||
final c = ChallengeGenerator(random: _FixedRandom(<int>[0, 5, 7])).math();
|
||||
expect(c.kind, ChallengeKind.math);
|
||||
@ -101,13 +95,6 @@ void main() {
|
||||
expect(c.answer.split('').toSet(), hasLength(8));
|
||||
});
|
||||
|
||||
test('flash displays the code it expects back', () {
|
||||
final c = ChallengeGenerator().flash();
|
||||
expect(c.kind, ChallengeKind.flash);
|
||||
expect(c.display, c.answer);
|
||||
expect(c.hint, contains('${kDismissFlashSeconds}s'));
|
||||
});
|
||||
|
||||
test('sequence covers the arithmetic branch', () {
|
||||
final c = ChallengeGenerator(
|
||||
random: _FixedRandom(<int>[0, 3, 2]),
|
||||
@ -142,15 +129,16 @@ void main() {
|
||||
expect(c.answer, word.split('').reversed.join());
|
||||
});
|
||||
|
||||
test('next() never returns a challenge that hides itself', () {
|
||||
// flash disappears after kDismissFlashSeconds, which is unsolvable on
|
||||
// the phone: the user reaches the ring screen only after walking to
|
||||
// another room and tapping a tag.
|
||||
final kinds = <ChallengeKind>{};
|
||||
test('every kind keeps its prompt on screen while it is answered', () {
|
||||
// The memorise-then-hide kind was deleted outright rather than merely
|
||||
// left out of the rotation, so this is now an enum-level guarantee: a
|
||||
// challenge that hides itself is unsolvable for someone who has just
|
||||
// walked in from another room.
|
||||
for (var i = 0; i < 400; i++) {
|
||||
kinds.add(ChallengeGenerator().next().kind);
|
||||
final c = ChallengeGenerator().next();
|
||||
expect(c.display, isNotEmpty, reason: '${c.kind} showed nothing');
|
||||
}
|
||||
expect(kinds, isNot(contains(ChallengeKind.flash)));
|
||||
expect(ChallengeKind.values, hasLength(4));
|
||||
});
|
||||
|
||||
test('next() produces every kind that is safe to show', () {
|
||||
|
||||
165
phone_app/test/services/dismiss_gate_test.dart
Normal file
165
phone_app/test/services/dismiss_gate_test.dart
Normal file
@ -0,0 +1,165 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
|
||||
|
||||
/// Drives a fresh gate through tag 1 -> steps -> tag 2, onto challenges.
|
||||
DismissGate _atChallenges() {
|
||||
final gate = DismissGate()
|
||||
..passTagStage()
|
||||
..passStepsStage()
|
||||
..passTagStage();
|
||||
expect(gate.stage, DismissStage.challenges);
|
||||
return gate;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('strict ordering', () {
|
||||
test('a fresh gate waits for tag 1', () {
|
||||
final gate = DismissGate();
|
||||
expect(gate.stage, DismissStage.tag1);
|
||||
expect(gate.expectedTagSlot, 0);
|
||||
expect(gate.isDone, isFalse);
|
||||
});
|
||||
|
||||
test('steps before tag 1 do not advance anything', () {
|
||||
final gate = DismissGate();
|
||||
expect(gate.passStepsStage(), isFalse);
|
||||
expect(gate.stage, DismissStage.tag1);
|
||||
});
|
||||
|
||||
test('the sequence runs tag 1 -> steps -> tag 2 -> challenges', () {
|
||||
final gate = DismissGate();
|
||||
expect(gate.passTagStage(), isTrue);
|
||||
expect(gate.stage, DismissStage.steps);
|
||||
expect(gate.expectedTagSlot, isNull, reason: 'no tag stage is live');
|
||||
expect(gate.passStepsStage(), isTrue);
|
||||
expect(gate.stage, DismissStage.tag2);
|
||||
expect(gate.expectedTagSlot, 1);
|
||||
expect(gate.passTagStage(), isTrue);
|
||||
expect(gate.stage, DismissStage.challenges);
|
||||
});
|
||||
|
||||
test('a tag tapped during the steps stage does not advance it', () {
|
||||
final gate = DismissGate()..passTagStage();
|
||||
expect(gate.stage, DismissStage.steps);
|
||||
expect(gate.passTagStage(), isFalse);
|
||||
expect(gate.stage, DismissStage.steps, reason: 'steps still required');
|
||||
});
|
||||
});
|
||||
|
||||
group('latching', () {
|
||||
test('a passed stage never reverts', () {
|
||||
// The bug this replaces: the old gate assigned the room-proof result
|
||||
// straight onto a bool, so a wrong tap after passing hid the challenge.
|
||||
final gate = _atChallenges();
|
||||
expect(gate.passTagStage(), isFalse);
|
||||
expect(gate.passStepsStage(), isFalse);
|
||||
expect(gate.stage, DismissStage.challenges);
|
||||
});
|
||||
|
||||
test('finishing does not un-finish', () {
|
||||
final gate = _atChallenges();
|
||||
for (var i = 0; i < DismissGate.challengesNormal; i++) {
|
||||
gate.recordChallenge(correct: true);
|
||||
}
|
||||
expect(gate.isDone, isTrue);
|
||||
expect(gate.recordChallenge(correct: false), isFalse);
|
||||
expect(gate.isDone, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('challenges', () {
|
||||
test('one correct answer is not enough; three are', () {
|
||||
final gate = _atChallenges()..recordChallenge(correct: true);
|
||||
expect(gate.isDone, isFalse);
|
||||
gate.recordChallenge(correct: true);
|
||||
expect(gate.isDone, isFalse);
|
||||
gate.recordChallenge(correct: true);
|
||||
expect(gate.isDone, isTrue);
|
||||
});
|
||||
|
||||
test('a wrong answer does NOT reset the tally', () {
|
||||
final gate = _atChallenges()
|
||||
..recordChallenge(correct: true)
|
||||
..recordChallenge(correct: true)
|
||||
..recordChallenge(correct: false);
|
||||
expect(gate.challengesPassed, 2, reason: 'cumulative, not consecutive');
|
||||
gate.recordChallenge(correct: true);
|
||||
expect(gate.isDone, isTrue);
|
||||
});
|
||||
|
||||
test('answers before the challenge stage are ignored', () {
|
||||
final gate = DismissGate();
|
||||
expect(gate.recordChallenge(correct: true), isFalse);
|
||||
expect(gate.challengesPassed, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('bypass', () {
|
||||
test('it drops the tags but NOT the walk', () {
|
||||
final gate = DismissGate();
|
||||
expect(gate.activateBypass(), isTrue);
|
||||
expect(gate.bypassActive, isTrue);
|
||||
expect(gate.stage, DismissStage.steps, reason: 'steps still required');
|
||||
expect(gate.challengesRequired, DismissGate.challengesBypass);
|
||||
});
|
||||
|
||||
test('six answers are needed once bypassed, and five are not', () {
|
||||
final gate = DismissGate()
|
||||
..activateBypass()
|
||||
..passStepsStage();
|
||||
expect(gate.stage, DismissStage.challenges, reason: 'tag 2 is skipped');
|
||||
for (var i = 0; i < DismissGate.challengesBypass - 1; i++) {
|
||||
gate.recordChallenge(correct: true);
|
||||
}
|
||||
expect(gate.isDone, isFalse);
|
||||
gate.recordChallenge(correct: true);
|
||||
expect(gate.isDone, isTrue);
|
||||
});
|
||||
|
||||
test('bypassing after tag 1 still requires the walk', () {
|
||||
final gate = DismissGate()
|
||||
..passTagStage()
|
||||
..activateBypass();
|
||||
expect(gate.stage, DismissStage.steps);
|
||||
gate.passStepsStage();
|
||||
expect(gate.stage, DismissStage.challenges);
|
||||
});
|
||||
|
||||
test('bypassing at tag 2 keeps the already-banked steps', () {
|
||||
final gate = DismissGate()
|
||||
..passTagStage()
|
||||
..passStepsStage();
|
||||
expect(gate.stage, DismissStage.tag2);
|
||||
gate.activateBypass();
|
||||
expect(gate.stage, DismissStage.challenges);
|
||||
});
|
||||
|
||||
test('it cannot fire once the challenges are live', () {
|
||||
// Raising 3 to 6 mid-answer would punish someone who did the walk.
|
||||
final gate = _atChallenges();
|
||||
expect(gate.activateBypass(), isFalse);
|
||||
expect(gate.challengesRequired, DismissGate.challengesNormal);
|
||||
});
|
||||
|
||||
test('it is one-way and idempotent', () {
|
||||
final gate = DismissGate();
|
||||
expect(gate.activateBypass(), isTrue);
|
||||
expect(gate.activateBypass(), isFalse);
|
||||
expect(gate.challengesRequired, DismissGate.challengesBypass);
|
||||
});
|
||||
|
||||
test('tags do nothing once bypassed', () {
|
||||
final gate = DismissGate()..activateBypass();
|
||||
expect(gate.expectedTagSlot, isNull);
|
||||
expect(gate.passTagStage(), isFalse);
|
||||
expect(gate.stage, DismissStage.steps);
|
||||
});
|
||||
});
|
||||
|
||||
test('the bypass is strictly harder than the normal path', () {
|
||||
expect(
|
||||
DismissGate.challengesBypass,
|
||||
greaterThan(DismissGate.challengesNormal),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -7,27 +7,64 @@ void main() {
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||||
|
||||
test('an unconfigured install registers neither proof', () async {
|
||||
test('an unconfigured install registers no tags', () async {
|
||||
final settings = await RoomProofSettings.load();
|
||||
expect(settings.nfcTagId, isNull);
|
||||
expect(settings.qrPayload, isNull);
|
||||
expect(settings.tagAt(0), isNull);
|
||||
expect(settings.tagAt(1), isNull);
|
||||
expect(settings.isComplete, isFalse);
|
||||
});
|
||||
|
||||
test('a saved NFC tag round-trips', () async {
|
||||
await RoomProofSettings.saveNfcTag('04a2b3c4');
|
||||
expect((await RoomProofSettings.load()).nfcTagId, '04a2b3c4');
|
||||
test('each slot round-trips independently', () async {
|
||||
await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4');
|
||||
expect((await RoomProofSettings.load()).tagAt(0), '04a2b3c4');
|
||||
expect((await RoomProofSettings.load()).tagAt(1), isNull);
|
||||
|
||||
await RoomProofSettings.saveNfcTagAt(1, '04ffee11');
|
||||
final both = await RoomProofSettings.load();
|
||||
expect(both.tagAt(0), '04a2b3c4', reason: 'slot 0 must survive');
|
||||
expect(both.tagAt(1), '04ffee11');
|
||||
expect(both.isComplete, isTrue);
|
||||
});
|
||||
|
||||
test('a saved QR payload round-trips', () async {
|
||||
await RoomProofSettings.saveQrPayload('kitchen');
|
||||
expect((await RoomProofSettings.load()).qrPayload, 'kitchen');
|
||||
});
|
||||
|
||||
test('the two proofs are stored independently', () async {
|
||||
await RoomProofSettings.saveNfcTag('04a2b3c4');
|
||||
await RoomProofSettings.saveQrPayload('kitchen');
|
||||
test('re-registering one slot leaves the other alone', () async {
|
||||
await RoomProofSettings.saveNfcTagAt(0, 'aaa');
|
||||
await RoomProofSettings.saveNfcTagAt(1, 'bbb');
|
||||
await RoomProofSettings.saveNfcTagAt(0, 'ccc');
|
||||
final settings = await RoomProofSettings.load();
|
||||
expect(settings.nfcTagId, '04a2b3c4');
|
||||
expect(settings.qrPayload, 'kitchen');
|
||||
expect(settings.tagAt(0), 'ccc');
|
||||
expect(settings.tagAt(1), 'bbb');
|
||||
});
|
||||
|
||||
test('a legacy single tag migrates into slot 1', () async {
|
||||
// An existing install has one tag under the pre-two-tag key. Losing it
|
||||
// would leave the user bypass-only on the first morning after updating.
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
RoomProofSettings.kLegacyNfcTagKey: '0465dab3ce2a81',
|
||||
});
|
||||
final settings = await RoomProofSettings.load();
|
||||
expect(settings.tagAt(0), '0465dab3ce2a81');
|
||||
expect(settings.tagAt(1), isNull);
|
||||
expect(settings.isComplete, isFalse);
|
||||
});
|
||||
|
||||
test('the same tag in both slots is not a complete configuration', () async {
|
||||
// One physical tag satisfying both stages would remove the walk.
|
||||
await RoomProofSettings.saveNfcTagAt(0, 'same');
|
||||
await RoomProofSettings.saveNfcTagAt(1, 'same');
|
||||
expect((await RoomProofSettings.load()).isComplete, isFalse);
|
||||
});
|
||||
|
||||
test('a malformed stored value degrades to unregistered', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
RoomProofSettings.kNfcTagsKey: 'not json',
|
||||
});
|
||||
final settings = await RoomProofSettings.load();
|
||||
expect(settings.tagAt(0), isNull);
|
||||
expect(settings.isComplete, isFalse);
|
||||
});
|
||||
|
||||
test('an out-of-range slot write is ignored', () async {
|
||||
await RoomProofSettings.saveNfcTagAt(5, 'nope');
|
||||
expect((await RoomProofSettings.load()).isComplete, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@ -19,48 +20,46 @@ void main() {
|
||||
scheduler = AlarmScheduler(channel: channel);
|
||||
});
|
||||
|
||||
const twoTags = RoomProofSettings(nfcTagIds: <String>['tag-1', 'tag-2']);
|
||||
|
||||
group('NFC', () {
|
||||
test('a matching registered tag satisfies the proof', () {
|
||||
final proof = RoomProof(scheduler: scheduler, registeredTagId: 'tag-1');
|
||||
final result = proof.checkNfc('tag-1');
|
||||
test('a matching registered tag satisfies its own slot', () {
|
||||
final proof = RoomProof(scheduler: scheduler, settings: twoTags);
|
||||
final result = proof.checkNfc('tag-1', slot: 0);
|
||||
expect(result.satisfied, isTrue);
|
||||
expect(result.kind, RoomProofKind.nfc);
|
||||
});
|
||||
|
||||
test('a different tag does not', () {
|
||||
final proof = RoomProof(scheduler: scheduler, registeredTagId: 'tag-1');
|
||||
expect(proof.checkNfc('tag-2').satisfied, isFalse);
|
||||
final proof = RoomProof(scheduler: scheduler, settings: twoTags);
|
||||
expect(proof.checkNfc('tag-9', slot: 0).satisfied, isFalse);
|
||||
});
|
||||
|
||||
test('the OTHER registered tag does not satisfy this slot', () {
|
||||
// Two locations must stay two locations: if tag 2 could pass stage 1
|
||||
// the walk between them would be optional.
|
||||
final proof = RoomProof(scheduler: scheduler, settings: twoTags);
|
||||
final result = proof.checkNfc('tag-2', slot: 0);
|
||||
expect(result.satisfied, isFalse);
|
||||
expect(result.detail, contains('Not tag 1'));
|
||||
});
|
||||
|
||||
test('each slot matches only its own tag', () {
|
||||
final proof = RoomProof(scheduler: scheduler, settings: twoTags);
|
||||
expect(proof.checkNfc('tag-2', slot: 1).satisfied, isTrue);
|
||||
expect(proof.checkNfc('tag-1', slot: 1).satisfied, isFalse);
|
||||
});
|
||||
|
||||
test('no registered tag means NFC cannot satisfy, not that all pass', () {
|
||||
final proof = RoomProof(scheduler: scheduler);
|
||||
final result = proof.checkNfc('tag-1');
|
||||
final result = proof.checkNfc('tag-1', slot: 0);
|
||||
expect(result.satisfied, isFalse);
|
||||
expect(result.detail, contains('No NFC tag registered'));
|
||||
expect(result.detail, contains('No tag 1 registered'));
|
||||
});
|
||||
|
||||
test('an empty registered tag is treated as unregistered', () {
|
||||
final proof = RoomProof(scheduler: scheduler, registeredTagId: '');
|
||||
expect(proof.checkNfc('').satisfied, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('QR', () {
|
||||
test('the expected payload satisfies the proof', () {
|
||||
final proof = RoomProof(scheduler: scheduler);
|
||||
final result = proof.checkQr('kitchen', expected: 'kitchen');
|
||||
expect(result.satisfied, isTrue);
|
||||
expect(result.kind, RoomProofKind.qr);
|
||||
});
|
||||
|
||||
test('a different payload does not', () {
|
||||
final proof = RoomProof(scheduler: scheduler);
|
||||
expect(proof.checkQr('bedroom', expected: 'kitchen').satisfied, isFalse);
|
||||
});
|
||||
|
||||
test('an empty expectation never matches', () {
|
||||
final proof = RoomProof(scheduler: scheduler);
|
||||
expect(proof.checkQr('', expected: '').satisfied, isFalse);
|
||||
expect(proof.checkNfc('', slot: 0).satisfied, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user