diff --git a/app/lib/core/widgets/app_form_scaffold.dart b/app/lib/core/widgets/app_form_scaffold.dart index 69fd60a..0db0df1 100644 --- a/app/lib/core/widgets/app_form_scaffold.dart +++ b/app/lib/core/widgets/app_form_scaffold.dart @@ -7,6 +7,10 @@ import 'package:shuffle/core/theme/app_spacing.dart'; /// centered card of at most [maxWidth] (default /// [AppBreakpoints.authCardMaxWidth]). The body scrolls, the keyboard pushes /// the content up (Scaffold resizes) and drags dismiss the keyboard. +/// +/// Semantics: the card keeps its children as separate semantics nodes +/// (`explicitChildNodes`), so heading, text and buttons stay individually +/// reachable (see `docs/design/auth-screens.md`). class AppFormScaffold extends StatelessWidget { const AppFormScaffold({ required this.child, @@ -30,9 +34,16 @@ class AppFormScaffold extends StatelessWidget { final carded = width >= AppBreakpoints.medium; final content = carded ? Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: child, + // Card is a semantics container; without explicit child + // nodes it folds title, text and buttons of the page into + // ONE node (heading + button in one), so screen readers + // find no button (issue #41). + child: Semantics( + explicitChildNodes: true, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: child, + ), ), ) : child; diff --git a/app/test/features/auth/presentation/auth_ui_support.dart b/app/test/features/auth/presentation/auth_ui_support.dart index d0f1a53..63478b6 100644 --- a/app/test/features/auth/presentation/auth_ui_support.dart +++ b/app/test/features/auth/presentation/auth_ui_support.dart @@ -124,3 +124,41 @@ List visibleTexts(WidgetTester tester) => [ for (final text in tester.widgetList(find.byType(Text))) if (text.data != null) text.data!, ]; + +/// Wide layout (>= 600 dp): the screen sits in a card (issue #41). +const wideScreenSize = Size(1280, 720); + +/// Asserts that title, body text and button are separate semantics nodes +/// (heading / text / button with label), not folded into one node (issue #41). +void expectSeparateSemantics( + WidgetTester tester, { + required String title, + required Key buttonKey, + required String buttonLabel, + Key? bodyKey, + String? bodyLabel, +}) { + expect( + tester.getSemantics(find.text(title).first), + matchesSemantics(label: title, isHeader: true), + reason: 'title must be a heading of its own', + ); + if (bodyKey != null) { + expect( + tester.getSemantics(find.byKey(bodyKey)), + matchesSemantics(label: bodyLabel), + reason: 'body text must be a text node of its own', + ); + } + expect( + tester.getSemantics(find.byKey(buttonKey)), + matchesSemantics( + label: buttonLabel, + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + ), + reason: 'button must be a button node of its own', + ); +} diff --git a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart index 720c82e..5df656d 100644 --- a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart +++ b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart @@ -290,6 +290,65 @@ void main() { expect(currentPath(router), '/login'); }); + group('QUE-06 (issue #41): separate semantics nodes in the wide card', () { + testWidgets('confirmation: heading, text and resend button', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open(tester, size: wideScreenSize); + + expectSeparateSemantics( + tester, + title: _de.authCheckEmailTitle, + bodyKey: const Key('check_email_body'), + bodyLabel: _de.authCheckEmailBody, + buttonKey: const Key('check_email_resend'), + buttonLabel: _de.authCheckEmailResend, + ); + handle.dispose(); + }); + + testWidgets('reset: heading, text and secondary buttons', (tester) async { + final handle = tester.ensureSemantics(); + await pumpAuthRoute( + tester, + repository, + location: '/forgot-password/check-email', + size: wideScreenSize, + ); + + expect( + tester.getSemantics(find.text(_de.authCheckEmailTitle).first), + matchesSemantics(label: _de.authCheckEmailTitle, isHeader: true), + ); + expect( + tester.getSemantics(find.byKey(const Key('check_email_body'))), + matchesSemantics(label: _de.authCheckEmailBodyReset), + ); + for (final (key, label) in [ + ( + const Key('check_email_different_address'), + _de.authCheckEmailDifferentAddress, + ), + (const Key('check_email_back_to_login'), _de.authBackToLogin), + ]) { + expect( + tester.getSemantics(find.byKey(key)), + matchesSemantics( + label: label, + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + } + handle.dispose(); + }); + }); + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { await open(tester, locale: const Locale('en')); diff --git a/app/test/features/auth/presentation/email_link/email_link_screen_test.dart b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart index c17e5d4..7037872 100644 --- a/app/test/features/auth/presentation/email_link/email_link_screen_test.dart +++ b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart @@ -263,6 +263,99 @@ void main() { }); }); + group('QUE-06 (issue #41): separate semantics nodes in the wide card', () { + testWidgets('ready: heading, text and button are separate nodes', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open(tester, size: wideScreenSize); + + expectSeparateSemantics( + tester, + title: _de.authConfirmTitle, + bodyKey: const Key('email_link_body'), + bodyLabel: _de.authConfirmBody, + buttonKey: const Key('email_link_submit'), + buttonLabel: _de.authConfirmSubmit, + ); + handle.dispose(); + }); + + testWidgets('recovery: heading, text and button are separate nodes', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open( + tester, + location: '/auth/confirm?token_hash=abc123&type=recovery', + size: wideScreenSize, + ); + + expectSeparateSemantics( + tester, + title: _de.authRecoveryTitle, + bodyKey: const Key('email_link_body'), + bodyLabel: _de.authRecoveryBody, + buttonKey: const Key('email_link_submit'), + buttonLabel: _de.authRecoverySubmit, + ); + handle.dispose(); + }); + + testWidgets('invalid link: banner action is a button of its own', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open( + tester, + location: '/auth/confirm?token_hash=x&type=bad', + size: wideScreenSize, + ); + + expect( + tester.getSemantics(find.text(_de.authConfirmTitle).first), + matchesSemantics(label: _de.authConfirmTitle, isHeader: true), + ); + expect( + tester.getSemantics(find.text(_de.authActionRequestNewLink)), + matchesSemantics( + label: _de.authActionRequestNewLink, + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('success: banner stays a live region, button is separate', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open(tester, size: wideScreenSize); + await tapSubmit(tester); + + expect( + find.bySemanticsLabel('Hinweis: ${_de.authConfirmSuccess}'), + findsOneWidget, + ); + expect( + tester.getSemantics(find.byKey(const Key('email_link_continue'))), + matchesSemantics( + label: _de.authConfirmContinue, + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + }); + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { await open(tester, locale: const Locale('en')); diff --git a/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart b/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart index 33374b3..d8065dc 100644 --- a/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart +++ b/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart @@ -375,6 +375,34 @@ void main() { handle.dispose(); }); + testWidgets('success view: heading, live region and button (issue #41)', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + when(() => repository.currentStatus).thenReturn(_changed); + await open(tester, size: wideScreenSize); + + expect( + tester.getSemantics(find.text(_de.authResetTitle).first), + matchesSemantics(label: _de.authResetTitle, isHeader: true), + ); + expect( + find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'), + findsOneWidget, + ); + expect( + tester.getSemantics(find.byKey(const Key('reset_password_continue'))), + matchesSemantics( + label: _de.authConfirmContinue, + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + testWidgets('text scale 2.0 on a small phone does not overflow', ( tester, ) async { diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 75154b1..833ad46 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -22,6 +22,20 @@ Schlüssel sind `lowerCamelCase` mit Präfix `auth`. Fokus am Knopf, das Banner wird als Live-Region angesagt. - Enter im letzten Feld sendet ab (`TextInputAction.done`), sonst `next`. +### Semantics-Struktur (Regel, Issue #41) + +- Überschrift, Text und Knopf sind **getrennte Semantics-Knoten**: `heading` (`AuthScreenTitle`), Text, `button` + mit Label (`AppPrimaryButton`, `TextButton`). Das gilt in jeder Breite, auch in der Karte ab 600 dp. +- Ursache des Fehlers: `Card` ist ein Semantics-Container ohne `explicitChildNodes`. Alle Kinder ohne eigenen + Container (Titel, `Text`, Knopf) werden in dessen einen Knoten gefaltet. Seiten mit Eingabefeld fielen nicht auf, + weil `AppTextField` & Co. eigene Container sind. Im Web und ab 600 dp trat es auf, auf dem Handy (ohne Karte) nicht. +- Deshalb kapselt `AppFormScaffold` den Karteninhalt in `Semantics(explicitChildNodes: true)`. Neue Layouts mit + `Card`/`Semantics(container: true)` um Seiteninhalt müssen das ebenfalls tun; `MergeSemantics` nur gezielt für + zusammengehörige Einzelelemente, nie um ganze Seiten. +- Banner bleiben eigene Live-Regionen (`AppBanner`), ihre Aktion ist ein eigener Knopf-Knoten. +- Test: `expectSeparateSemantics` (`app/test/features/auth/presentation/auth_ui_support.dart`) im weiten Layout + (`wideScreenSize`); E2E klickt per `getByRole('button', …)`. + Legende der Skizzen: `[ ]` Feld, `[x]` Checkbox, `( )` Primärknopf, `<…>` Link/TextButton, `!` Banner. ## 2. Skizzen