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..6044ac3 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -1,23 +1,49 @@ 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 { +/// Placeholder home screen. To be replaced by the catalog search / QUE-09. +/// +/// 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}); @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, + ), + 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..d466f62 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 plays.", + "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..1f4ab53 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 plays.'** + 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..bba36f6 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 plays.'; + + @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..146ddb0 --- /dev/null +++ b/app/test/features/home/presentation/home_screen_test.dart @@ -0,0 +1,271 @@ +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: 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( + 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); + // 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 b61e82d..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 @@ -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..37a353a --- /dev/null +++ b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts @@ -0,0 +1,89 @@ +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 plays.', +}; + +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); + + // 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$/); + }); + + 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 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(); + 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); + }); +});