Files
sure/mobile/test/widgets/api_key_login_dialog_test.dart
ghost 6d3e39e588 fix(mobile): dispose API-key dialog controller on any dismissal (#2399)
* fix(mobile): own the API-key dialog controller in its own State

_showApiKeyDialog created a TextEditingController in the parent State and
only disposed it in the Cancel/Sign-In handlers, so it leaked whenever the
dialog was dismissed via a barrier tap or the system back button.

Disposing it right after `await showDialog` (an earlier attempt) instead
risks disposing the controller while the dialog's TextField is still
mounted during the route's exit transition ("TextEditingController was
used after being disposed").

Extract the dialog into a small StatefulWidget (_ApiKeyLoginDialog) that
owns the controller and disposes it in State.dispose(), tying the
controller's lifecycle to the dialog's widget tree. The dialog pops
true/false/null and the screen shows the error snackbar on a failed
attempt.

flutter analyze: no new issues; flutter test: all green.

* test(mobile): cover ApiKeyLoginDialog controller disposal

Per review: add a widget test for the lifecycle contract that is the core
of this change. Exposes the dialog as `ApiKeyLoginDialog` (@visibleForTesting)
with an injectable controller, and asserts the controller is disposed on
all three dismissal paths — Cancel button, barrier tap, and system back —
by checking that using the controller afterward throws (a disposed
ChangeNotifier throws on reuse).

119 tests pass; flutter analyze: no new issues.

* test(mobile): derive the barrier-tap point from dialog geometry

Per review: replace the hard-coded Offset(10, 10) in the barrier-dismissal
test. Tapping the ModalBarrier widget directly hits the dialog that
occludes its centre, so instead tap halfway between the screen corner and
the dialog's top-left — a point derived from the dialog's real geometry,
resilient to layout changes, and always on the dismissible barrier.

* fix(mobile): keyboard submit + disable Sign In when API key is empty

- Add onSubmitted to the API key TextField so the keyboard Done/Enter key
  submits the form (no need to tap the button).
- Wire a controller listener to rebuild the dialog state and disable the
  Sign In ElevatedButton while the field is blank, giving clear feedback
  before any network call is made.
2026-06-30 07:06:03 +02:00

76 lines
2.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/l10n/app_localizations.dart';
import 'package:sure_mobile/screens/login_screen.dart';
void main() {
// Opens the dialog with an injected controller and returns it so the test can
// assert the dialog disposed it. A disposed ChangeNotifier throws when used
// again, which is how we verify disposal.
Future<TextEditingController> openDialog(WidgetTester tester) async {
final controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showDialog<bool>(
context: context,
builder: (_) => ApiKeyLoginDialog(controller: controller),
),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('API Key Login'), findsOneWidget);
return controller;
}
void expectDisposed(TextEditingController controller) {
expect(() => controller.addListener(() {}), throwsA(isA<FlutterError>()));
}
testWidgets('disposes its controller when cancelled', (tester) async {
final controller = await openDialog(tester);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('API Key Login'), findsNothing);
expectDisposed(controller);
});
testWidgets('disposes its controller when dismissed by a barrier tap',
(tester) async {
final controller = await openDialog(tester);
// The barrier fills the screen but its centre is occluded by the dialog, so
// tap halfway between the screen corner and the dialog's top-left — a point
// derived from the dialog's real geometry (not a fixed coordinate) that is
// always on the dismissible barrier.
final dialogTopLeft = tester.getTopLeft(find.byType(AlertDialog));
await tester.tapAt(dialogTopLeft / 2);
await tester.pumpAndSettle();
expect(find.text('API Key Login'), findsNothing);
expectDisposed(controller);
});
testWidgets('disposes its controller when dismissed by the system back button',
(tester) async {
final controller = await openDialog(tester);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('API Key Login'), findsNothing);
expectDisposed(controller);
});
}