Merge remote-tracking branch 'origin/main' into wallet-72

This commit is contained in:
Stephan D committed 2026-10-23 12:43:55 +02:00
commit 9370eacd9c
26 files changed
+975 -82

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(
+206
View File
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../generated/i18n/app_localizations.dart';
/// Birthday entry with a canonical date string at the profile boundary.
class BirthField extends StatefulWidget {
const BirthField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
@override
State<BirthField> createState() => _BirthFieldState();
}
class _BirthFieldState extends State<BirthField> {
final _day = TextEditingController();
final _year = TextEditingController();
final _monthField = GlobalKey<FormFieldState<int>>();
int? _month;
late String _emitted;
@override
void initState() {
super.initState();
_readValue();
}
@override
void didUpdateWidget(BirthField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _emitted) {
_readValue();
_monthField.currentState?.didChange(_month);
}
}
void _readValue() {
_emitted = widget.value;
final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value);
_year.text = parts?.group(1) ?? '';
final month = int.tryParse(parts?.group(2) ?? '');
_month = month != null && month >= 1 && month <= 12 ? month : null;
_day.text = parts?.group(3) ?? '';
}
DateTime? get _date {
final day = int.tryParse(_day.text);
final year = int.tryParse(_year.text);
if (day == null ||
year == null ||
year < 1 ||
_year.text.length != 4 ||
_month == null) {
return null;
}
final date = DateTime(year, _month!, day);
return date.year == year && date.month == _month && date.day == day
? date
: null;
}
bool _future(DateTime date) =>
date.isAfter(DateUtils.dateOnly(DateTime.now()));
void _change() {
setState(() {});
final date = _date;
_emitted = date == null || _future(date)
? ''
: '${_year.text}-${_month.toString().padLeft(2, '0')}-'
'${_day.text.padLeft(2, '0')}';
widget.onChanged(_emitted);
}
@override
void dispose() {
_day.dispose();
_year.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final date = _date;
final complete =
_day.text.isNotEmpty && _month != null && _year.text.length == 4;
final error = !complete
? null
: date == null
? l.kycBirthInvalid
: _future(date)
? l.kycBirthFuture
: null;
final months = DateFormat.MMMM(Localizations.localeOf(context).toString());
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
final day = _number(
key: 'birth-day',
controller: _day,
label: l.kycBirthDay,
length: 2,
autofill: AutofillHints.birthdayDay,
);
final month = DropdownButtonFormField<int>(
key: _monthField,
initialValue: _month,
isExpanded: true,
decoration: InputDecoration(labelText: l.kycBirthMonth),
items: [
for (var month = 1; month <= 12; month++)
DropdownMenuItem(
value: month,
child: Text(months.format(DateTime(2000, month))),
),
],
onChanged: !widget.enabled
? null
: (value) {
_month = value;
_change();
},
);
final year = _number(
key: 'birth-year',
controller: _year,
label: l.kycBirthYear,
length: 4,
autofill: AutofillHints.birthdayYear,
);
final scale = MediaQuery.textScalerOf(context).scale(16) / 16;
if (constraints.maxWidth < 400 * scale) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
day,
const SizedBox(height: 16),
month,
const SizedBox(height: 16),
year,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: day),
const SizedBox(width: 12),
Expanded(flex: 4, child: month),
const SizedBox(width: 12),
Expanded(flex: 3, child: year),
],
);
},
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Semantics(
liveRegion: true,
child: Text(
error,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
],
);
}
Widget _number({
required String key,
required TextEditingController controller,
required String label,
required int length,
required String autofill,
}) => TextFormField(
key: ValueKey(key),
controller: controller,
enabled: widget.enabled,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
autofillHints: [autofill],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(length),
],
decoration: InputDecoration(labelText: label),
onChanged: (_) => _change(),
);
}
+4 -9
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'birth.dart';
import 'country.dart';
import '../generated/i18n/app_localizations.dart';
@@ -37,17 +38,11 @@ class ProfileForm extends StatelessWidget {
onChanged: (value) => update(name: value),
),
const SizedBox(height: 16),
TextFormField(
BirthField(
key: const ValueKey('kyc-birth'),
initialValue: p.profile.dateOfBirth,
value: p.profile.dateOfBirth,
enabled: !p.busy,
keyboardType: TextInputType.datetime,
autofillHints: const [AutofillHints.birthday],
decoration: InputDecoration(
labelText: l.kycBirth,
helperText: l.kycBirthHelp,
),
onChanged: (value) => update(birth: value.trim()),
onChanged: (value) => update(birth: value),
),
const SizedBox(height: 16),
CountryField(
+5 -1
View File
@@ -272,7 +272,11 @@
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
"kycFullName": "Full legal name",
"kycBirth": "Date of birth",
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
"kycBirthDay": "Day",
"kycBirthMonth": "Month",
"kycBirthYear": "Year",
"kycBirthInvalid": "Enter a valid date of birth.",
"kycBirthFuture": "Date of birth cannot be in the future.",
"kycCountry": "Country of residence",
"kycCountrySelect": "Select a country",
"kycCountrySearch": "Search countries",
+5 -1
View File
@@ -272,7 +272,11 @@
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
"kycFullName": "Полное имя по документу",
"kycBirth": "Дата рождения",
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
"kycBirthDay": "День",
"kycBirthMonth": "Месяц",
"kycBirthYear": "Год",
"kycBirthInvalid": "Укажите корректную дату рождения.",
"kycBirthFuture": "Дата рождения не может быть в будущем.",
"kycCountry": "Страна проживания",
"kycCountrySelect": "Выберите страну",
"kycCountrySearch": "Поиск страны",
+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
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final day = find.byKey(const ValueKey('birth-day'));
final year = find.byKey(const ValueKey('birth-year'));
final month = find.byType(DropdownButtonFormField<int>);
Future<void> mount(
WidgetTester t,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await t.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, date, _) => BirthField(
value: date,
enabled: enabled,
onChanged: (date) => value.value = date,
),
),
),
),
),
);
await t.pumpAndSettle();
}
Future<void> select(WidgetTester t, String name) async {
await t.ensureVisible(month);
await t.tap(month);
await t.pumpAndSettle();
await t.tap(find.text(name).last);
await t.pumpAndSettle();
}
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('Date of birth'), findsOneWidget);
await t.enterText(day, '23');
await select(t, 'April');
expect(value.value, '');
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-23');
await t.enterText(day, '9');
await t.pumpAndSettle();
expect(value.value, '1990-04-09');
await t.enterText(year, '19');
await t.pumpAndSettle();
expect(value.value, '');
// A provider rebuild must retain the other components of an incomplete date.
expect(t.widget<TextFormField>(day).controller!.text, '9');
expect(find.text('April'), findsOneWidget);
await t.enterText(year, '1985');
await t.pumpAndSettle();
expect(value.value, '1985-04-09');
});
testWidgets('leap dates, impossible days and future dates recover inline', (
t,
) async {
final value = ValueNotifier('2000-02-29');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('February'), findsOneWidget);
await t.enterText(year, '2001');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
expect(t.widget<TextFormField>(day).controller!.text, '29');
await t.enterText(year, '2004');
await t.pumpAndSettle();
expect(value.value, '2004-02-29');
expect(find.text('Enter a valid date of birth.'), findsNothing);
await t.enterText(day, '31');
await select(t, 'April');
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '30');
await t.enterText(year, '${DateTime.now().year + 1}');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-30');
await t.enterText(day, '0');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '1');
await t.enterText(year, '0000');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
});
testWidgets(
'prefill, external replacement, locale change and clear synchronize',
(t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
expect(t.widget<TextFormField>(year).controller!.text, '1990');
value.value = '1985-12-09';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '09');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(find.text('December'), findsOneWidget);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('Дата рождения'), findsOneWidget);
expect(find.text('декабрь'), findsOneWidget);
expect(value.value, '1985-12-09');
await select(t, 'апрель');
expect(value.value, '1985-04-09');
value.value = '';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '');
expect(t.widget<TextFormField>(year).controller!.text, '');
expect(find.text('апрель'), findsNothing);
},
);
testWidgets('digits and length limits keep each component usable', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.enterText(day, '');
await t.enterText(day, 'a123');
await t.enterText(year, '');
await t.enterText(year, 'b19856');
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '12');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(value.value, '1985-04-12');
});
testWidgets('busy state disables every birthday control', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value, enabled: false);
expect(t.widget<TextFormField>(day).enabled, isFalse);
expect(t.widget<TextFormField>(year).enabled, isFalse);
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
expect(value.value, '1990-04-23');
});
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
t,
) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: day, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.escape);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: year, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
});
testWidgets(
'Russian controls and errors fit 320px with large text and labeled targets',
(t) async {
t.view.physicalSize = const Size(320, 900);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
final semantics = t.ensureSemantics();
final value = ValueNotifier('1990-09-23');
addTearDown(value.dispose);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('сентябрь'), findsOneWidget);
expect(t, meetsGuideline(labeledTapTargetGuideline));
expect(t, meetsGuideline(androidTapTargetGuideline));
await t.enterText(day, '31');
await t.pumpAndSettle();
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
await select(t, 'март');
expect(value.value, '1990-03-31');
expect(t.takeException(), isNull);
semantics.dispose();
},
);
}
+48 -2
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
@@ -66,10 +67,16 @@ void main() {
return service;
}
Future<void> submit(WidgetTester t) async {
Future<void> profile(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start')));
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await tap(t, find.byType(DropdownButtonFormField<int>));
// The month label follows the selected app locale.
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
await t.pumpAndSettle();
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
await tap(
t,
find.descendant(
@@ -85,6 +92,10 @@ void main() {
'PL',
);
await t.pumpAndSettle();
}
Future<void> submit(WidgetTester t) async {
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byKey(const ValueKey('pick-selfie')));
@@ -93,6 +104,41 @@ void main() {
await tap(t, find.byKey(const ValueKey('kyc-submit')));
}
testWidgets(
'invalid birthday blocks advancing and valid birthday survives Back',
(t) async {
await open(t);
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport')));
final next = find.byKey(const ValueKey('kyc-next'));
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNull);
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
await tap(t, next);
await tap(t, find.text('Back'));
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
.controller!
.text,
'23',
);
expect(find.text('April'), findsOneWidget);
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
.controller!
.text,
'1990',
);
},
);
testWidgets('two photos, review, pending and approval with service limits', (
t,
) async {
+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);
},
);
}
}
@@ -12,9 +12,13 @@ abstract final class BackendMapper {
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
}
static AuthResult signup(BackendDTO dto) {
static AuthResult signup(BackendDTO dto, {required String email}) {
if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult(userId: dto.userId, isSimulated: false);
return AuthResult(
userId: dto.userId,
isSimulated: false,
delivery: VerificationDelivery(email: email, isSimulated: false),
);
}
static FactorChallenge challenge(BackendDTO dto, String id) {
@@ -5,6 +5,12 @@ import '../models/verification.dart';
abstract final class AuthMapper {
static AuthResult fromDTO(AuthDTO dto) =>
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
userId: dto.userId,
isSimulated: dto.simulated,
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
);
}
abstract final class VerificationMapper {
@@ -1,4 +1,5 @@
import 'two_factor.dart';
import 'verification.dart';
/// Ephemeral input: never persist, log, or include passwords in provider state.
class LoginRequest {
@@ -31,9 +32,11 @@ class AuthResult {
required this.userId,
required this.isSimulated,
this.challenge,
this.delivery,
});
final String userId;
final FactorChallenge? challenge;
final VerificationDelivery? delivery;
final bool isSimulated;
}
@@ -11,6 +11,13 @@ enum VerificationStatus {
enum VerificationError { expired, invalid, internal, needsAssistance }
/// An accepted email-queue operation, not proof of inbox delivery or activation.
class VerificationDelivery {
const VerificationDelivery({required this.email, required this.isSimulated});
final String email;
final bool isSimulated;
}
class VerificationException implements Exception {
const VerificationException(this.code);
final VerificationError code;
@@ -4,13 +4,22 @@ import '../models/verification.dart';
import '../services/verification.dart';
class VerificationProvider extends ChangeNotifier {
VerificationProvider(this.service);
VerificationProvider(this.service, {VerificationDelivery? delivery})
: _state = _initialState(delivery);
final VerificationService service;
VerificationState _state = const VerificationState(VerificationStatus.idle);
VerificationState _state;
VerificationState get state => _state;
bool _disposed = false;
int _generation = 0;
static VerificationState _initialState(VerificationDelivery? delivery) =>
delivery == null
? const VerificationState(VerificationStatus.idle)
: VerificationState(
VerificationStatus.sent,
isSimulated: delivery.isSimulated,
);
Future<void> send({required String email, required String locale}) => _run(
() => service.send(email: email.trim(), locale: locale),
VerificationStatus.sending,
@@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
notifyListeners();
}
void reset() {
void reset({VerificationDelivery? delivery}) {
if (_disposed) return;
++_generation;
_state = const VerificationState(VerificationStatus.idle);
_state = _initialState(delivery);
notifyListeners();
}
@@ -51,7 +51,6 @@ class BackendAuthServices
final _pending = <String, ({String token, String challengeId})>{};
final _grants = <String, AuthSession>{};
final _retryKeys = <String, String>{};
String? _queuedSignup;
AuthSession? _session;
AuthSession? get session => _session;
@override
@@ -113,9 +112,8 @@ class BackendAuthServices
'locale': r.locale,
'idempotencyKey': key,
});
final result = BackendMapper.signup(dto);
final result = BackendMapper.signup(dto, email: email);
_retryKeys.remove('signup:$email');
_queuedSignup = email;
return result;
}
@@ -141,14 +139,6 @@ class BackendAuthServices
required String locale,
}) async {
final normalized = email.trim().toLowerCase();
// Signup already queued this email. The existing confirmation page requests
// its initial delivery on mount; consume that receipt once.
if (_queuedSignup == normalized) {
_queuedSignup = null;
return VerificationMapper.fromDTO(
const VerificationDTO(simulated: false),
);
}
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
try {
await _post('verification/send', {
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
Future<AuthResult> signup(SignupRequest request) async {
await Future<void>.delayed(delay);
if (failure case final error?) throw AuthException(error);
return AuthMapper.fromDTO(
return AuthMapper.signup(
const AuthDTO(userId: 'demo-user', simulated: true),
email: request.email.trim(),
);
}
}
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
simulation: _simulation(loginService),
);
@override
Future<AuthResult> signup(SignupRequest request) => _run(
JourneyKind.signup,
JourneyStep.signup,
() => signupService.signup(request),
(r) => r.isSimulated,
simulation: _simulation(signupService),
);
Future<AuthResult> signup(SignupRequest request) async {
final result = await _run(
JourneyKind.signup,
JourneyStep.signup,
() => signupService.signup(request),
(r) => r.isSimulated,
simulation: _simulation(signupService),
);
final delivery = result.delivery;
if (delivery != null) {
final operation = recorder.begin(
JourneyKind.verification,
JourneyStep.emailDelivery,
onboarding: true,
simulated: delivery.isSimulated,
);
recorder.finish(operation, simulated: delivery.isSimulated);
}
return result;
}
@override
Future<VerificationResult> send({
required String email,
@@ -142,11 +142,11 @@ void main() {
.isSimulated,
isTrue,
);
expect(
(await const DummySignupService(delay: Duration.zero).signup(signup))
.isSimulated,
isTrue,
);
final accepted = await const DummySignupService(delay: Duration.zero)
.signup(signup);
expect(accepted.isSimulated, isTrue);
expect(accepted.delivery?.email, 'a@example.com');
expect(accepted.delivery?.isSimulated, isTrue);
await expectLater(
const DummyLoginService(
delay: Duration.zero,
@@ -89,35 +89,48 @@ void main() {
expect(keys[0], keys[1]);
},
);
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
final paths = <String>[];
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
paths.add(request.url.path);
if (request.url.path.endsWith('/signup')) {
expect(jsonDecode(request.body)['password'], ' password preserved ');
return response({'userId': 'user'});
}
return response({'accepted': true});
}),
);
addTearDown(service.close);
final result = await service.signup(
const SignupRequest(
name: 'User',
email: 'USER@example.com',
password: ' password preserved ',
locale: 'en',
),
);
expect(result.isSimulated, isFalse);
await service.send(email: 'user@example.com', locale: 'en');
expect(paths, ['/api/v1/accounts/signup']);
await service.send(email: 'user@example.com', locale: 'en');
expect(paths.last, '/api/v1/accounts/verification/send');
});
test(
'signup returns its receipt and every explicit resend reaches API',
() async {
final paths = <String>[];
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
paths.add(request.url.path);
if (request.url.path.endsWith('/signup')) {
expect(
jsonDecode(request.body)['password'],
' password preserved ',
);
return response({'userId': 'user'});
}
return response({'accepted': true});
}),
);
addTearDown(service.close);
final result = await service.signup(
const SignupRequest(
name: 'User',
email: 'USER@example.com',
password: ' password preserved ',
locale: 'en',
),
);
expect(result.isSimulated, isFalse);
expect(result.delivery?.email, 'user@example.com');
expect(result.delivery?.isSimulated, isFalse);
expect(paths, ['/api/v1/accounts/signup']);
await service.send(email: 'user@example.com', locale: 'en');
expect(paths, [
'/api/v1/accounts/signup',
'/api/v1/accounts/verification/send',
]);
await service.send(email: 'user@example.com', locale: 'en');
expect(paths.last, '/api/v1/accounts/verification/send');
expect(paths, hasLength(3));
},
);
test('login confirms backend session only after factor and consumes local grant once', () async {
final now = DateTime.now();
var verified = false;
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
}
void main() {
test(
'accepted delivery initializes sent state without calling the service',
() async {
final service = PendingService();
final provider = VerificationProvider(
service,
delivery: const VerificationDelivery(
email: 'ada@example.com',
isSimulated: false,
),
);
addTearDown(provider.dispose);
expect(provider.state.status, VerificationStatus.sent);
expect(provider.state.isSimulated, isFalse);
expect(provider.state.result, isNull);
expect(service.calls, 0);
final resend = provider.send(email: 'ada@example.com', locale: 'en');
expect(provider.state.status, VerificationStatus.sending);
expect(service.calls, 1);
service.pending.completeError(
const VerificationException(VerificationError.internal),
);
await resend;
expect(provider.state.status, VerificationStatus.error);
expect(provider.state.result, isNull);
provider.reset();
expect(provider.state.status, VerificationStatus.idle);
},
);
for (final scenario in {
'demo-valid': VerificationStatus.verified,
'demo-expired': VerificationStatus.expired,