Deliver localized auth app with verification and go_router

Integrate themed login/signup, EN/RU localization, shared dummy providers, verification recovery screens and named URL routes. Store frontend guidelines, workflow skills, validation and delivery documentation. Related to #2, #3, #5 and #6.
This commit is contained in:
Stephan D committed 2026-09-11 15:37:07 +02:00
1 parent 27424ed886
commit 10a278c65a
44 files changed
+4128 -28

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class ControlledLogin implements LoginService {
final calls = <Completer<AuthResult>>[];
LoginRequest? request;
@override
Future<AuthResult> login(LoginRequest input) {
request = input;
final completer = Completer<AuthResult>();
calls.add(completer);
return completer.future;
}
}
void main() {
testWidgets(
'app shows loading, localized failure, and retries through injected service',
(tester) async {
tester.view.physicalSize = const Size(1440, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final service = ControlledLogin();
await tester.pumpWidget(
SxchngeApp(
loginService: service,
localeProvider: LocaleProvider(initialLocale: const Locale('ru')),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('email')),
'a@example.com',
);
await tester.enterText(
find.byKey(const ValueKey('password')),
'example-password',
);
final button = find.byKey(const ValueKey('submit'));
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pump();
expect(tester.widget<FilledButton>(button).onPressed, isNull);
expect(find.text('Подождите…'), findsOneWidget);
expect(service.request!.locale, 'ru');
service.calls.single.completeError(
const AuthException(AuthError.unavailable),
);
await tester.pumpAndSettle();
expect(
find.text('Сервис недоступен. Попробуйте ещё раз.'),
findsOneWidget,
);
await tester.tap(button);
await tester.pump();
expect(service.calls, hasLength(2));
service.calls.last.complete(
const AuthResult(userId: 'demo', isSimulated: true),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('changing mode while loading ignores the old login result', (
tester,
) async {
final service = ControlledLogin();
await tester.pumpWidget(SxchngeApp(loginService: service));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('email')),
'a@example.com',
);
await tester.enterText(
find.byKey(const ValueKey('password')),
'example-password',
);
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
await tester.tap(find.byKey(const ValueKey('submit')));
await tester.pump();
await tester.ensureVisible(find.text('Sign up'));
await tester.tap(find.text('Sign up'));
await tester.pumpAndSettle();
service.calls.single.complete(
const AuthResult(userId: 'demo', isSimulated: true),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(tester.takeException(), isNull);
});
}
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_app/auth/validation.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations_en.dart';
Future<void> openApp(
WidgetTester tester, {
Size size = const Size(390, 844),
double scale = 1,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = scale;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await tester.pumpWidget(const SxchngeApp());
await tester.pumpAndSettle();
}
Future<void> submit(WidgetTester tester) async {
final button = find.byKey(const ValueKey('submit'));
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pumpAndSettle();
}
void main() {
final l = AppLocalizationsEn();
test(
'validation rejects malformed email and mismatched signup passwords',
() {
for (final email in ['', 'user', 'user@', 'user @example.com']) {
expect(AuthValidation.email(email, l), isNotNull);
}
expect(AuthValidation.email(' user+tag@example.com ', l), isNull);
expect(AuthValidation.password('short', l, signUp: true), isNotNull);
expect(AuthValidation.password('short', l, signUp: false), isNull);
expect(
AuthValidation.confirmation('different', 'password', l),
isNotNull,
);
expect(AuthValidation.name(' ', l), isNotNull);
},
);
testWidgets(
'login validates, toggles password, and submits with the keyboard',
(tester) async {
await openApp(tester);
await submit(tester);
expect(find.text('Enter your email address.'), findsOneWidget);
expect(find.text('Enter your password.'), findsOneWidget);
await tester.enterText(
find.byKey(const ValueKey('email')),
'test@example.com',
);
await tester.enterText(
find.byKey(const ValueKey('password')),
'example-password',
);
await tester.tap(find.byTooltip('Show password'));
await tester.pump();
expect(
tester
.widget<TextField>(
find.descendant(
of: find.byKey(const ValueKey('password')),
matching: find.byType(TextField),
),
)
.obscureText,
isFalse,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'signup validates confirmation and clears credentials when switching',
(tester) async {
await openApp(tester);
await tester.tap(find.text('Sign up'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('name')), 'Alex');
await tester.enterText(
find.byKey(const ValueKey('email')),
'alex@example.com',
);
await tester.enterText(
find.byKey(const ValueKey('password')),
'example-password',
);
await tester.enterText(
find.byKey(const ValueKey('confirmation')),
'different',
);
await submit(tester);
expect(find.text('Passwords do not match.'), findsOneWidget);
await tester.enterText(
find.byKey(const ValueKey('confirmation')),
'example-password',
);
await submit(tester);
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
await tester.ensureVisible(
find.byKey(const ValueKey('demo-confirmation')),
);
await tester.tap(find.byKey(const ValueKey('demo-confirmation')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-verified')),
findsOneWidget,
);
await tester.ensureVisible(find.text('Back to login'));
await tester.tap(find.text('Back to login'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
expect(find.text('example-password'), findsNothing);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('resizing preserves the selected mode and entered values', (
tester,
) async {
await openApp(tester, size: const Size(1440, 1000));
await tester.tap(find.text('Sign up'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('email')),
'alex@example.com',
);
tester.view.physicalSize = const Size(390, 844);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('name')), findsOneWidget);
expect(find.text('alex@example.com'), findsOneWidget);
expect(tester.takeException(), isNull);
});
for (final size in [const Size(320, 740), const Size(1440, 1000)]) {
testWidgets('both modes fit $size at 200% text scale', (tester) async {
await openApp(tester, size: size, scale: 2);
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text('Sign up'));
await tester.tap(find.text('Sign up'));
await tester.pumpAndSettle();
await submit(tester);
expect(tester.takeException(), isNull);
});
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test('saved choice overrides system locale, with English fallback', () async {
expect(
LocaleProvider.resolve(null, [const Locale('ru', 'RU')]),
const Locale('ru'),
);
expect(
LocaleProvider.resolve('en', [const Locale('ru')]),
const Locale('en'),
);
expect(
LocaleProvider.resolve('ru-RU', [const Locale('en')]),
const Locale('ru'),
);
expect(
LocaleProvider.resolve('de', [const Locale('ru')]),
const Locale('en'),
);
expect(
LocaleProvider.resolve(null, [const Locale('fr')]),
const Locale('en'),
);
expect(LocaleProvider.resolve(null, []), const Locale('en'));
final first = await LocaleProvider.load([const Locale('en')]);
await first.setLocale(const Locale('ru'));
final restarted = await LocaleProvider.load([const Locale('en')]);
expect(restarted.locale, const Locale('ru'));
await Future.wait([
first.setLocale(const Locale('en')),
first.setLocale(const Locale('ru')),
]);
expect(
(await SharedPreferences.getInstance()).getString(
LocaleProvider.storageKey,
),
'ru',
);
first.dispose();
restarted.dispose();
});
test('catalogs have equal keys and no empty translations', () {
final en = jsonDecode(File('lib/l10n/en.arb').readAsStringSync()) as Map;
final ru = jsonDecode(File('lib/l10n/ru.arb').readAsStringSync()) as Map;
expect(ru.keys.toSet(), en.keys.toSet());
expect(
ru.values.every((value) => value is String && value.trim().isNotEmpty),
isTrue,
);
});
testWidgets(
'switching language updates errors and keeps form data and mode',
(tester) async {
tester.view.physicalSize = const Size(1440, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final locale = await LocaleProvider.load([const Locale('en')]);
await tester.pumpWidget(SxchngeApp(localeProvider: locale));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign up'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('name')), 'Алексей');
await tester.enterText(find.byKey(const ValueKey('email')), 'invalid');
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
await tester.tap(find.byKey(const ValueKey('submit')));
await tester.pumpAndSettle();
expect(find.text('Enter a valid email address.'), findsOneWidget);
await tester.ensureVisible(find.byKey(const ValueKey('locale-selector')));
await tester.tap(find.byKey(const ValueKey('locale-selector')));
await tester.pumpAndSettle();
await tester.tap(
find.ancestor(
of: find.text('Русский'),
matching: find.byType(CheckedPopupMenuItem<Locale>),
),
);
await tester.pumpAndSettle();
expect(find.text('Введите корректный адрес почты.'), findsOneWidget);
expect(find.text('Алексей'), findsOneWidget);
expect(find.text('invalid'), findsOneWidget);
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
expect(find.byTooltip('Показать пароль'), findsOneWidget);
expect(
(await SharedPreferences.getInstance()).getString(
LocaleProvider.storageKey,
),
'ru',
);
expect(tester.takeException(), isNull);
},
);
for (final width in [320.0, 1440.0]) {
testWidgets('Russian forms fit at width $width with 200% text', (
tester,
) async {
tester.view.physicalSize = Size(width, 1000);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: const Locale('ru')),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text('Регистрация'));
await tester.tap(find.text('Регистрация'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
await tester.tap(find.byKey(const ValueKey('submit')));
await tester.pumpAndSettle();
expect(find.text('Введите ваше имя.'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
import 'package:sxchnge_app/routing/routes.dart';
import 'package:sxchnge_app/verification/page.dart';
class PendingConfirmation implements VerificationService {
final results = <String, Completer<VerificationResult>>{};
@override
Future<VerificationResult> confirm({required String token}) =>
(results[token] = Completer<VerificationResult>()).future;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async => const VerificationResult(isSimulated: true);
}
Future<GoRouter> openRoute(
WidgetTester tester,
String route, {
VerificationService service = const DummyVerificationService(
delay: Duration.zero,
),
}) async {
tester.platformDispatcher.defaultRouteNameTestValue = route;
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(SxchngeApp(verificationService: service));
await tester.pump();
return GoRouter.of(tester.element(find.byType(Scaffold).last));
}
void main() {
testWidgets('unknown deep link redirects to canonical login URL', (
tester,
) async {
final router = await openRoute(tester, '/unknown?token=private');
await tester.pumpAndSettle();
expect(router.routeInformationProvider.value.uri.toString(), '/');
expect(find.byKey(const ValueKey('email')), findsOneWidget);
});
testWidgets(
'changing confirmation token ignores the previous pending result',
(tester) async {
final service = PendingConfirmation();
final router = await openRoute(
tester,
'/verify-email?token=first',
service: service,
);
await tester.pump();
expect(service.results.containsKey('first'), isTrue);
router.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'second'},
);
await tester.pump();
await tester.pump();
service.results['second']!.completeError(
const VerificationException(VerificationError.expired),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-expired')),
findsOneWidget,
);
service.results['first']!.complete(
const VerificationResult(isSimulated: true),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-expired')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('verification-verified')), findsNothing);
},
);
testWidgets(
'platform back and forward route updates recheck confirmation tokens',
(tester) async {
final router = await openRoute(tester, '/verify-email?token=demo-valid');
await tester.pumpAndSettle();
await router.routeInformationProvider.didPushRouteInformation(
RouteInformation(uri: Uri.parse('/verify-email?token=demo-expired')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-expired')),
findsOneWidget,
);
await router.routeInformationProvider.didPushRouteInformation(
RouteInformation(uri: Uri.parse('/verify-email?token=demo-valid')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-verified')),
findsOneWidget,
);
expect(
router.routeInformationProvider.value.uri.queryParameters['token'],
'demo-valid',
);
},
);
testWidgets('locale rebuild preserves router, URL and confirmation state', (
tester,
) async {
final router = await openRoute(tester, '/verify-email?token=demo-valid');
await tester.pumpAndSettle();
final context = tester.element(find.byType(VerificationPage));
await context.read<LocaleProvider>().setLocale(const Locale('ru'));
await tester.pumpAndSettle();
expect(
GoRouter.of(tester.element(find.byType(VerificationPage))),
same(router),
);
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget);
});
testWidgets(
'signup email stays in memory and is excluded from serialized history',
(tester) async {
final router = await openRoute(tester, '/');
await tester.pumpAndSettle();
router.goNamed(AppRoutes.verificationSent, extra: 'alex@example.com');
await tester.pumpAndSettle();
expect(find.text('alex@example.com'), findsOneWidget);
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.state.toString(), isNot(contains('alex@example.com')));
expect(restored.uri.toString(), '/verification-sent');
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
router.goNamed(AppRoutes.verificationSent);
await tester.pumpAndSettle();
expect(find.text('alex@example.com'), findsNothing);
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
},
);
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
class RecoveringService implements VerificationService {
int sends = 0;
int confirmations = 0;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
if (++sends == 1) throw StateError('private error');
return const VerificationResult(isSimulated: true);
}
@override
Future<VerificationResult> confirm({required String token}) async {
if (++confirmations == 1) throw StateError('private error');
return const VerificationResult(isSimulated: true);
}
}
Future<void> openVerification(
WidgetTester tester,
String route, {
VerificationService service = const DummyVerificationService(
delay: Duration.zero,
),
}) async {
tester.platformDispatcher.defaultRouteNameTestValue = route;
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(SxchngeApp(verificationService: service));
await tester.pumpAndSettle();
}
void main() {
for (final scenario in {
'demo-valid': 'verified',
'demo-expired': 'expired',
'wrong': 'invalid',
'': 'invalid',
'demo-internal': 'error',
}.entries) {
testWidgets(
'confirmation link resolves ${scenario.key} as ${scenario.value}',
(tester) async {
await openVerification(tester, '/verify-email?token=${scenario.key}');
expect(
find.byKey(ValueKey('verification-${scenario.value}')),
findsOneWidget,
);
expect(find.textContaining('DEMO'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('internal confirmation error can retry without entering email', (
tester,
) async {
final service = RecoveringService();
await openVerification(
tester,
'/verify-email?token=opaque',
service: service,
);
expect(find.text('private error'), findsNothing);
final retry = find.byKey(const ValueKey('verification-retry'));
await tester.ensureVisible(retry);
await tester.tap(retry);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
expect(service.confirmations, 2);
});
testWidgets(
'request validates email, reports send error and resends successfully',
(tester) async {
final service = RecoveringService();
await openVerification(tester, '/verification-sent', service: service);
final resend = find.byKey(const ValueKey('verification-resend'));
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.text('Enter your email address.'), findsOneWidget);
expect(service.sends, 0);
await tester.enterText(
find.byKey(const ValueKey('verification-email')),
'alex@example.com',
);
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-error')), findsOneWidget);
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(service.sends, 2);
},
);
for (final route in [
'/verification-sent',
'/verify-email?token=demo-valid',
'/verify-email?token=demo-expired',
'/verify-email?token=bad',
'/verify-email?token=demo-internal',
]) {
testWidgets('$route fits narrow viewport at 200% in EN and RU', (
tester,
) async {
tester.view.physicalSize = const Size(320, 740);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await openVerification(tester, route);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const ValueKey('locale-selector')));
await tester.pumpAndSettle();
await tester.tap(
find.ancestor(
of: find.text('Русский'),
matching: find.byType(CheckedPopupMenuItem<Locale>),
),
);
await tester.pumpAndSettle();
expect(find.text('RU'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}