Compare commits

...

3 commits

Author SHA1 Message Date
21ed0588eb Merge pull request 'feat(auth): Anmelde-Einstieg auf der Startseite (#49, PR A)' (#62) from feature/49a-anmeldung-einstieg into main
Some checks failed
CI / web-build (push) Waiting to run
CI / supabase (push) Waiting to run
CI / supabase-tests (push) Waiting to run
CI / e2e (push) Blocked by required conditions
CI / flutter (push) Has been cancelled
Reviewed-on: #62
2026-11-04 10:03:40 +00:00
cschlaefke
7eaed46dc2 test(auth): address review findings on sign-in prompt (PR A)
All checks were successful
CI / flutter (pull_request) Successful in 1m38s
CI / web-build (pull_request) Successful in 1m38s
CI / supabase (pull_request) Successful in 13s
CI / supabase-tests (pull_request) Successful in 35s
CI / e2e (pull_request) Successful in 2m58s
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 <noreply@anthropic.com>
2026-11-04 01:25:22 +01:00
cschlaefke
f078c8a094 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>
2026-11-04 01:03:16 +01:00
14 changed files with 730 additions and 12 deletions

View file

@ -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;
},
);
}

View file

@ -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';

View file

@ -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,
),
),
],
),
),
),
),
),
);
}
}

View file

@ -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(),
],
],
),
),
),
),
),
),

View file

@ -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.",

View file

@ -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.",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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);
});
});
}

View file

@ -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<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: 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);
});
});
}

View file

@ -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`.

View file

@ -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 |

View file

@ -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);
});
});