From f078c8a09403906839ed11997c2a2c91e9d8b76d Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:03:16 +0100 Subject: [PATCH 1/2] 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 --- .../providers/sign_in_prompt_provider.dart | 31 +++ .../providers/sign_in_prompt_provider.g.dart | 76 ++++++ .../presentation/widgets/sign_in_prompt.dart | 75 ++++++ .../home/presentation/home_screen.dart | 45 +++- app/lib/l10n/app_de.arb | 4 + app/lib/l10n/app_en.arb | 4 + app/lib/l10n/app_localizations.dart | 24 ++ app/lib/l10n/app_localizations_de.dart | 13 + app/lib/l10n/app_localizations_en.dart | 12 + .../sign_in_prompt_provider_test.dart | 75 ++++++ .../home/presentation/home_screen_test.dart | 243 ++++++++++++++++++ docs/design/auth-screens.md | 18 ++ e2e/README.md | 2 +- .../web/krit49-anmeldung-einstieg.spec.ts | 78 ++++++ 14 files changed, 690 insertions(+), 10 deletions(-) create mode 100644 app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart create mode 100644 app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart create mode 100644 app/lib/features/auth/presentation/widgets/sign_in_prompt.dart create mode 100644 app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart create mode 100644 app/test/features/home/presentation/home_screen_test.dart create mode 100644 e2e/tests/web/krit49-anmeldung-einstieg.spec.ts diff --git a/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart new file mode 100644 index 0000000..f1a8b29 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart @@ -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; + }, + ); +} diff --git a/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart new file mode 100644 index 0000000..dfffef0 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart @@ -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 + with $Provider { + /// 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 $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(value), + ); + } +} + +String _$showSignInPromptHash() => r'1a87f4e14f79f3e5d9aa16f0f5812afabe603c27'; diff --git a/app/lib/features/auth/presentation/widgets/sign_in_prompt.dart b/app/lib/features/auth/presentation/widgets/sign_in_prompt.dart new file mode 100644 index 0000000..d6155d8 --- /dev/null +++ b/app/lib/features/auth/presentation/widgets/sign_in_prompt.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/home/presentation/home_screen.dart b/app/lib/features/home/presentation/home_screen.dart index 1274e26..9f317f4 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -1,23 +1,50 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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. -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}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + final showPrompt = ref.watch(showSignInPromptProvider); return Scaffold( key: const Key('home_screen'), appBar: AppBar(title: Text(l10n.appTitle)), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - l10n.homePlaceholderMessage, - key: const Key('home_placeholder_message'), - textAlign: TextAlign.center, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: AppSpacing.pagePadding(constraints.maxWidth), + child: ConstrainedBox( + constraints: BoxConstraints( + 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(), + ], + ], + ), + ), + ), ), ), ), diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 907b00b..416cd15 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -51,6 +51,10 @@ "authLoginSubmit": "Anmelden", "authLoginForgotPassword": "Passwort vergessen?", "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", "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.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 86e8967..ba96571 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -90,6 +90,10 @@ "authLoginSubmit": "Sign in", "authLoginForgotPassword": "Forgot password?", "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", "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.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2f5596f..078f9d7 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -404,6 +404,30 @@ abstract class AppLocalizations { /// **'Create account'** 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. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 899aaf2..bbb4c85 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -191,6 +191,19 @@ class AppLocalizationsDe extends AppLocalizations { @override 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 String get authCheckEmailTitle => 'Prüfe dein Postfach'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index eb22e28..cc7e7c5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -188,6 +188,18 @@ class AppLocalizationsEn extends AppLocalizations { @override 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 String get authCheckEmailTitle => 'Check your inbox'; diff --git a/app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart b/app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart new file mode 100644 index 0000000..7dac161 --- /dev/null +++ b/app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart @@ -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 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 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(); + 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.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); + }); + }); +} diff --git a/app/test/features/home/presentation/home_screen_test.dart b/app/test/features/home/presentation/home_screen_test.dart new file mode 100644 index 0000000..e8c891d --- /dev/null +++ b/app/test/features/home/presentation/home_screen_test.dart @@ -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 open( + WidgetTester tester, { + AuthStatus? status = const AuthStatus.signedOut(), + Stream? 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.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); + }, + ); + }); +} diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index b61e82d..d04f41c 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -284,3 +284,21 @@ teilt – Entscheidung beim flutter-dev (A2). - Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar. - Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`, `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`. diff --git a/e2e/README.md b/e2e/README.md index 3685cd0..bf6043f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -9,7 +9,7 @@ die Tests laufen deshalb parallel. | Projekt | Ziel | Voraussetzung | Tests | |---|---|---|---| | `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 | | `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 | diff --git a/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts new file mode 100644 index 0000000..b900655 --- /dev/null +++ b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts @@ -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); + }); +}); From 7eaed46dc2077c46dadf4ce7fd8ec5c7203b52c3 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:25:22 +0100 Subject: [PATCH 2/2] test(auth): address review findings on sign-in prompt (PR A) Correct the focus comment (refs #61), test Space key and focus state, prove scrollability at 200 percent text, drop needless minWidth, English doc comment, allow OutlinedButton as secondary action in standalone cards, English prompt text says plays instead of games. Refs #49 Co-Authored-By: Claude Sonnet 5.5 --- .../home/presentation/home_screen.dart | 7 ++--- app/lib/l10n/app_en.arb | 2 +- app/lib/l10n/app_localizations.dart | 2 +- app/lib/l10n/app_localizations_en.dart | 2 +- .../home/presentation/home_screen_test.dart | 30 ++++++++++++++++++- docs/design/auth-screens.md | 2 +- .../web/krit49-anmeldung-einstieg.spec.ts | 21 +++++++++---- 7 files changed, 52 insertions(+), 14 deletions(-) diff --git a/app/lib/features/home/presentation/home_screen.dart b/app/lib/features/home/presentation/home_screen.dart index 9f317f4..6044ac3 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -5,10 +5,10 @@ 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. +/// Placeholder home screen. To be replaced by the catalog search / QUE-09. /// -/// Liest nur [showSignInPromptProvider]; die Entscheidung, ob der -/// Anmelde-Hinweis erscheint, liegt dort (Spec #49 § 5.1). +/// Reads only [showSignInPromptProvider]; the decision whether the sign-in +/// prompt appears lives there (Spec #49 § 5.1). class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @@ -26,7 +26,6 @@ class HomeScreen extends ConsumerWidget { child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight - AppSpacing.lg * 2, - minWidth: constraints.maxWidth, ), child: Center( child: Column( diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index ba96571..d466f62 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -91,7 +91,7 @@ "authLoginForgotPassword": "Forgot password?", "authLoginCreateAccount": "Create account", "signInPromptTitle": "Your account", - "signInPromptBody": "Sign in to manage your collection and games.", + "signInPromptBody": "Sign in to manage your collection and plays.", "signInPromptSignIn": "Sign in", "signInPromptRegister": "Create account", "authCheckEmailTitle": "Check your inbox", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 078f9d7..1f4ab53 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -413,7 +413,7 @@ abstract class AppLocalizations { /// No description provided for @signInPromptBody. /// /// In en, this message translates to: - /// **'Sign in to manage your collection and games.'** + /// **'Sign in to manage your collection and plays.'** String get signInPromptBody; /// No description provided for @signInPromptSignIn. diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index cc7e7c5..bba36f6 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -192,7 +192,7 @@ class AppLocalizationsEn extends AppLocalizations { String get signInPromptTitle => 'Your account'; @override - String get signInPromptBody => 'Sign in to manage your collection and games.'; + String get signInPromptBody => 'Sign in to manage your collection and plays.'; @override String get signInPromptSignIn => 'Sign in'; diff --git a/app/test/features/home/presentation/home_screen_test.dart b/app/test/features/home/presentation/home_screen_test.dart index e8c891d..146ddb0 100644 --- a/app/test/features/home/presentation/home_screen_test.dart +++ b/app/test/features/home/presentation/home_screen_test.dart @@ -188,7 +188,15 @@ void main() { locale: locale, ); expect(tester.takeException(), isNull); - // The page scrolls: the buttons stay reachable and tappable. + // The page scrolls: dragging moves the content, the buttons stay + // reachable and tappable. + final before = tester.getTopLeft(registerButton).dy; + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.pump(); + expect(tester.getTopLeft(registerButton).dy, lessThan(before)); await tester.ensureVisible(registerButton); await tester.pump(); expect( @@ -233,11 +241,31 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(focusIn(registerButton), isTrue); + // Visible focus: keyboard navigation switches to traditional highlight + // mode and the button holds the primary focus (the focus ring comes + // from the focused state of the theme). + expect( + FocusManager.instance.highlightMode, + FocusHighlightMode.traditional, + ); + expect(FocusManager.instance.primaryFocus?.hasPrimaryFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(find.byKey(const Key('register_screen')), findsOneWidget); }, ); + + testWidgets('QUE-06 Krit. 4: Leertaste löst „Konto erstellen“ aus', ( + tester, + ) async { + await open(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('register_screen')), findsOneWidget); + }); }); } diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index d04f41c..cba224a 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -15,7 +15,7 @@ Schlüssel sind `lowerCamelCase` mit Präfix `auth`. - Titel: `headlineSmall`, darunter optional ein Einleitungssatz in `bodyMedium`. Titel als Semantics-Überschrift (`Semantics(header: true)`). - Banner (`AppBanner`) stehen **über** den Feldern, direkt unter dem Titel. Fehler am Feld stehen unter dem Feld. -- Genau ein Primärknopf (`AppPrimaryButton`, volle Breite). Sekundäraktionen sind `TextButton`s (48 dp). +- Genau ein Primärknopf (`AppPrimaryButton`, volle Breite). Sekundäraktionen sind `TextButton`s (48 dp). Für eigenständige Karten (z. B. `SignInPrompt`) ist ein `OutlinedButton` als Sekundäraktion zulässig. - Eingaben bleiben bei jedem Fehler erhalten. Während `submitting` sind Knopf und Felder gesperrt, der Knopf zeigt den Spinner und den gesprochenen Text `authSubmitting`. - Nach einem Fehler setzt der Screen den Fokus auf das erste fehlerhafte Feld; bei Banner-Fehlern bleibt der diff --git a/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts index b900655..37a353a 100644 --- a/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts +++ b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts @@ -7,7 +7,7 @@ const gotrue = new GoTrueClient(); /** Texte (app_en.arb). */ const prompt = { title: 'Your account', - body: 'Sign in to manage your collection and games.', + body: 'Sign in to manage your collection and plays.', }; test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf der Startseite', () => { @@ -48,10 +48,10 @@ test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf 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). + // Reihenfolge ueber das Verhalten: erster Tab = „Sign in“, zweiter Tab = „Create account“, + // Enter loest den fokussierten Knopf aus. DOM-Fokus (toBeFocused) liegt nur auf „Create account“; + // „Sign in“ bekommt nie DOM-Fokus (kein tabindex, AppPrimaryButton verwirft focusable/focused + // des FilledButton), siehe Issue #61. Der Fokusring selbst wird im Canvas gezeichnet. await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); await expect(page).toHaveURL(/\/login$/); @@ -62,10 +62,21 @@ test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible(); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeFocused(); await page.keyboard.press('Enter'); await expect(page).toHaveURL(/\/register$/); }); + test('QUE-06 Kriterium 4: Leertaste loest „Create account“ 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 expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeFocused(); + await page.keyboard.press('Space'); + await expect(page).toHaveURL(/\/register$/); + }); + test('#49 Kriterium 1: angemeldet erscheint der Hinweis nicht', async ({ page }) => { const email = randomEmail(); const password = randomPassword();