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
This commit is contained in:
cschlaefke 2026-11-04 10:03:40 +00:00
commit 21ed0588eb
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);
});
});