From dc2d54668b7559d48ed36224b59bb9cb4b81e97e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 27 Aug 2026 21:39:01 +0200 Subject: [PATCH] Phase 4/3: close the ring screen on dismiss, and make NFC registerable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh --- AGENTS.md | 22 +++- .../com/kuhy/wake_alarm_sync/AlarmActivity.kt | 16 ++- .../com/kuhy/wake_alarm_sync/AlarmChannel.kt | 7 +- .../com/kuhy/wake_alarm_sync/MainActivity.kt | 35 ++++++ .../com/kuhy/wake_alarm_sync/StepCounter.kt | 30 +++-- phone_app/lib/screens/ring_screen.dart | 11 +- phone_app/lib/screens/room_proof_screen.dart | 116 ++++++++++++++++++ phone_app/lib/screens/settings_screen.dart | 14 ++- phone_app/lib/services/alarm_scheduler.dart | 22 ++++ phone_app/test/ring_route_test.dart | 114 +++++++++++++++++ phone_app/test/room_proof_screen_test.dart | 80 ++++++++++++ .../services/room_proof_settings_test.dart | 33 +++++ phone_app/test/services/room_proof_test.dart | 2 +- 13 files changed, 483 insertions(+), 19 deletions(-) create mode 100644 phone_app/lib/screens/room_proof_screen.dart create mode 100644 phone_app/test/room_proof_screen_test.dart create mode 100644 phone_app/test/services/room_proof_settings_test.dart diff --git a/AGENTS.md b/AGENTS.md index a89b0f8..3b5079c 100644 --- a/AGENTS.md +++ b/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 diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt index 84aa17a..3d6f7a6 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmActivity.kt @@ -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() { diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt index 26b6833..60ed8f5 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmChannel.kt @@ -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) diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt index d119cbc..291fb8a 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/MainActivity.kt @@ -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 { diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StepCounter.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StepCounter.kt index 4bd5d9b..6da27a5 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StepCounter.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/StepCounter.kt @@ -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) } } diff --git a/phone_app/lib/screens/ring_screen.dart b/phone_app/lib/screens/ring_screen.dart index 3c9a673..8421ac3 100644 --- a/phone_app/lib/screens/ring_screen.dart +++ b/phone_app/lib/screens/ring_screen.dart @@ -90,6 +90,7 @@ class _RingScreenState extends State { @override void dispose() { + _scheduler.clearNfcListener(); _flashTimer?.cancel(); _input.dispose(); _elapsed.stop(); @@ -124,8 +125,16 @@ class _RingScreenState extends State { _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 _finishDismiss() async { + await _scheduler.dismiss(); + await _scheduler.closeRingScreen(); } @override diff --git a/phone_app/lib/screens/room_proof_screen.dart b/phone_app/lib/screens/room_proof_screen.dart new file mode 100644 index 0000000..2dc896a --- /dev/null +++ b/phone_app/lib/screens/room_proof_screen.dart @@ -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 createState() => _RoomProofScreenState(); +} + +class _RoomProofScreenState extends State { + 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 _load() async { + final settings = await RoomProofSettings.load(); + if (!mounted) return; + setState(() { + _tagId = settings.nfcTagId; + _qr.text = settings.qrPayload ?? ''; + }); + } + + Future _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 _onTag(String tagId) async { + await RoomProofSettings.saveNfcTag(tagId); + await _scheduler.stopTagRegistration(); + if (!mounted) return; + setState(() { + _tagId = tagId; + _scanning = false; + _status = 'Tag registered.'; + }); + } + + Future _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: [ + 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 future) {} diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index 627f9bf..e015b2d 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -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( + MaterialPageRoute(builder: (_) => const RoomProofScreen()), + ), + ), ListTile( contentPadding: EdgeInsets.zero, title: const Text('Advanced sync (GitHub)'), diff --git a/phone_app/lib/services/alarm_scheduler.dart b/phone_app/lib/services/alarm_scheduler.dart index b5e0179..93884e5 100644 --- a/phone_app/lib/services/alarm_scheduler.dart +++ b/phone_app/lib/services/alarm_scheduler.dart @@ -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 startTagRegistration() async => + await _channel.invokeMethod('startTagRegistration') ?? false; + + /// Stops reader mode after registration. + Future stopTagRegistration() => + _channel.invokeMethod('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 closeRingScreen() => + _channel.invokeMethod('closeRingScreen'); + /// Starts the ring immediately, without waiting for the alarm instant. /// /// Exists so the foreground-service and full-screen-intent path can be diff --git a/phone_app/test/ring_route_test.dart b/phone_app/test/ring_route_test.dart index 73c4bf0..2e89971 100644 --- a/phone_app/test/ring_route_test.dart +++ b/phone_app/test/ring_route_test.dart @@ -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({ + 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({ + 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({ + RoomProofSettings.kNfcTagKey: '04a2b3c4', + }); + final calls = []; + 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 { diff --git a/phone_app/test/room_proof_screen_test.dart b/phone_app/test/room_proof_screen_test.dart new file mode 100644 index 0000000..b628ec9 --- /dev/null +++ b/phone_app/test/room_proof_screen_test.dart @@ -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 calls; + late bool nfcStarts; + late MethodChannel channel; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + calls = []; + 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); + }); +} diff --git a/phone_app/test/services/room_proof_settings_test.dart b/phone_app/test/services/room_proof_settings_test.dart new file mode 100644 index 0000000..d1471e4 --- /dev/null +++ b/phone_app/test/services/room_proof_settings_test.dart @@ -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({})); + + 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'); + }); +} diff --git a/phone_app/test/services/room_proof_test.dart b/phone_app/test/services/room_proof_test.dart index 4341e53..00bd19c 100644 --- a/phone_app/test/services/room_proof_test.dart +++ b/phone_app/test/services/room_proof_test.dart @@ -7,7 +7,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AlarmScheduler scheduler; - int steps = 0; + var steps = 0; setUp(() { const channel = MethodChannel(AlarmScheduler.channelName);