feat(auth): sign-in prompt card on the home screen for signed-out users
Adds SignInPrompt (sign in / create account), showSignInPromptProvider, HomeScreen as ConsumerWidget, l10n de/en, widget/unit tests and a Playwright web test (#49 criterion 1, QUE-09 criterion 13, QUE-06). Refs #49 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8c003ddece
commit
f078c8a094
14 changed files with 690 additions and 10 deletions
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:shuffle/core/logging/app_logger.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
|
||||||
|
|
||||||
|
part 'sign_in_prompt_provider.g.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('SignInPrompt');
|
||||||
|
|
||||||
|
/// Whether the home screen shows the sign-in prompt (Spec #49, E1).
|
||||||
|
///
|
||||||
|
/// - signed out: true
|
||||||
|
/// - signed in / password recovery: false
|
||||||
|
/// - loading (no first value yet): false, so the prompt does not flash up
|
||||||
|
/// on start with a stored session
|
||||||
|
/// - error in the status stream: true (treated as signed out), the error is
|
||||||
|
/// only logged
|
||||||
|
@riverpod
|
||||||
|
bool showSignInPrompt(Ref ref) {
|
||||||
|
final status = ref.watch(authStatusProvider);
|
||||||
|
return status.when(
|
||||||
|
data: (value) => value.isSignedOut,
|
||||||
|
loading: () => false,
|
||||||
|
error: (error, _) {
|
||||||
|
_log.warning(
|
||||||
|
'Auth status stream failed, treating as signed out',
|
||||||
|
error: error.runtimeType,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'sign_in_prompt_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Whether the home screen shows the sign-in prompt (Spec #49, E1).
|
||||||
|
///
|
||||||
|
/// - signed out: true
|
||||||
|
/// - signed in / password recovery: false
|
||||||
|
/// - loading (no first value yet): false, so the prompt does not flash up
|
||||||
|
/// on start with a stored session
|
||||||
|
/// - error in the status stream: true (treated as signed out), the error is
|
||||||
|
/// only logged
|
||||||
|
|
||||||
|
@ProviderFor(showSignInPrompt)
|
||||||
|
final showSignInPromptProvider = ShowSignInPromptProvider._();
|
||||||
|
|
||||||
|
/// Whether the home screen shows the sign-in prompt (Spec #49, E1).
|
||||||
|
///
|
||||||
|
/// - signed out: true
|
||||||
|
/// - signed in / password recovery: false
|
||||||
|
/// - loading (no first value yet): false, so the prompt does not flash up
|
||||||
|
/// on start with a stored session
|
||||||
|
/// - error in the status stream: true (treated as signed out), the error is
|
||||||
|
/// only logged
|
||||||
|
|
||||||
|
final class ShowSignInPromptProvider
|
||||||
|
extends $FunctionalProvider<bool, bool, bool>
|
||||||
|
with $Provider<bool> {
|
||||||
|
/// Whether the home screen shows the sign-in prompt (Spec #49, E1).
|
||||||
|
///
|
||||||
|
/// - signed out: true
|
||||||
|
/// - signed in / password recovery: false
|
||||||
|
/// - loading (no first value yet): false, so the prompt does not flash up
|
||||||
|
/// on start with a stored session
|
||||||
|
/// - error in the status stream: true (treated as signed out), the error is
|
||||||
|
/// only logged
|
||||||
|
ShowSignInPromptProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'showSignInPromptProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$showSignInPromptHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool create(Ref ref) {
|
||||||
|
return showSignInPrompt(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(bool value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<bool>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$showSignInPromptHash() => r'1a87f4e14f79f3e5d9aa16f0f5812afabe603c27';
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shuffle/core/l10n/l10n.dart';
|
||||||
|
import 'package:shuffle/core/routing/app_routes.dart';
|
||||||
|
import 'package:shuffle/core/theme/app_spacing.dart';
|
||||||
|
import 'package:shuffle/core/widgets/app_primary_button.dart';
|
||||||
|
|
||||||
|
/// Hint card for signed-out users with the way to sign in and to register
|
||||||
|
/// (Spec #49 E1, QUE-09 Kriterium 13). Contains no logic: the host decides
|
||||||
|
/// whether to show it (see `showSignInPromptProvider`).
|
||||||
|
///
|
||||||
|
/// At most [AppBreakpoints.authCardMaxWidth] wide. Heading, text and both
|
||||||
|
/// buttons are separate semantics nodes in reading and tab order.
|
||||||
|
class SignInPrompt extends StatelessWidget {
|
||||||
|
const SignInPrompt({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: AppBreakpoints.authCardMaxWidth,
|
||||||
|
),
|
||||||
|
child: Card(
|
||||||
|
key: const Key('sign_in_prompt'),
|
||||||
|
// Card is a semantics container; keep the children separate.
|
||||||
|
child: Semantics(
|
||||||
|
explicitChildNodes: true,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
|
child: FocusTraversalGroup(
|
||||||
|
policy: ReadingOrderTraversalPolicy(),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Semantics(
|
||||||
|
header: true,
|
||||||
|
child: Text(
|
||||||
|
l10n.signInPromptTitle,
|
||||||
|
key: const Key('sign_in_prompt_title'),
|
||||||
|
style: textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
l10n.signInPromptBody,
|
||||||
|
key: const Key('sign_in_prompt_body'),
|
||||||
|
style: textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
AppPrimaryButton(
|
||||||
|
key: const Key('sign_in_prompt_sign_in'),
|
||||||
|
label: l10n.signInPromptSignIn,
|
||||||
|
onPressed: () => context.go(AppRoutes.login),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
OutlinedButton(
|
||||||
|
key: const Key('sign_in_prompt_register'),
|
||||||
|
onPressed: () => context.go(AppRoutes.register),
|
||||||
|
child: Text(
|
||||||
|
l10n.signInPromptRegister,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,50 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shuffle/core/l10n/l10n.dart';
|
import 'package:shuffle/core/l10n/l10n.dart';
|
||||||
|
import 'package:shuffle/core/theme/app_spacing.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/providers/sign_in_prompt_provider.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/widgets/sign_in_prompt.dart';
|
||||||
|
|
||||||
/// Platzhalter-Startseite. Wird durch die Katalogsuche bzw. QUE-09 ersetzt.
|
/// Platzhalter-Startseite. Wird durch die Katalogsuche bzw. QUE-09 ersetzt.
|
||||||
class HomeScreen extends StatelessWidget {
|
///
|
||||||
|
/// Liest nur [showSignInPromptProvider]; die Entscheidung, ob der
|
||||||
|
/// Anmelde-Hinweis erscheint, liegt dort (Spec #49 § 5.1).
|
||||||
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final showPrompt = ref.watch(showSignInPromptProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: const Key('home_screen'),
|
key: const Key('home_screen'),
|
||||||
appBar: AppBar(title: Text(l10n.appTitle)),
|
appBar: AppBar(title: Text(l10n.appTitle)),
|
||||||
body: Center(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.all(24),
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
child: Text(
|
padding: AppSpacing.pagePadding(constraints.maxWidth),
|
||||||
l10n.homePlaceholderMessage,
|
child: ConstrainedBox(
|
||||||
key: const Key('home_placeholder_message'),
|
constraints: BoxConstraints(
|
||||||
textAlign: TextAlign.center,
|
minHeight: constraints.maxHeight - AppSpacing.lg * 2,
|
||||||
|
minWidth: constraints.maxWidth,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.homePlaceholderMessage,
|
||||||
|
key: const Key('home_placeholder_message'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (showPrompt) ...[
|
||||||
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
const SignInPrompt(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@
|
||||||
"authLoginSubmit": "Anmelden",
|
"authLoginSubmit": "Anmelden",
|
||||||
"authLoginForgotPassword": "Passwort vergessen?",
|
"authLoginForgotPassword": "Passwort vergessen?",
|
||||||
"authLoginCreateAccount": "Konto erstellen",
|
"authLoginCreateAccount": "Konto erstellen",
|
||||||
|
"signInPromptTitle": "Dein Konto",
|
||||||
|
"signInPromptBody": "Melde dich an, um deine Sammlung und Partien zu verwalten.",
|
||||||
|
"signInPromptSignIn": "Anmelden",
|
||||||
|
"signInPromptRegister": "Konto erstellen",
|
||||||
"authCheckEmailTitle": "Prüfe dein Postfach",
|
"authCheckEmailTitle": "Prüfe dein Postfach",
|
||||||
"authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.",
|
"authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.",
|
||||||
"authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.",
|
"authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.",
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,10 @@
|
||||||
"authLoginSubmit": "Sign in",
|
"authLoginSubmit": "Sign in",
|
||||||
"authLoginForgotPassword": "Forgot password?",
|
"authLoginForgotPassword": "Forgot password?",
|
||||||
"authLoginCreateAccount": "Create account",
|
"authLoginCreateAccount": "Create account",
|
||||||
|
"signInPromptTitle": "Your account",
|
||||||
|
"signInPromptBody": "Sign in to manage your collection and games.",
|
||||||
|
"signInPromptSignIn": "Sign in",
|
||||||
|
"signInPromptRegister": "Create account",
|
||||||
"authCheckEmailTitle": "Check your inbox",
|
"authCheckEmailTitle": "Check your inbox",
|
||||||
"authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.",
|
"authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.",
|
||||||
"authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.",
|
"authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.",
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,30 @@ abstract class AppLocalizations {
|
||||||
/// **'Create account'**
|
/// **'Create account'**
|
||||||
String get authLoginCreateAccount;
|
String get authLoginCreateAccount;
|
||||||
|
|
||||||
|
/// No description provided for @signInPromptTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your account'**
|
||||||
|
String get signInPromptTitle;
|
||||||
|
|
||||||
|
/// No description provided for @signInPromptBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign in to manage your collection and games.'**
|
||||||
|
String get signInPromptBody;
|
||||||
|
|
||||||
|
/// No description provided for @signInPromptSignIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign in'**
|
||||||
|
String get signInPromptSignIn;
|
||||||
|
|
||||||
|
/// No description provided for @signInPromptRegister.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create account'**
|
||||||
|
String get signInPromptRegister;
|
||||||
|
|
||||||
/// No description provided for @authCheckEmailTitle.
|
/// No description provided for @authCheckEmailTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get authLoginCreateAccount => 'Konto erstellen';
|
String get authLoginCreateAccount => 'Konto erstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptTitle => 'Dein Konto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptBody =>
|
||||||
|
'Melde dich an, um deine Sammlung und Partien zu verwalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptSignIn => 'Anmelden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptRegister => 'Konto erstellen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authCheckEmailTitle => 'Prüfe dein Postfach';
|
String get authCheckEmailTitle => 'Prüfe dein Postfach';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get authLoginCreateAccount => 'Create account';
|
String get authLoginCreateAccount => 'Create account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptTitle => 'Your account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptBody => 'Sign in to manage your collection and games.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptSignIn => 'Sign in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get signInPromptRegister => 'Create account';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authCheckEmailTitle => 'Check your inbox';
|
String get authCheckEmailTitle => 'Check your inbox';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shuffle/features/auth/domain/auth_status.dart';
|
||||||
|
import 'package:shuffle/features/auth/domain/auth_user.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/providers/sign_in_prompt_provider.dart';
|
||||||
|
|
||||||
|
const _user = AuthUser(id: 'u1', email: 'a@example.test', emailConfirmed: true);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
ProviderContainer containerWith(Stream<AuthStatus> stream) {
|
||||||
|
final container = ProviderContainer.test(
|
||||||
|
overrides: [authStatusProvider.overrideWith((ref) => stream)],
|
||||||
|
);
|
||||||
|
// Keep the provider alive and listened to, as the home screen does.
|
||||||
|
container.listen(showSignInPromptProvider, (_, _) {});
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> firstValue(ProviderContainer container) => container
|
||||||
|
.read(authStatusProvider.future)
|
||||||
|
.then((_) {}, onError: (Object _) {});
|
||||||
|
|
||||||
|
group('QUE-09 Krit. 13 / #49 Krit. 1: showSignInPromptProvider', () {
|
||||||
|
test('signedOut -> true', () async {
|
||||||
|
final container = containerWith(
|
||||||
|
Stream.value(const AuthStatus.signedOut()),
|
||||||
|
);
|
||||||
|
await firstValue(container);
|
||||||
|
expect(container.read(showSignInPromptProvider), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signedIn -> false', () async {
|
||||||
|
final container = containerWith(
|
||||||
|
Stream.value(const AuthStatus.signedIn(_user)),
|
||||||
|
);
|
||||||
|
await firstValue(container);
|
||||||
|
expect(container.read(showSignInPromptProvider), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passwordRecovery -> false', () async {
|
||||||
|
final container = containerWith(
|
||||||
|
Stream.value(const AuthStatus.passwordRecovery(_user)),
|
||||||
|
);
|
||||||
|
await firstValue(container);
|
||||||
|
expect(container.read(showSignInPromptProvider), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'loading (no first value) -> false, then follows the stream',
|
||||||
|
() async {
|
||||||
|
final controller = StreamController<AuthStatus>();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final container = containerWith(controller.stream);
|
||||||
|
expect(container.read(showSignInPromptProvider), isFalse);
|
||||||
|
|
||||||
|
controller.add(const AuthStatus.signedOut());
|
||||||
|
await firstValue(container);
|
||||||
|
expect(container.read(showSignInPromptProvider), isTrue);
|
||||||
|
|
||||||
|
controller.add(const AuthStatus.signedIn(_user));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(container.read(showSignInPromptProvider), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('stream error -> true (treated as signed out)', () async {
|
||||||
|
final container = containerWith(Stream.error(StateError('boom')));
|
||||||
|
await firstValue(container);
|
||||||
|
expect(container.read(showSignInPromptProvider), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
243
app/test/features/home/presentation/home_screen_test.dart
Normal file
243
app/test/features/home/presentation/home_screen_test.dart
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shuffle/features/auth/domain/auth_status.dart';
|
||||||
|
import 'package:shuffle/features/auth/domain/auth_user.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
|
||||||
|
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
|
||||||
|
import 'package:shuffle/features/home/presentation/home_screen.dart';
|
||||||
|
import 'package:shuffle/l10n/app_localizations_de.dart';
|
||||||
|
import 'package:shuffle/l10n/app_localizations_en.dart';
|
||||||
|
|
||||||
|
import '../../../helpers/pump_app.dart';
|
||||||
|
import '../../auth/presentation/auth_ui_support.dart';
|
||||||
|
|
||||||
|
final _de = AppLocalizationsDe();
|
||||||
|
final _en = AppLocalizationsEn();
|
||||||
|
|
||||||
|
const _user = AuthUser(id: 'u1', email: 'a@example.test', emailConfirmed: true);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockAuthRepository repository;
|
||||||
|
|
||||||
|
setUp(() => repository = MockAuthRepository());
|
||||||
|
|
||||||
|
/// Pumps the home route plus the real auth routes. [status] `null` means
|
||||||
|
/// the status stream has no value yet (loading); [statusStream] overrides it.
|
||||||
|
Future<void> open(
|
||||||
|
WidgetTester tester, {
|
||||||
|
AuthStatus? status = const AuthStatus.signedOut(),
|
||||||
|
Stream<AuthStatus>? statusStream,
|
||||||
|
Locale locale = const Locale('de'),
|
||||||
|
ThemeMode themeMode = ThemeMode.light,
|
||||||
|
double? textScale,
|
||||||
|
Size size = const Size(400, 800),
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (_, _) => const HomeScreen()),
|
||||||
|
...authRoutes,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(router.dispose);
|
||||||
|
final stream =
|
||||||
|
statusStream ??
|
||||||
|
(status == null
|
||||||
|
? const Stream<AuthStatus>.empty()
|
||||||
|
: Stream.value(status));
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
router: router,
|
||||||
|
locale: locale,
|
||||||
|
themeMode: themeMode,
|
||||||
|
textScale: textScale,
|
||||||
|
settle: status != null || statusStream != null,
|
||||||
|
overrides: authUiOverrides(
|
||||||
|
repository,
|
||||||
|
locale: locale,
|
||||||
|
extra: [authStatusProvider.overrideWith((ref) => stream)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (status == null && statusStream == null) await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
final promptCard = find.byKey(const Key('sign_in_prompt'));
|
||||||
|
final signInButton = find.byKey(const Key('sign_in_prompt_sign_in'));
|
||||||
|
final registerButton = find.byKey(const Key('sign_in_prompt_register'));
|
||||||
|
|
||||||
|
group('#49 Krit. 1: Anmelde-Hinweis auf der Startseite', () {
|
||||||
|
testWidgets('abgemeldet: Hinweis mit zwei Knöpfen und Platzhaltertext', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester);
|
||||||
|
expect(promptCard, findsOneWidget);
|
||||||
|
expect(find.text(_de.signInPromptTitle), findsOneWidget);
|
||||||
|
expect(find.text(_de.signInPromptBody), findsOneWidget);
|
||||||
|
expect(find.text(_de.signInPromptSignIn), findsOneWidget);
|
||||||
|
expect(find.text(_de.signInPromptRegister), findsOneWidget);
|
||||||
|
expect(find.text(_de.homePlaceholderMessage), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('QUE-05 Krit. 2: abgemeldet, en: englische Texte', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester, locale: const Locale('en'));
|
||||||
|
expect(find.text(_en.signInPromptTitle), findsOneWidget);
|
||||||
|
expect(find.text(_en.signInPromptBody), findsOneWidget);
|
||||||
|
expect(find.text(_en.signInPromptSignIn), findsOneWidget);
|
||||||
|
expect(find.text(_en.signInPromptRegister), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('angemeldet: kein Hinweis, Platzhaltertext bleibt', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester, status: const AuthStatus.signedIn(_user));
|
||||||
|
expect(promptCard, findsNothing);
|
||||||
|
expect(find.text(_de.homePlaceholderMessage), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Laden (kein erster Wert): kein Hinweis', (tester) async {
|
||||||
|
await open(tester, status: null);
|
||||||
|
expect(promptCard, findsNothing);
|
||||||
|
expect(find.text(_de.homePlaceholderMessage), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Fehler im Statusstrom: Hinweis wird angezeigt', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester, statusStream: Stream.error(StateError('boom')));
|
||||||
|
expect(promptCard, findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('KTO-01 Krit. 4: Tipp auf „Anmelden“ öffnet den LoginScreen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester);
|
||||||
|
await tester.tap(signInButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('login_screen')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'KTO-01 Krit. 1: Tipp auf „Konto erstellen“ öffnet den RegisterScreen',
|
||||||
|
(tester) async {
|
||||||
|
await open(tester);
|
||||||
|
await tester.tap(registerButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('register_screen')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('breit (1280 dp): Karte höchstens 480 dp breit, zentriert', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(tester, size: wideScreenSize);
|
||||||
|
final rect = tester.getRect(promptCard);
|
||||||
|
expect(rect.width, lessThanOrEqualTo(480));
|
||||||
|
expect(rect.center.dx, closeTo(wideScreenSize.width / 2, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('QUE-06: Barrierefreiheit des Anmelde-Hinweises', () {
|
||||||
|
for (final (name, size, theme) in [
|
||||||
|
('Handy hell', const Size(400, 800), ThemeMode.light),
|
||||||
|
('Handy dunkel', const Size(400, 800), ThemeMode.dark),
|
||||||
|
('breit hell', wideScreenSize, ThemeMode.light),
|
||||||
|
]) {
|
||||||
|
testWidgets('QUE-06 Krit. 1-3: Guidelines ($name)', (tester) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
await open(tester, size: size, themeMode: theme);
|
||||||
|
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
|
||||||
|
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
||||||
|
handle.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'QUE-06 Krit. 1: Überschrift, Text und Knöpfe sind eigene Semantics-Knoten',
|
||||||
|
(tester) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
await open(tester, size: wideScreenSize);
|
||||||
|
expectSeparateSemantics(
|
||||||
|
tester,
|
||||||
|
title: _de.signInPromptTitle,
|
||||||
|
bodyKey: const Key('sign_in_prompt_body'),
|
||||||
|
bodyLabel: _de.signInPromptBody,
|
||||||
|
buttonKey: const Key('sign_in_prompt_sign_in'),
|
||||||
|
buttonLabel: _de.signInPromptSignIn,
|
||||||
|
);
|
||||||
|
expectButtonSemantics(tester, registerButton, _de.signInPromptRegister);
|
||||||
|
handle.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final locale in const [Locale('de'), Locale('en')]) {
|
||||||
|
testWidgets(
|
||||||
|
'QUE-06 Krit. 3: Schriftgröße 200 % ohne Überlauf (${locale.languageCode})',
|
||||||
|
(tester) async {
|
||||||
|
await open(
|
||||||
|
tester,
|
||||||
|
textScale: 2,
|
||||||
|
size: const Size(320, 640),
|
||||||
|
locale: locale,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
// The page scrolls: the buttons stay reachable and tappable.
|
||||||
|
await tester.ensureVisible(registerButton);
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
tester.getSize(registerButton).height,
|
||||||
|
greaterThanOrEqualTo(48),
|
||||||
|
);
|
||||||
|
await tester.tap(registerButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('register_screen')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'QUE-06 Krit. 4: Tab-Reihenfolge Anmelden -> Konto erstellen, Enter löst aus',
|
||||||
|
(tester) async {
|
||||||
|
await open(tester);
|
||||||
|
bool focusIn(Finder button) {
|
||||||
|
final focus = FocusManager.instance.primaryFocus;
|
||||||
|
if (focus?.context == null) return false;
|
||||||
|
return find
|
||||||
|
.descendant(
|
||||||
|
of: button,
|
||||||
|
matching: find.byElementPredicate(
|
||||||
|
(e) => e == focus!.context,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.evaluate()
|
||||||
|
.isNotEmpty ||
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byElementPredicate((e) => e == focus!.context),
|
||||||
|
matching: button,
|
||||||
|
)
|
||||||
|
.evaluate()
|
||||||
|
.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusIn(signInButton), isTrue);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusIn(registerButton), isTrue);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('register_screen')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -284,3 +284,21 @@ teilt – Entscheidung beim flutter-dev (A2).
|
||||||
- Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar.
|
- Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar.
|
||||||
- Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`,
|
- Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`,
|
||||||
`labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2).
|
`labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2).
|
||||||
|
|
||||||
|
## 3. Anmelde-Hinweis auf der Startseite (`SignInPrompt`, Spec #49, PR A)
|
||||||
|
|
||||||
|
Karte auf der Startseite, nur im Status `signedOut` (Entscheidung in `showSignInPromptProvider`; Laden: kein Hinweis,
|
||||||
|
Stream-Fehler: Hinweis). Auf allen Plattformen inklusive Web.
|
||||||
|
|
||||||
|
```
|
||||||
|
Dein Konto (Überschrift, titleLarge, Semantics header)
|
||||||
|
Melde dich an, um deine Sammlung und Partien zu verwalten. (bodyMedium)
|
||||||
|
( Anmelden ) -> /login (AppPrimaryButton, volle Breite)
|
||||||
|
( Konto erstellen ) -> /register (OutlinedButton, volle Breite)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Max. 480 dp breit (`AppBreakpoints.authCardMaxWidth`), zentriert unter dem Platzhaltertext; Innenabstand 24 dp, Knopfabstand 12 dp.
|
||||||
|
- Karteninhalt in `Semantics(explicitChildNodes: true)` (Regel aus Issue #41): Überschrift, Text und beide Knöpfe sind eigene Knoten.
|
||||||
|
- Lese- und Tab-Reihenfolge: Überschrift, Text, „Anmelden“, „Konto erstellen“. Kein `from`-Parameter.
|
||||||
|
- l10n: `signInPromptTitle`, `signInPromptBody`, `signInPromptSignIn`, `signInPromptRegister`.
|
||||||
|
- Keys: `sign_in_prompt`, `sign_in_prompt_title`, `sign_in_prompt_body`, `sign_in_prompt_sign_in`, `sign_in_prompt_register`.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ die Tests laufen deshalb parallel.
|
||||||
| Projekt | Ziel | Voraussetzung | Tests |
|
| Projekt | Ziel | Voraussetzung | Tests |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 |
|
| `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 |
|
||||||
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 21 |
|
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 27 |
|
||||||
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
|
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
|
||||||
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
|
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
|
||||||
|
|
||||||
|
|
|
||||||
78
e2e/tests/web/krit49-anmeldung-einstieg.spec.ts
Normal file
78
e2e/tests/web/krit49-anmeldung-einstieg.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { expectSignedIn, login, open, textOf } from '../../support/web';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
/** Texte (app_en.arb). */
|
||||||
|
const prompt = {
|
||||||
|
title: 'Your account',
|
||||||
|
body: 'Sign in to manage your collection and games.',
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf der Startseite', () => {
|
||||||
|
test('#49 Kriterium 1: abgemeldet zeigt die Startseite den Hinweis mit „Sign in“ und „Create account“', async ({ page }) => {
|
||||||
|
await open(page, '/');
|
||||||
|
await expect(textOf(page, prompt.title)).toBeVisible();
|
||||||
|
await expect(textOf(page, prompt.body)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#49 Kriterium 1: „Sign in“ fuehrt zu /login', async ({ page }) => {
|
||||||
|
await open(page, '/');
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#49 Kriterium 1: „Create account“ fuehrt zu /register', async ({ page }) => {
|
||||||
|
await open(page, '/');
|
||||||
|
await page.getByRole('button', { name: 'Create account', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/register$/);
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Display name' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QUE-06 Kriterium 4: Lesereihenfolge; erster Tab erreicht „Sign in“, Enter loest aus', async ({ page }) => {
|
||||||
|
await open(page, '/');
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Lesereihenfolge (Semantics-Baum): Ueberschrift, Text, „Sign in“, „Create account“.
|
||||||
|
const snapshot = await page.locator('body').ariaSnapshot();
|
||||||
|
const pos = [
|
||||||
|
`heading "${prompt.title}"`,
|
||||||
|
prompt.body,
|
||||||
|
'button "Sign in"',
|
||||||
|
'button "Create account"',
|
||||||
|
].map((needle) => snapshot.indexOf(needle));
|
||||||
|
expect(pos.every((p) => p >= 0)).toBe(true);
|
||||||
|
expect([...pos].sort((x, y) => x - y)).toEqual(pos);
|
||||||
|
|
||||||
|
// Flutter-Web behandelt Tab im eigenen Fokussystem; `document.activeElement` bleibt auf dem
|
||||||
|
// Semantics-Platzhalter. Deshalb wird die Reihenfolge ueber das Verhalten geprueft: erster Tab
|
||||||
|
// = „Sign in“, zweiter Tab = „Create account“, Enter loest den fokussierten Knopf aus.
|
||||||
|
// Der Fokusring selbst wird im Canvas gezeichnet (Widget-Test, Theme).
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QUE-06 Kriterium 4: zweiter Tab erreicht „Create account“, Enter loest aus', async ({ page }) => {
|
||||||
|
await open(page, '/');
|
||||||
|
await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page).toHaveURL(/\/register$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#49 Kriterium 1: angemeldet erscheint der Hinweis nicht', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
await login(page, email, password);
|
||||||
|
await expectSignedIn(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Create account', exact: true })).toHaveCount(0);
|
||||||
|
await expect(textOf(page, prompt.body)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue