mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 12:27:32 +01:00
Phase 4/3: close the ring screen on dismiss, and make NFC registerable
Two defects that each defeated the alarm in a different way:
1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
the alarm re-armed, but the dismiss screen stayed up over the lockscreen
forever — back is disabled in both halves by design — and the live instance
kept _inRoom=true with the solved answer still typed, so the next ring
could reuse an already-passed gate. AlarmActivity now handles
closeRingScreen; the shared AlarmChannel deliberately holds
applicationContext and cannot finish an activity, hence the split handler.
2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
checkNfc always answered "no tag registered" and checkQr was never called.
Functionally that left steps as the only satisfiable proof, which is
exactly the state the decision rules out ("NFC or QR qualifies; steps alone
does NOT") and removes the redundancy whose purpose is that one failing
mechanism at 06:00 must never block dismissal. Added RoomProofScreen
(reader mode from MainActivity, since registration needs an Activity) and
linked it from Settings.
Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.
New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.
Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.
flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
parent
9980b47cf4
commit
dc2d54668b
22
AGENTS.md
22
AGENTS.md
@ -190,9 +190,29 @@ 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.
|
||||
|
||||
**`ringNow` + `lib/main_ring_probe.dart` are a deliberate manual test
|
||||
handle**, the phone-side equivalent of `wake-alarm.service` on the PC: the
|
||||
normal arm path needs a sync backend connected, so there is no other way to
|
||||
fire the ring on an emulator. The probe runs the **real** app widget and
|
||||
pushes `/ring` — an earlier version rendered its own UI and so proved nothing
|
||||
about the route. `flutter build apk` uses `lib/main.dart`, so the probe is
|
||||
never the shipped entrypoint; `ringNow` is reachable only over this app's own
|
||||
channel and arms nothing. Do not "clean these up" without replacing the
|
||||
capability.
|
||||
|
||||
Emulator: `wake_alarm_api36` (Pixel 6a profile, API 36). The SDK tools are at
|
||||
`~/Android/Sdk` but **not on PATH** — export `ANDROID_HOME` and add
|
||||
`cmdline-tools/latest/bin`, `emulator` and `platform-tools` yourself.
|
||||
`cmdline-tools/latest/bin`, `emulator` and `platform-tools` yourself. The
|
||||
phone (Pixel 6a, `23181JEGR08034`) holds a **release-signed** build, so
|
||||
`phone_deploy.sh` needs `--release` or the install fails with
|
||||
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
|
||||
|
||||
**The ring screen must close itself on dismiss.** `AlarmActivity` handles
|
||||
`closeRingScreen` (the shared `AlarmChannel` holds `applicationContext` and
|
||||
cannot finish an activity). Without it the dismiss screen stays up over the
|
||||
lockscreen forever — back is disabled by design in both halves — and the live
|
||||
instance would carry a satisfied room proof and the solved answer into the
|
||||
next ring.
|
||||
|
||||
## Module map
|
||||
|
||||
|
||||
@ -34,7 +34,21 @@ class AlarmActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
AlarmChannel.register(this, flutterEngine)
|
||||
alarmChannel = channel(flutterEngine)
|
||||
val channel = channel(flutterEngine)
|
||||
alarmChannel = channel
|
||||
// AlarmChannel deliberately holds applicationContext, so it cannot
|
||||
// finish an activity. Without this the dismiss screen stays up over
|
||||
// the lockscreen forever after a correct answer — back is disabled,
|
||||
// and the live instance would carry _inRoom=true and the solved
|
||||
// answer into the next ring.
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
if (call.method == "closeRingScreen") {
|
||||
finishAndRemoveTask()
|
||||
result.success(null)
|
||||
} else {
|
||||
AlarmChannel.handleCall(applicationContext, call, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@ -18,10 +18,10 @@ object AlarmChannel {
|
||||
fun register(context: Context, engine: FlutterEngine) {
|
||||
val app = context.applicationContext
|
||||
MethodChannel(engine.dartExecutor.binaryMessenger, AlarmActivity.CHANNEL)
|
||||
.setMethodCallHandler { call, result -> handle(app, call, result) }
|
||||
.setMethodCallHandler { call, result -> handleCall(app, call, result) }
|
||||
}
|
||||
|
||||
private fun handle(context: Context, call: MethodCall, result: MethodChannel.Result) {
|
||||
fun handleCall(context: Context, call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"schedule" -> schedule(context, call, result)
|
||||
"cancel" -> {
|
||||
@ -49,6 +49,9 @@ object AlarmChannel {
|
||||
result.success(null)
|
||||
}
|
||||
"hasNfc" -> result.success(NfcReader.isAvailable(context))
|
||||
// Handled by AlarmActivity, which owns the window; reaching here
|
||||
// means the picker asked, where there is no ring screen to close.
|
||||
"closeRingScreen" -> result.success(null)
|
||||
"stepCount" -> result.success(StepCounter.stepsSinceRingStart(context))
|
||||
"beginStepCount" -> {
|
||||
StepCounter.markRingStart(context)
|
||||
|
||||
@ -7,6 +7,7 @@ import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* The normal app entry point (the time picker).
|
||||
@ -51,9 +52,43 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Kept so NFC reader mode can push tag ids back to the Settings screen. */
|
||||
private var alarmChannel: MethodChannel? = null
|
||||
|
||||
/** True while Settings is waiting for a tag to register. */
|
||||
private var registeringTag = false
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
AlarmChannel.register(this, flutterEngine)
|
||||
val channel = AlarmActivity.channel(flutterEngine)
|
||||
alarmChannel = channel
|
||||
// Registration needs an Activity for reader mode, which the shared
|
||||
// channel handler cannot supply — it holds applicationContext.
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"startTagRegistration" -> {
|
||||
registeringTag = NfcReader.start(this, channel)
|
||||
result.success(registeringTag)
|
||||
}
|
||||
"stopTagRegistration" -> {
|
||||
registeringTag = false
|
||||
NfcReader.stop(this)
|
||||
result.success(null)
|
||||
}
|
||||
else -> AlarmChannel.handleCall(applicationContext, call, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (registeringTag) NfcReader.stop(this)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (registeringTag) alarmChannel?.let { NfcReader.start(this, it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@ -29,6 +29,9 @@ object StepCounter {
|
||||
@Volatile
|
||||
private var latest: Long? = null
|
||||
|
||||
/** The single registered listener; kept so rings do not accumulate them. */
|
||||
private var listener: SensorEventListener? = null
|
||||
|
||||
/**
|
||||
* Starts listening and records the first reading as this ring's baseline.
|
||||
*
|
||||
@ -70,22 +73,25 @@ object StepCounter {
|
||||
* the ring screen polls repeatedly, and re-registering per read would cost
|
||||
* a fresh sensor warm-up each time.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun listen(context: Context, onFirst: (Long) -> Unit) {
|
||||
val manager =
|
||||
context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return
|
||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return
|
||||
manager.registerListener(
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val total = event.values.firstOrNull()?.toLong() ?: return
|
||||
latest = total
|
||||
onFirst(total)
|
||||
}
|
||||
// One registration per process: markRingStart runs on every ring, and
|
||||
// an unregistered listener per ring would accumulate for the lifetime
|
||||
// of the app.
|
||||
listener?.let { manager.unregisterListener(it) }
|
||||
val fresh = object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val total = event.values.firstOrNull()?.toLong() ?: return
|
||||
latest = total
|
||||
onFirst(total)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
},
|
||||
sensor,
|
||||
SensorManager.SENSOR_DELAY_NORMAL,
|
||||
)
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
listener = fresh
|
||||
manager.registerListener(fresh, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ class _RingScreenState extends State<RingScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scheduler.clearNfcListener();
|
||||
_flashTimer?.cancel();
|
||||
_input.dispose();
|
||||
_elapsed.stop();
|
||||
@ -124,8 +125,16 @@ class _RingScreenState extends State<RingScreen> {
|
||||
_startFlashTimerIfNeeded();
|
||||
return;
|
||||
}
|
||||
unawaited(_scheduler.dismiss());
|
||||
setState(() => _status = 'Dismissed');
|
||||
unawaited(_finishDismiss());
|
||||
}
|
||||
|
||||
/// Stops the ring, then closes this screen.
|
||||
///
|
||||
/// Ordered: the sound must stop even if closing the window fails.
|
||||
Future<void> _finishDismiss() async {
|
||||
await _scheduler.dismiss();
|
||||
await _scheduler.closeRingScreen();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
116
phone_app/lib/screens/room_proof_screen.dart
Normal file
116
phone_app/lib/screens/room_proof_screen.dart
Normal file
@ -0,0 +1,116 @@
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
class RoomProofScreen extends StatefulWidget {
|
||||
/// Creates the screen; [scheduler] is injectable for tests.
|
||||
const RoomProofScreen({super.key, this.scheduler});
|
||||
|
||||
/// Platform bridge used to drive NFC reader mode.
|
||||
final AlarmScheduler? scheduler;
|
||||
|
||||
@override
|
||||
State<RoomProofScreen> createState() => _RoomProofScreenState();
|
||||
}
|
||||
|
||||
class _RoomProofScreenState extends State<RoomProofScreen> {
|
||||
late final AlarmScheduler _scheduler = widget.scheduler ?? AlarmScheduler();
|
||||
final TextEditingController _qr = TextEditingController();
|
||||
|
||||
String? _tagId;
|
||||
bool _scanning = false;
|
||||
String _status = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_scanning) 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 ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
_scheduler.onNfcTag(_onTag);
|
||||
final started = await _scheduler.startTagRegistration();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scanning = started;
|
||||
_status = started
|
||||
? 'Tap the tag against the phone…'
|
||||
: 'NFC is unavailable or switched off.';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onTag(String tagId) async {
|
||||
await RoomProofSettings.saveNfcTag(tagId);
|
||||
await _scheduler.stopTagRegistration();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tagId = tagId;
|
||||
_scanning = false;
|
||||
_status = 'Tag registered.';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveQr() async {
|
||||
await RoomProofSettings.saveQrPayload(_qr.text.trim());
|
||||
if (!mounted) return;
|
||||
setState(() => _status = 'QR code saved.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Room proof')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
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 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),
|
||||
Text(_status),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local helper so this file need not import dart:async for fire-and-forget.
|
||||
void unawaited(Future<void> future) {}
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||
|
||||
import 'package:wake_alarm_sync/screens/github_mirror_screen.dart';
|
||||
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
||||
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
|
||||
|
||||
@ -9,7 +10,9 @@ 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]).
|
||||
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]), and
|
||||
/// "Room proof" registers the NFC tag / QR code ([RoomProofScreen]) — without
|
||||
/// a registered tag, NFC can never satisfy the dismiss room proof.
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
/// Creates a [SettingsScreen].
|
||||
const SettingsScreen({super.key});
|
||||
@ -40,6 +43,15 @@ 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'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const RoomProofScreen()),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Advanced sync (GitHub)'),
|
||||
|
||||
@ -65,6 +65,28 @@ class AlarmScheduler {
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts reader mode so the next tag tapped can be registered.
|
||||
///
|
||||
/// Returns false when the device has no NFC or it is switched off, so the
|
||||
/// UI can say so rather than waiting on a tap that cannot come.
|
||||
Future<bool> startTagRegistration() async =>
|
||||
await _channel.invokeMethod<bool>('startTagRegistration') ?? false;
|
||||
|
||||
/// Stops reader mode after registration.
|
||||
Future<void> stopTagRegistration() =>
|
||||
_channel.invokeMethod<void>('stopTagRegistration');
|
||||
|
||||
/// Stops listening for NFC taps.
|
||||
void clearNfcListener() => _channel.setMethodCallHandler(null);
|
||||
|
||||
/// Closes the ring screen after a successful dismiss.
|
||||
///
|
||||
/// Without this the dismiss screen stays up over the lockscreen forever —
|
||||
/// back is disabled by design — and the live instance would carry a
|
||||
/// satisfied room proof and the solved answer into the next ring.
|
||||
Future<void> closeRingScreen() =>
|
||||
_channel.invokeMethod<void>('closeRingScreen');
|
||||
|
||||
/// Starts the ring immediately, without waiting for the alarm instant.
|
||||
///
|
||||
/// Exists so the foreground-service and full-screen-intent path can be
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -5,6 +7,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
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/room_proof_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@ -44,6 +48,116 @@ void main() {
|
||||
expect(find.text('Dismiss'), 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', (
|
||||
tester,
|
||||
) async {
|
||||
// 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();
|
||||
|
||||
expect(calls, contains('dismiss'));
|
||||
expect(calls, contains('closeRingScreen'));
|
||||
});
|
||||
|
||||
testWidgets('satisfying the room proof reveals the challenge', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
80
phone_app/test/room_proof_screen_test.dart
Normal file
80
phone_app/test/room_proof_screen_test.dart
Normal file
@ -0,0 +1,80 @@
|
||||
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/room_proof_screen.dart';
|
||||
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late List<String> calls;
|
||||
late bool nfcStarts;
|
||||
late MethodChannel channel;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
calls = <String>[];
|
||||
nfcStarts = true;
|
||||
channel = const MethodChannel(AlarmScheduler.channelName);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call.method);
|
||||
if (call.method == 'startTagRegistration') return nfcStarts;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('shows that no tag is registered on a fresh install', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('none registered'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('starting a scan puts the reader into waiting state', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Register NFC tag'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, contains('startTagRegistration'));
|
||||
expect(find.textContaining('Tap the tag'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says so when the device cannot scan, rather than waiting', (
|
||||
tester,
|
||||
) async {
|
||||
nfcStarts = false;
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Register NFC tag'));
|
||||
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', (
|
||||
tester,
|
||||
) async {
|
||||
await RoomProofSettings.saveNfcTag('04a2b3c4');
|
||||
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('04a2b3c4'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
33
phone_app/test/services/room_proof_settings_test.dart
Normal file
33
phone_app/test/services/room_proof_settings_test.dart
Normal file
@ -0,0 +1,33 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||||
|
||||
test('an unconfigured install registers neither proof', () async {
|
||||
final settings = await RoomProofSettings.load();
|
||||
expect(settings.nfcTagId, isNull);
|
||||
expect(settings.qrPayload, isNull);
|
||||
});
|
||||
|
||||
test('a saved NFC tag round-trips', () async {
|
||||
await RoomProofSettings.saveNfcTag('04a2b3c4');
|
||||
expect((await RoomProofSettings.load()).nfcTagId, '04a2b3c4');
|
||||
});
|
||||
|
||||
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');
|
||||
final settings = await RoomProofSettings.load();
|
||||
expect(settings.nfcTagId, '04a2b3c4');
|
||||
expect(settings.qrPayload, 'kitchen');
|
||||
});
|
||||
}
|
||||
@ -7,7 +7,7 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late AlarmScheduler scheduler;
|
||||
int steps = 0;
|
||||
var steps = 0;
|
||||
|
||||
setUp(() {
|
||||
const channel = MethodChannel(AlarmScheduler.channelName);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user