Merge pull request 'Show accepted signup delivery without a second send step' (#63) from email-verification-62 into main

This commit was merged in pull request #63.
This commit is contained in:
tech committed 2026-10-23 10:31:43 +00:00
commit 658d715c8a
19 files changed
+423 -69

No files matched your search

+2 -1
View File
@@ -99,10 +99,11 @@ class _AuthFormState extends State<AuthForm> {
return;
}
final email = _email.text.trim();
final delivery = signup.state.result!.delivery;
TextInput.finishAutofillContext();
_password.clear();
_confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email);
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
} else {
final login = context.read<LoginProvider>();
await login.login(
+5
View File
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart';
@@ -144,6 +146,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
name: AppRoutes.verificationSent,
builder: (context, state) => VerificationPage(
email: state.extra is String ? state.extra as String : null,
delivery: state.extra is VerificationDelivery
? state.extra as VerificationDelivery
: null,
),
),
GoRoute(
+24 -8
View File
@@ -15,9 +15,10 @@ import '../routing/routes.dart';
import '../generated/i18n/app_localizations.dart';
class VerificationPage extends StatefulWidget {
const VerificationPage({super.key, this.email, this.token});
const VerificationPage({super.key, this.email, this.token, this.delivery});
final String? email;
final String? token;
final VerificationDelivery? delivery;
@override
State<VerificationPage> createState() => _VerificationPageState();
@@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget {
class _VerificationPageState extends State<VerificationPage> {
final _form = GlobalKey<FormState>();
late final _email = TextEditingController(text: widget.email);
late final _email = TextEditingController(
text: widget.delivery?.email ?? widget.email,
);
late final _provider = VerificationProvider(
context.read<VerificationService>(),
delivery: widget.delivery,
);
final _emailFocus = FocusNode();
String? _recipient;
@@ -44,7 +48,7 @@ class _VerificationPageState extends State<VerificationPage> {
if (!mounted) return;
if (widget.token != null) {
_provider.confirm(widget.token!);
} else if (_email.text.isNotEmpty) {
} else if (widget.delivery == null && _email.text.isNotEmpty) {
_send();
}
});
@@ -58,14 +62,26 @@ class _VerificationPageState extends State<VerificationPage> {
@override
void didUpdateWidget(covariant VerificationPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.token == widget.token) return;
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
_provider.reset();
if (oldWidget.token == widget.token &&
oldWidget.email == widget.email &&
identical(oldWidget.delivery, widget.delivery)) {
return;
}
if (oldWidget.token != widget.token) {
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
}
_provider.reset(delivery: widget.delivery);
_lastWasSend = false;
_validated = false;
_email.clear();
_recipient = null;
_email.text = widget.delivery?.email ?? widget.email ?? '';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.token != null) _provider.confirm(widget.token!);
if (!mounted) return;
if (widget.token != null) {
_provider.confirm(widget.token!);
} else if (widget.delivery == null && _email.text.isNotEmpty) {
_send();
}
});
}
+1 -1
View File
@@ -206,7 +206,7 @@ packages:
source: hosted
version: "18.0.1"
http:
dependency: transitive
dependency: "direct dev"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+1
View File
@@ -32,6 +32,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
http: ^1.6.0
flutter:
generate: true
uses-material-design: true
+38
View File
@@ -42,6 +42,44 @@ Future<GoRouter> openRoute(
}
void main() {
testWidgets('queued signup receipt is transient and updates its recipient', (
tester,
) async {
final router = await openRoute(tester, '/');
await tester.pumpAndSettle();
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'first@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('first@example.com'), findsOneWidget);
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'second@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.text('first@example.com'), findsNothing);
expect(find.text('second@example.com'), findsOneWidget);
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.state.toString(), isNot(contains('second@example.com')));
expect(restored.uri.toString(), '/verification-sent');
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
router.goNamed(AppRoutes.verificationSent);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
expect(find.text('second@example.com'), findsNothing);
});
for (final language in ['en', 'ru']) {
testWidgets(
'verified link keeps success visible and prefills login ($language)',
+153
View File
@@ -0,0 +1,153 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class DeliveryBackend extends BackendAuthServices {
DeliveryBackend(List<String> requests)
: super(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
requests.add(request.url.path);
return http.Response(
jsonEncode({
'data': request.url.path.endsWith('/signup')
? {'userId': 'customer'}
: {'accepted': true},
}),
202,
);
}),
);
int sends = 0;
bool failResend = true;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
sends++;
if (failResend) {
throw const VerificationException(VerificationError.internal);
}
return super.send(email: email, locale: locale);
}
}
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'accepted signup survives an unavailable resend service ($language)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final requests = <String>[];
final backend = DeliveryBackend(requests);
final analytics = DummyAnalyticsService();
await tester.pumpWidget(
SxchngeApp(
backend: backend,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
await tester.ensureVisible(signup);
await tester.tap(signup);
await tester.pumpAndSettle();
for (final entry in {
'name': 'Ada Lovelace',
'email': 'ada@example.com',
'password': 'password preserved',
'confirmation': 'password preserved',
}.entries) {
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
}
final submit = find.byKey(const ValueKey('submit'));
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('ada@example.com'), findsOneWidget);
expect(backend.sends, 0);
expect(requests, ['/api/v1/accounts/signup']);
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.uri.toString(), '/verification-sent');
expect(restored.state.toString(), isNot(contains('ada@example.com')));
expect(
analytics.events.where(
(event) => event.name == 'auth_signup_succeeded',
),
hasLength(1),
);
expect(
analytics.events.where(
(event) => event.name == 'auth_email_delivery_succeeded',
),
hasLength(1),
);
final resend = find.byKey(const ValueKey('verification-resend'));
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-error')),
findsOneWidget,
);
expect(backend.sends, 1);
expect(
tester
.widget<TextFormField>(
find.byKey(const ValueKey('verification-email')),
)
.controller!
.text,
'ada@example.com',
);
backend.failResend = false;
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(backend.sends, 2);
expect(requests.last, '/api/v1/accounts/verification/send');
expect(requests, hasLength(2));
expect(
analytics.events.map((event) => event.journeyId).toSet(),
hasLength(1),
);
expect(
analytics.events.map((event) => event.properties).toString(),
isNot(contains('ada@example.com')),
);
expect(tester.takeException(), isNull);
},
);
}
}