diff --git a/app/lib/core/widgets/app_banner.dart b/app/lib/core/widgets/app_banner.dart new file mode 100644 index 0000000..9860f85 --- /dev/null +++ b/app/lib/core/widgets/app_banner.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +enum AppBannerType { info, error } + +/// Inline message banner (info / error) above forms. +/// +/// Type is conveyed by icon + [typeLabel] (spoken prefix, e.g. "Error"), +/// never by color alone. The message is a live region, so screen readers +/// announce it when it appears. The optional action is a separate 48 dp text +/// button. All texts come from the caller (l10n). +class AppBanner extends StatelessWidget { + const AppBanner({ + required this.type, + required this.message, + required this.typeLabel, + super.key, + this.actionLabel, + this.onAction, + }) : assert( + (actionLabel == null) == (onAction == null), + 'actionLabel and onAction belong together', + ); + + final AppBannerType type; + final String message; + final String typeLabel; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final isError = type == AppBannerType.error; + final background = isError + ? scheme.errorContainer + : scheme.secondaryContainer; + final foreground = isError + ? scheme.onErrorContainer + : scheme.onSecondaryContainer; + + return DecoratedBox( + decoration: BoxDecoration( + color: background, + borderRadius: AppRadius.mdAll, + border: Border.all(color: foreground.withValues(alpha: 0.5)), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + container: true, + liveRegion: true, + label: '$typeLabel: $message', + child: ExcludeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.info_outline, + color: foreground, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: foreground, + ), + ), + ), + ], + ), + ), + ), + if (actionLabel != null) + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + onPressed: onAction, + style: TextButton.styleFrom(foregroundColor: foreground), + child: Text(actionLabel!), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/core/widgets/app_checkbox_field.dart b/app/lib/core/widgets/app_checkbox_field.dart new file mode 100644 index 0000000..a68b03f --- /dev/null +++ b/app/lib/core/widgets/app_checkbox_field.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_field_error.dart'; + +/// A piece of a checkbox label: plain text or a link. +@immutable +class AppLabelSegment { + const AppLabelSegment.text(this.text) : onTap = null; + + /// A link. It is focusable via keyboard and exposed to screen readers as a + /// custom action named [text]. + const AppLabelSegment.link(this.text, this.onTap); + + final String text; + final VoidCallback? onTap; + + bool get isLink => onTap != null; +} + +/// Checkbox with a label (optionally with embedded links) and error state. +/// +/// The whole row (checkbox + label) toggles the value and is at least 48 dp +/// high. Links inside the label open their target instead of toggling. +/// [errorText] is shown with icon below the row and announced as live region. +class AppCheckboxField extends StatelessWidget { + AppCheckboxField({ + required this.value, + required this.onChanged, + required String label, + super.key, + this.errorText, + this.enabled = true, + }) : segments = [AppLabelSegment.text(label)]; + + const AppCheckboxField.rich({ + required this.value, + required this.onChanged, + required this.segments, + super.key, + this.errorText, + this.enabled = true, + }); + + final bool value; + final ValueChanged? onChanged; + final List segments; + final String? errorText; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasError = errorText != null && errorText!.isNotEmpty; + final interactive = enabled && onChanged != null; + final labelStyle = theme.textTheme.bodyLarge!; + final linkStyle = labelStyle.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ); + + final plainLabel = segments.map((s) => s.text).join(); + final linkActions = { + for (final s in segments.where((s) => s.isLink)) + CustomSemanticsAction(label: s.text): s.onTap!, + }; + + final label = Text.rich( + TextSpan( + style: labelStyle, + children: [ + for (final s in segments) + if (s.isLink) + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _InlineLink( + text: s.text, + style: linkStyle, + onTap: s.onTap!, + ), + ) + else + TextSpan(text: s.text), + ], + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + container: true, + checked: value, + enabled: interactive, + label: plainLabel, + onTap: interactive ? () => onChanged!(!value) : null, + customSemanticsActions: linkActions, + child: ExcludeSemantics( + child: InkWell( + canRequestFocus: false, + borderRadius: AppRadius.smAll, + onTap: interactive ? () => onChanged!(!value) : null, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSpacing.minTouchTarget, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: value, + isError: hasError, + onChanged: interactive + ? (v) => onChanged!(v ?? false) + : null, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + ), + child: label, + ), + ), + ], + ), + ), + ), + ), + ), + if (hasError) + Padding( + padding: const EdgeInsets.only( + left: AppSpacing.md, + bottom: AppSpacing.xs, + ), + child: AppFieldError(errorText!), + ), + ], + ); + } +} + +class _InlineLink extends StatelessWidget { + const _InlineLink({ + required this.text, + required this.style, + required this.onTap, + }); + + final String text; + final TextStyle style; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm / 2), + focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + child: Text(text, style: style), + ); + } +} diff --git a/app/lib/core/widgets/app_field_error.dart b/app/lib/core/widgets/app_field_error.dart new file mode 100644 index 0000000..506c945 --- /dev/null +++ b/app/lib/core/widgets/app_field_error.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Inline error message below a form control. +/// +/// Information is never conveyed by color alone: icon + text. The text is a +/// polite live region, so screen readers announce it when it appears. +class AppFieldError extends StatelessWidget { + const AppFieldError(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.error; + return Semantics( + container: true, + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.xxs), + child: Icon(Icons.error_outline, size: 16, color: color), + ), + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith(color: color), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/core/widgets/app_form_scaffold.dart b/app/lib/core/widgets/app_form_scaffold.dart new file mode 100644 index 0000000..69fd60a --- /dev/null +++ b/app/lib/core/widgets/app_form_scaffold.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Page frame for form screens (login, register, ...). +/// +/// Compact (< 600 dp): full-bleed, scrollable. From 600 dp: content in a +/// 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. +class AppFormScaffold extends StatelessWidget { + const AppFormScaffold({ + required this.child, + super.key, + this.appBar, + this.maxWidth = AppBreakpoints.authCardMaxWidth, + }); + + final Widget child; + final PreferredSizeWidget? appBar; + final double maxWidth; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: appBar, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final carded = width >= AppBreakpoints.medium; + final content = carded + ? Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: child, + ), + ) + : child; + return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: AppSpacing.pagePadding(width), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.lg * 2, + ), + child: Align( + alignment: carded ? Alignment.center : Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: carded ? maxWidth : double.infinity, + ), + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: SizedBox(width: double.infinity, child: content), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/core/widgets/app_password_field.dart b/app/lib/core/widgets/app_password_field.dart new file mode 100644 index 0000000..c5c6377 --- /dev/null +++ b/app/lib/core/widgets/app_password_field.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; + +/// Password field with visibility toggle, rule hint and autofill hints. +/// +/// The toggle is a 48 dp icon button with a tooltip/semantics label +/// ([showPasswordLabel] / [hidePasswordLabel], l10n from the caller). The +/// field is obscured initially. Use [isNewPassword] on register/reset so +/// password managers offer to generate a strong password. +class AppPasswordField extends StatefulWidget { + const AppPasswordField({ + required this.label, + required this.showPasswordLabel, + required this.hidePasswordLabel, + super.key, + this.controller, + this.focusNode, + this.ruleHint, + this.errorText, + this.isNewPassword = false, + this.autofillHints, + this.textInputAction, + this.enabled = true, + this.onChanged, + this.onSubmitted, + this.onEditingComplete, + }); + + final String label; + final String showPasswordLabel; + final String hidePasswordLabel; + final TextEditingController? controller; + final FocusNode? focusNode; + + /// Rule hint (e.g. "at least 10 characters"), shown while there is no error. + final String? ruleHint; + final String? errorText; + final bool isNewPassword; + + /// Overrides the default hints (`password` / `newPassword`). + final Iterable? autofillHints; + final TextInputAction? textInputAction; + final bool enabled; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onEditingComplete; + + @override + State createState() => _AppPasswordFieldState(); +} + +class _AppPasswordFieldState extends State { + bool _obscured = true; + + @override + Widget build(BuildContext context) { + return AppTextField( + label: widget.label, + controller: widget.controller, + focusNode: widget.focusNode, + helperText: widget.ruleHint, + errorText: widget.errorText, + obscureText: _obscured, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: widget.textInputAction, + enabled: widget.enabled, + autofillHints: + widget.autofillHints ?? + [ + if (widget.isNewPassword) + AutofillHints.newPassword + else + AutofillHints.password, + ], + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + onEditingComplete: widget.onEditingComplete, + suffixIcon: IconButton( + tooltip: _obscured + ? widget.showPasswordLabel + : widget.hidePasswordLabel, + icon: Icon(_obscured ? Icons.visibility : Icons.visibility_off), + onPressed: widget.enabled + ? () => setState(() => _obscured = !_obscured) + : null, + ), + ); + } +} diff --git a/app/lib/core/widgets/app_primary_button.dart b/app/lib/core/widgets/app_primary_button.dart new file mode 100644 index 0000000..dce08a7 --- /dev/null +++ b/app/lib/core/widgets/app_primary_button.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Primary action button with loading state. +/// +/// While [isLoading] the button is disabled (no double submit) and shows a +/// spinner next to the label. [loadingLabel] is the spoken label during +/// loading (l10n from the caller, e.g. "Registering..."); falls back to +/// [label]. Full width by default ([expand]); at least 48 dp high, grows with +/// the text scale. +class AppPrimaryButton extends StatelessWidget { + const AppPrimaryButton({ + required this.label, + required this.onPressed, + super.key, + this.isLoading = false, + this.loadingLabel, + this.icon, + this.expand = true, + }); + + final String label; + final VoidCallback? onPressed; + final bool isLoading; + final String? loadingLabel; + final IconData? icon; + final bool expand; + + @override + Widget build(BuildContext context) { + final enabled = onPressed != null && !isLoading; + final scheme = Theme.of(context).colorScheme; + final leading = isLoading + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: scheme.onSurface.withValues(alpha: 0.6), + ), + ) + : icon != null + ? Icon(icon, size: 20) + : null; + + final button = FilledButton( + onPressed: enabled ? onPressed : null, + child: Row( + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leading != null) ...[ + leading, + const SizedBox(width: AppSpacing.sm), + ], + Flexible(child: Text(label, textAlign: TextAlign.center)), + ], + ), + ); + + return Semantics( + button: true, + enabled: enabled, + label: isLoading ? (loadingLabel ?? label) : label, + excludeSemantics: true, + onTap: enabled ? onPressed : null, + child: expand ? SizedBox(width: double.infinity, child: button) : button, + ); + } +} diff --git a/app/lib/core/widgets/app_text_field.dart b/app/lib/core/widgets/app_text_field.dart new file mode 100644 index 0000000..a2feacf --- /dev/null +++ b/app/lib/core/widgets/app_text_field.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_field_error.dart'; + +/// Single-line text field of the design system. +/// +/// The [label] is always visible above the field and wraps (no clipping at +/// 200 % text scale). [errorText] replaces [helperText], is shown with icon +/// and error border and is announced as a live region. All strings come from +/// the caller (l10n). +class AppTextField extends StatelessWidget { + const AppTextField({ + required this.label, + super.key, + this.controller, + this.focusNode, + this.helperText, + this.errorText, + this.keyboardType, + this.textInputAction, + this.autofillHints, + this.textCapitalization = TextCapitalization.none, + this.autocorrect = true, + this.enableSuggestions = true, + this.obscureText = false, + this.suffixIcon, + this.maxLength, + this.showCounter = false, + this.inputFormatters, + this.enabled = true, + this.autofocus = false, + this.onChanged, + this.onSubmitted, + this.onEditingComplete, + }); + + final String label; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? helperText; + final String? errorText; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final Iterable? autofillHints; + final TextCapitalization textCapitalization; + final bool autocorrect; + final bool enableSuggestions; + final bool obscureText; + + /// Trailing widget inside the field (e.g. the password visibility toggle). + final Widget? suffixIcon; + + /// Soft limit: input beyond it is not blocked, so the caller can show the + /// error and keep the user's input. + final int? maxLength; + + /// Shows a `n/max` counter (needs [maxLength]). + final bool showCounter; + final List? inputFormatters; + final bool enabled; + final bool autofocus; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onEditingComplete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasError = errorText != null && errorText!.isNotEmpty; + final helper = helperText; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + Semantics( + label: label, + child: TextField( + controller: controller, + focusNode: focusNode, + enabled: enabled, + autofocus: autofocus, + keyboardType: keyboardType, + textInputAction: textInputAction, + autofillHints: autofillHints, + textCapitalization: textCapitalization, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + obscureText: obscureText, + inputFormatters: inputFormatters, + maxLength: maxLength, + maxLengthEnforcement: MaxLengthEnforcement.none, + buildCounter: showCounter && maxLength != null + ? null + : ( + _, { + required currentLength, + required isFocused, + maxLength, + }) => null, + onChanged: onChanged, + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + decoration: InputDecoration( + suffixIcon: suffixIcon, + error: hasError ? AppFieldError(errorText!) : null, + helper: !hasError && helper != null && helper.isNotEmpty + ? Text( + helper, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + ), + ), + ), + ], + ); + } +} diff --git a/app/test/core/widgets/app_banner_test.dart b/app/test/core/widgets/app_banner_test.dart new file mode 100644 index 0000000..b3deaab --- /dev/null +++ b/app/test/core/widgets/app_banner_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppBanner', () { + testWidgets('shows message and type icon', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppBanner( + type: AppBannerType.error, + typeLabel: 'Error', + message: 'Something failed', + ), + ); + expect(find.text('Something failed'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + + await pumpWidgetUnderTest( + tester, + const AppBanner( + type: AppBannerType.info, + typeLabel: 'Note', + message: 'FYI', + ), + ); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets('is a live region with type prefix', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppBanner( + type: AppBannerType.error, + typeLabel: 'Error', + message: 'Something failed', + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Error: Something failed')), + matchesSemantics(label: 'Error: Something failed', isLiveRegion: true), + ); + handle.dispose(); + }); + + testWidgets('action calls back and is at least 48 dp', (tester) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppBanner( + type: AppBannerType.info, + typeLabel: 'Note', + message: 'Confirm first', + actionLabel: 'Resend', + onAction: () => calls++, + ), + ); + await tester.tap(find.text('Resend')); + expect(calls, 1); + expect( + tester.getSize(find.byType(TextButton)).height, + greaterThanOrEqualTo(48), + ); + }); + + for (final mode in themeModes) { + for (final type in AppBannerType.values) { + testWidgets('meets a11y guidelines in $mode as $type', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppBanner( + type: type, + typeLabel: 'Note', + message: 'Confirm your address first', + actionLabel: 'Resend', + onAction: () {}, + ), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + AppBanner( + type: AppBannerType.error, + typeLabel: 'Error', + message: 'A long message that has to wrap onto several lines here', + actionLabel: 'Resend confirmation email', + onAction: () {}, + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_checkbox_field_test.dart b/app/test/core/widgets/app_checkbox_field_test.dart new file mode 100644 index 0000000..effb51e --- /dev/null +++ b/app/test/core/widgets/app_checkbox_field_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_checkbox_field.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppCheckboxField', () { + testWidgets('tapping the label toggles the value', (tester) async { + bool? received; + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: false, + label: 'I am at least 16', + onChanged: (v) => received = v, + ), + ); + await tester.tap(find.text('I am at least 16')); + expect(received, isTrue); + }); + + testWidgets('tapping the checkbox toggles the value', (tester) async { + bool? received; + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: true, + label: 'Label', + onChanged: (v) => received = v, + ), + ); + await tester.tap(find.byType(Checkbox)); + expect(received, isFalse); + }); + + testWidgets('does not react when disabled', (tester) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: false, + label: 'Label', + enabled: false, + onChanged: (_) => calls++, + ), + ); + await tester.tap(find.text('Label')); + expect(calls, 0); + }); + + testWidgets('whole row is at least 48 dp high', (tester) async { + await pumpWidgetUnderTest( + tester, + AppCheckboxField(value: false, label: 'Short', onChanged: (_) {}), + ); + expect( + tester.getSize(find.byType(InkWell)).height, + greaterThanOrEqualTo(48), + ); + }); + + testWidgets('link tap opens the link and does not toggle', (tester) async { + var toggled = 0; + var opened = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + onChanged: (_) => toggled++, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () => opened++), + ], + ), + ); + await tester.tap(find.text('terms')); + expect(opened, 1); + expect(toggled, 0); + await tester.tap(find.textContaining('I accept the')); + expect(toggled, 1); + }); + + testWidgets('exposes checked state, full label and link actions', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: true, + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () {}), + ], + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('I accept the terms')), + matchesSemantics( + label: 'I accept the terms', + hasCheckedState: true, + isChecked: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + customActions: [const CustomSemanticsAction(label: 'terms')], + ), + ); + handle.dispose(); + }); + + testWidgets('error shows icon and text as live region, error border', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppCheckboxField( + value: false, + label: 'Label', + errorText: 'Required', + onChanged: (_) {}, + ), + ); + expect(find.text('Required'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(tester.widget(find.byType(Checkbox)).isError, isTrue); + expect( + tester.getSemantics(find.text('Required')), + matchesSemantics(label: 'Required', isLiveRegion: true), + ); + handle.dispose(); + }); + + testWidgets('keyboard focus reaches checkbox and link, space toggles', ( + tester, + ) async { + bool? received; + var opened = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + onChanged: (v) => received = v, + segments: [ + const AppLabelSegment.text('Accept '), + AppLabelSegment.link('terms', () => opened++), + ], + ), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(received, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + expect(opened, 1); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode with error', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + errorText: 'Required', + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () {}), + ], + ), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + errorText: 'Please accept the privacy policy and the terms', + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I have read the privacy policy and '), + AppLabelSegment.link('accept the terms', () {}), + ], + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_form_scaffold_test.dart b/app/test/core/widgets/app_form_scaffold_test.dart new file mode 100644 index 0000000..3c5cd54 --- /dev/null +++ b/app/test/core/widgets/app_form_scaffold_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; + +import '../../helpers/pump_app.dart'; + +Future _pump( + WidgetTester tester, { + required Size size, + Widget? content, + double? textScale, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + textScale: textScale, + home: AppFormScaffold( + child: content ?? const SizedBox(key: Key('content'), height: 100), + ), + ); +} + +void main() { + group('AppFormScaffold', () { + testWidgets('compact: full width without card', (tester) async { + await _pump(tester, size: const Size(400, 800)); + expect(find.byType(Card), findsNothing); + expect( + tester.getSize(find.byKey(const Key('content'))).width, + 400 - 2 * AppSpacing.lg, + ); + }); + + testWidgets('just below 600 dp is still compact', (tester) async { + await _pump(tester, size: const Size(599, 800)); + expect(find.byType(Card), findsNothing); + }); + + testWidgets('medium: centered card limited to authCardMaxWidth', ( + tester, + ) async { + await _pump(tester, size: const Size(700, 900)); + expect(find.byType(Card), findsOneWidget); + final card = tester.getRect(find.byType(Card)); + expect(card.width, AppBreakpoints.authCardMaxWidth); + expect(card.center.dx, closeTo(350, 0.5)); + }); + + testWidgets('expanded: card stays at authCardMaxWidth', (tester) async { + await _pump(tester, size: const Size(1280, 800)); + expect( + tester.getRect(find.byType(Card)).width, + AppBreakpoints.authCardMaxWidth, + ); + }); + + testWidgets('scrolls when the content is taller than the viewport', ( + tester, + ) async { + await _pump( + tester, + size: const Size(400, 400), + content: const Column( + children: [ + SizedBox(height: 900), + SizedBox(key: Key('end'), height: 40), + ], + ), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('end')), + 200, + scrollable: find.byType(Scrollable).first, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('keyboard inset shrinks the body and the form stays ' + 'scrollable', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await pumpApp( + tester, + home: const AppFormScaffold( + child: Column( + children: [ + SizedBox(height: 700), + TextField(key: Key('last')), + ], + ), + ), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('last')), + 100, + scrollable: find.byType(Scrollable).first, + ); + final bottom = tester.getBottomLeft(find.byKey(const Key('last'))).dy; + expect(bottom, lessThanOrEqualTo(500)); + }); + + testWidgets('does not overflow at 200 % text scale on a small phone', ( + tester, + ) async { + await _pump( + tester, + size: const Size(320, 480), + textScale: 2, + content: const Column( + children: [ + Text('A heading that is long enough to wrap around a few lines'), + SizedBox(height: 300), + ], + ), + ); + expect(tester.takeException(), isNull); + }); + + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('card meets contrast guideline in $mode', (tester) async { + final handle = tester.ensureSemantics(); + tester.view.physicalSize = const Size(800, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + themeMode: mode, + home: const AppFormScaffold(child: Text('Sign in')), + ); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + }); +} diff --git a/app/test/core/widgets/app_password_field_test.dart b/app/test/core/widgets/app_password_field_test.dart new file mode 100644 index 0000000..968e568 --- /dev/null +++ b/app/test/core/widgets/app_password_field_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_password_field.dart'; + +import 'widget_harness.dart'; + +Widget _field({ + String? ruleHint, + String? errorText, + bool isNewPassword = false, + TextEditingController? controller, +}) => AppPasswordField( + label: 'Password', + showPasswordLabel: 'Show password', + hidePasswordLabel: 'Hide password', + ruleHint: ruleHint, + errorText: errorText, + isNewPassword: isNewPassword, + controller: controller, +); + +bool _obscured(WidgetTester tester) => + tester.widget(find.byType(EditableText)).obscureText; + +void main() { + group('AppPasswordField', () { + testWidgets('is obscured initially and toggles visibility', (tester) async { + await pumpWidgetUnderTest(tester, _field()); + expect(_obscured(tester), isTrue); + expect(find.byTooltip('Show password'), findsOneWidget); + + await tester.tap(find.byTooltip('Show password')); + await tester.pump(); + expect(_obscured(tester), isFalse); + expect(find.byTooltip('Hide password'), findsOneWidget); + + await tester.tap(find.byTooltip('Hide password')); + await tester.pump(); + expect(_obscured(tester), isTrue); + }); + + testWidgets('toggle keeps the entered text', (tester) async { + final controller = TextEditingController(text: 'secret-123'); + addTearDown(controller.dispose); + await pumpWidgetUnderTest(tester, _field(controller: controller)); + await tester.tap(find.byTooltip('Show password')); + await tester.pump(); + expect(controller.text, 'secret-123'); + }); + + testWidgets('toggle has a semantics label and a 48 dp target', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest(tester, _field()); + expect( + tester.getSemantics(find.byType(IconButton)).tooltip, + 'Show password', + ); + final size = tester.getSize(find.byType(IconButton)); + expect(size.width, greaterThanOrEqualTo(48)); + expect(size.height, greaterThanOrEqualTo(48)); + handle.dispose(); + }); + + testWidgets('uses password autofill hint by default', (tester) async { + await pumpWidgetUnderTest(tester, _field()); + expect( + tester.widget(find.byType(EditableText)).autofillHints, + [AutofillHints.password], + ); + }); + + testWidgets('uses newPassword autofill hint for new passwords', ( + tester, + ) async { + await pumpWidgetUnderTest(tester, _field(isNewPassword: true)); + expect( + tester.widget(find.byType(EditableText)).autofillHints, + [AutofillHints.newPassword], + ); + }); + + testWidgets('disables autocorrect and suggestions', (tester) async { + await pumpWidgetUnderTest(tester, _field()); + final editable = tester.widget(find.byType(EditableText)); + expect(editable.autocorrect, isFalse); + expect(editable.enableSuggestions, isFalse); + }); + + testWidgets('shows rule hint, error replaces it', (tester) async { + await pumpWidgetUnderTest(tester, _field(ruleHint: 'At least 10')); + expect(find.text('At least 10'), findsOneWidget); + await pumpWidgetUnderTest( + tester, + _field(ruleHint: 'At least 10', errorText: 'Too short'), + ); + expect(find.text('Too short'), findsOneWidget); + expect(find.text('At least 10'), findsNothing); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + _field(ruleHint: 'At least 10', errorText: 'Too short'), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + _field(ruleHint: 'At least 10 characters, no common passwords'), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_primary_button_test.dart b/app/test/core/widgets/app_primary_button_test.dart new file mode 100644 index 0000000..4447e38 --- /dev/null +++ b/app/test/core/widgets/app_primary_button_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppPrimaryButton', () { + testWidgets('calls onPressed', (tester) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Register', onPressed: () => calls++), + ); + await tester.tap(find.text('Register')); + expect(calls, 1); + }); + + testWidgets('is disabled and shows a spinner while loading', ( + tester, + ) async { + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppPrimaryButton( + label: 'Register', + isLoading: true, + onPressed: () => calls++, + ), + settle: false, + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.tap(find.text('Register'), warnIfMissed: false); + expect(calls, 0); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('is disabled without onPressed', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppPrimaryButton(label: 'Register', onPressed: null), + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('semantics: button with label, loading label while loading', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Register', onPressed: () {}), + ); + expect( + tester.getSemantics(find.byType(FilledButton)), + matchesSemantics( + label: 'Register', + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + ), + ); + + await pumpWidgetUnderTest( + tester, + AppPrimaryButton( + label: 'Register', + loadingLabel: 'Registering', + isLoading: true, + onPressed: () {}, + ), + settle: false, + ); + expect( + tester.getSemantics(find.byType(FilledButton)), + matchesSemantics( + label: 'Registering', + isButton: true, + hasEnabledState: true, + ), + ); + handle.dispose(); + }); + + testWidgets('is at least 48 dp high and full width by default', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Go', onPressed: () {}), + ); + final size = tester.getSize(find.byType(FilledButton)); + expect(size.height, greaterThanOrEqualTo(48)); + expect(size.width, 400 - 32); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + AppPrimaryButton(label: 'Register', onPressed: () {}), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale', (tester) async { + await pumpWidgetUnderTest( + tester, + AppPrimaryButton( + label: 'Create account and confirm email address', + isLoading: true, + onPressed: () {}, + ), + textScale: 2, + size: const Size(320, 640), + settle: false, + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/app_text_field_test.dart b/app/test/core/widgets/app_text_field_test.dart new file mode 100644 index 0000000..4470e01 --- /dev/null +++ b/app/test/core/widgets/app_text_field_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; + +import 'widget_harness.dart'; + +void main() { + group('AppTextField', () { + testWidgets('shows label and helper text, error replaces helper', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + const AppTextField(label: 'Email', helperText: 'Helper'), + ); + expect(find.text('Email'), findsOneWidget); + expect(find.text('Helper'), findsOneWidget); + + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Email', + helperText: 'Helper', + errorText: 'Broken', + ), + ); + expect(find.text('Broken'), findsOneWidget); + expect(find.text('Helper'), findsNothing); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('exposes the label as semantics label of the text field', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest(tester, const AppTextField(label: 'Email')); + expect( + tester.getSemantics(find.byType(TextField)), + matchesSemantics( + label: 'Email', + isTextField: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + handle.dispose(); + }); + + testWidgets('announces the error as live region', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppTextField(label: 'Email', errorText: 'Broken'), + ); + expect( + tester.getSemantics(find.text('Broken')), + matchesSemantics(label: 'Broken', isLiveRegion: true), + ); + handle.dispose(); + }); + + testWidgets('reports changes and keeps input', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + String? last; + await pumpWidgetUnderTest( + tester, + AppTextField( + label: 'Name', + controller: controller, + onChanged: (v) => last = v, + ), + ); + await tester.enterText(find.byType(TextField), 'abc'); + expect(last, 'abc'); + expect(controller.text, 'abc'); + }); + + testWidgets('does not block input beyond maxLength, shows counter', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await pumpWidgetUnderTest( + tester, + AppTextField( + label: 'Name', + controller: controller, + maxLength: 5, + showCounter: true, + ), + ); + await tester.enterText(find.byType(TextField), 'abcdefg'); + await tester.pump(); + expect(controller.text, 'abcdefg'); + expect(find.text('7/5'), findsOneWidget); + }); + + testWidgets('hides the counter by default', (tester) async { + await pumpWidgetUnderTest( + tester, + const AppTextField(label: 'Name', maxLength: 5), + ); + await tester.enterText(find.byType(TextField), 'ab'); + await tester.pump(); + expect(find.text('2/5'), findsNothing); + }); + + testWidgets('applies input formatters', (tester) async { + await pumpWidgetUnderTest( + tester, + AppTextField( + label: 'Digits', + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ); + await tester.enterText(find.byType(TextField), 'a1b2'); + expect( + tester.widget(find.byType(TextField)).controller?.text, + isNot('a1b2'), + ); + }); + + for (final mode in themeModes) { + testWidgets('meets a11y guidelines in $mode with error', (tester) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Email', + helperText: 'Helper', + errorText: 'Broken', + ), + themeMode: mode, + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('does not overflow at 200 % text scale with long texts', ( + tester, + ) async { + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'A rather long label that needs to wrap over many lines', + helperText: 'A long helper text that also needs to wrap around', + errorText: 'A long error text that also needs to wrap around lines', + ), + textScale: 2, + size: const Size(320, 640), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/app/test/core/widgets/widget_harness.dart b/app/test/core/widgets/widget_harness.dart new file mode 100644 index 0000000..9e4bd4c --- /dev/null +++ b/app/test/core/widgets/widget_harness.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/pump_app.dart'; + +/// Pumps [child] in a scrollable Scaffold with the app theme. +Future pumpWidgetUnderTest( + WidgetTester tester, + Widget child, { + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + bool settle = true, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpApp( + tester, + themeMode: themeMode, + textScale: textScale, + settle: settle, + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); +} + +const themeModes = [ThemeMode.light, ThemeMode.dark]; diff --git a/docs/design-system.md b/docs/design-system.md index 119f4e0..6beead6 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,7 +1,7 @@ # Design-System (Theme-Basis, F6 / Issue #31) -Stand: Basis. Konkrete Komponenten (Formularfelder, Banner, Spiel-Kachel usw.) folgen in U1. -Code: `app/lib/core/theme/` (`app_theme.dart`, `app_spacing.dart`), Tests: `app/test/core/theme/`. +Stand: Basis + Formular-Komponenten (U1). Weitere Komponenten (Spiel-Kachel, Listen, Leer-/Ladezustände) folgen. +Code: `app/lib/core/theme/` (`app_theme.dart`, `app_spacing.dart`), `app/lib/core/widgets/`; Tests: `app/test/core/theme/`, `app/test/core/widgets/`. ## Theme @@ -60,3 +60,23 @@ wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth FilledButton, ElevatedButton, OutlinedButton, TextButton, IconButton, Checkbox, InputDecoration (gefüllt, Rahmen 1 dp, Fokus 3 dp, Fehler 2 dp), Card, AppBar, Divider, SnackBar (schwebend, mit Schließen-Icon). + +## Formular-Komponenten (`app/lib/core/widgets/`, U1) + +Gemeinsame Regeln: Alle Texte (Labels, Fehler, Semantics-Labels) kommen als Parameter von außen (l10n), nie +hart codiert. Fehler = Icon + Text + Fehlerrahmen, nie nur Farbe. Fehlertexte sind Live-Regionen (werden von +Screenreadern angesagt). Layout bricht bei 200 % Textskalierung um statt abzuschneiden. Alle Komponenten sind +in Hell und Dunkel gegen `androidTapTargetGuideline`, `labeledTapTargetGuideline` und `textContrastGuideline` +getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vorschau. + +| Komponente | Zweck und API (Kurzform) | +|---|---| +| `AppTextField` | `label` (sichtbar über dem Feld, umbrechend, auch Semantics-Label), `helperText`, `errorText` (ersetzt Hilfetext), `controller`, `focusNode`, `keyboardType`, `textInputAction`, `autofillHints`, `maxLength` + `showCounter` (weiche Grenze: Eingabe wird nicht abgeschnitten, damit der Fehler am Feld erscheint), `inputFormatters`, `enabled`, `onChanged`, `onSubmitted`. | +| `AppPasswordField` | Wie `AppTextField`, dazu `showPasswordLabel` / `hidePasswordLabel` (Pflicht; Tooltip + Semantics des 48-dp-Schalters), `ruleHint` (Regelhinweis, solange kein Fehler), `isNewPassword` (Autofill `newPassword` statt `password`), `autofillHints` zum Überschreiben. Startet verdeckt, Autokorrektur und Vorschläge aus. | +| `AppCheckboxField` | `AppCheckboxField(value, onChanged, label)` bzw. `.rich(segments: [AppLabelSegment.text(..), AppLabelSegment.link(text, onTap)])`, `errorText`. Ganze Zeile (Checkbox + Label) ist Touch-Ziel >= 48 dp. Links sind per Tastatur fokussierbar und für Screenreader als benutzerdefinierte Aktionen verfügbar; Semantics-Knoten meldet Zustand und den vollen Labeltext. | +| `AppPrimaryButton` | `label`, `onPressed`, `isLoading` (deaktiviert + Spinner, kein Doppelsenden), `loadingLabel` (gesprochener Text im Ladezustand), `icon`, `expand` (Standard: volle Breite). | +| `AppFormScaffold` | `child`, `appBar`, `maxWidth` (Standard `AppBreakpoints.authCardMaxWidth`). Kompakt: Vollbild, scrollbar; ab 600 dp zentrierte Karte. Tastatur schiebt den Inhalt hoch (Scaffold-Resize), Ziehen schließt die Tastatur. | +| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton). Live-Region mit „typeLabel: message“. | +| `AppFieldError` | Interner Baustein: Fehlerzeile mit Icon als Live-Region, von Textfeld und Checkbox genutzt. | + +Auth-Screens: Layout-Skizzen und Textvorschläge siehe `docs/design/auth-screens.md`. diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md new file mode 100644 index 0000000..0e7ed28 --- /dev/null +++ b/docs/design/auth-screens.md @@ -0,0 +1,214 @@ +# Auth-Screens: Layout-Skizzen und Texte (Spec #1, Task U1) + +Grundlage: `docs/specs/1-auth-registrierung-anmeldung.md` (§ 4.4 Meldungen, § 5.4 Screens, § 6 Plattform). +Komponenten: `app/lib/core/widgets/` (siehe `docs/design-system.md`). Alle Texte unten sind +**Vorschläge** und gehen über l10n (ARB); die ARB-Dateien pflegt der flutter-dev (A1/A2). +Schlüssel sind `lowerCamelCase` mit Präfix `auth`. + +## 1. Gemeinsames Layout + +- Alle sechs Screens nutzen `AppFormScaffold`. Kompakt (< 600 dp): Vollbild, scrollbar, Seitenrand 16 dp. + Ab 600 dp: zentrierte Karte, max. 480 dp breit (`AppBreakpoints.authCardMaxWidth`), Innenabstand 24 dp. +- Inhalt in einer Spalte, Abstand zwischen Feldern 16 dp, zwischen Gruppen 24 dp. +- Reihenfolge = Lesereihenfolge = Tab-Reihenfolge: Titel, Banner (falls vorhanden), Felder, Checkboxen, + Primärknopf, Sekundärlinks. +- 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). +- 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 + Fokus am Knopf, das Banner wird als Live-Region angesagt. +- Enter im letzten Feld sendet ab (`TextInputAction.done`), sonst `next`. + +Legende der Skizzen: `[ ]` Feld, `[x]` Checkbox, `( )` Primärknopf, `<…>` Link/TextButton, `!` Banner. + +## 2. Skizzen + +### RegisterScreen (`/register`) + +``` +Konto erstellen (Titel) +! Fehler-Banner (nur bei registrationRejected / network / rateLimited / unexpected) +E-Mail +[ name@example.com ] Fehler: authEmailInvalid +Passwort +[ •••••••••• (Auge) ] Hinweis: authPasswordRule / Fehler je Regel +Anzeigename +[ Alex 4/30 ] Fehler: authDisplayNameEmpty | TooLong | Invalid +[x] Ich bin mindestens 16 Jahre alt Fehler: authAgeNotConfirmed +[x] Ich habe die gelesen und stimme den zu + Fehler: authTermsNotAccepted +( Konto erstellen ) + +``` + +### CheckEmailScreen (`/register/check-email`, Modus Bestätigung bzw. Reset) + +``` +Prüfe dein Postfach (Titel) +Wir haben dir eine E-Mail geschickt, falls ... (Text, bodyLarge) +Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach. +! Info-Banner "E-Mail wurde erneut gesendet" (nach Resend, auch bei emailSendLimited) +! Fehler-Banner bei rateLimited / network +(nur wenn E-Mail fehlt, z. B. nach Reload:) E-Mail [ ] +( E-Mail erneut senden ) -> nur Modus Bestätigung, gesperrt solange Anfrage läuft + (Modus Reset: -> /forgot-password) +``` + +### LoginScreen (`/login`) + +``` +Anmelden +! Fehler-Banner: authInvalidCredentials | rateLimited | network (über dem Formular, nie am Feld) +! Info-Banner emailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden" +E-Mail [ ] +Passwort [ ••••• (Auge) ] +( Anmelden ) + + +``` + +### ForgotPasswordScreen (`/forgot-password`) + +``` +Passwort zurücksetzen +Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen. +E-Mail [ ] Fehler: authEmailInvalid +( Link senden ) + +``` + +### EmailLinkScreen (`/auth/confirm`) + +Zustände: `idle` (Knopf), `submitting`, `success` (nur Bestätigung), `error` (Link ungültig). + +``` +E-Mail-Adresse bestätigen | Neues Passwort festlegen (Titel je Typ) +Ein Klick genügt, um fortzufahren. +( E-Mail-Adresse bestätigen ) | ( Weiter zum neuen Passwort ) + +Erfolg: ! Info-Banner "E-Mail bestätigt" (+ Mobile-Web: "Du kannst dich jetzt auch in der App anmelden.") +Fehler: ! Fehler-Banner "Der Link ist abgelaufen ..." ( Neuen Link anfordern ) +``` + +### ResetPasswordScreen (`/reset-password`) + +``` +Neues Passwort festlegen +Neues Passwort +[ ••••••••• (Auge) ] Live-Regeln: authPasswordRule, Fehler je Regel / samePassword +( Passwort speichern ) + -> signOut +Erfolg: Hinweis authPasswordChanged, dann Weiterleitung nach / +``` + +## 3. Textvorschläge + +Ton: du-Form, kurz, sachlich-freundlich, keine Schuldzuweisung. Platzhalter in ICU-Syntax (`{n}`). +Neutrale Texte (Krit. 4, F12) verraten nie, ob eine Adresse registriert ist. + +### 3.1 Meldungen aus § 4.4 (Fehlerabbildung) + +| Schlüssel | de | en | +|---|---|---| +| `authErrorInvalidCredentials` | E-Mail-Adresse oder Passwort ist nicht korrekt. | Email address or password is incorrect. | +| `authErrorEmailNotConfirmed` | Bestätige zuerst deine E-Mail-Adresse. Wir haben dir dazu eine E-Mail geschickt. | Please confirm your email address first. We sent you an email for this. | +| `authActionResendConfirmation` | Bestätigungs-E-Mail erneut senden | Resend confirmation email | +| `authErrorRateLimited` | Zu viele Versuche. Bitte warte einige Minuten und versuche es erneut. | Too many attempts. Please wait a few minutes and try again. | +| `authErrorWeakPasswordLength` | Das Passwort ist zu kurz. | The password is too short. | +| `authErrorWeakPasswordCharacters` | Das Passwort ist zu einfach. Wähle ein anderes. | The password is too simple. Choose a different one. | +| `authErrorWeakPasswordPwned` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. | +| `authErrorSamePassword` | Wähle ein anderes als dein bisheriges Passwort. | Choose a password different from your current one. | +| `authErrorLinkInvalidOrExpired` | Der Link ist abgelaufen oder wurde schon benutzt. | The link has expired or has already been used. | +| `authActionRequestNewLink` | Neuen Link anfordern | Request a new link | +| `authErrorEmailInvalid` | Gib eine gültige E-Mail-Adresse ein. | Enter a valid email address. | +| `authErrorRegistrationRejected` | Registrierung nicht möglich. Bitte prüfe deine Angaben. | Registration is not possible. Please check your details. | +| `authErrorNetwork` | Keine Verbindung. Für diesen Schritt brauchst du Internet. | No connection. You need internet access for this step. | +| `authErrorUnexpected` | Etwas ist schiefgelaufen. Bitte versuche es erneut. | Something went wrong. Please try again. | +| `authActionRetry` | Erneut versuchen | Try again | + +`emailSendLimited` hat bewusst **keinen** eigenen Text: Register/Forgot/CheckEmail zeigen den neutralen Erfolgstext +(§ 3.3), nur der Resend im LoginScreen zeigt `authErrorRateLimited`. + +### 3.2 Meldungen aus § 5.4 (Felder, Banner, Hinweise) + +| Schlüssel | de | en | +|---|---|---| +| `authPasswordTooShort` | Mindestens 10 Zeichen – es fehlen noch {missing}. | At least 10 characters – {missing} more needed. | +| `authPasswordCommon` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. | +| `authPasswordRule` | Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort. | At least 10 characters. Not a very common password. | +| `authPasswordRequired` | Gib ein Passwort ein. | Enter a password. | +| `authDisplayNameEmpty` | Gib einen Anzeigenamen ein. | Enter a display name. | +| `authDisplayNameTooLong` | Der Anzeigename darf höchstens {max} Zeichen lang sein. | The display name can be at most {max} characters long. | +| `authDisplayNameInvalid` | Dieser Anzeigename ist nicht erlaubt. Verwende sichtbare Zeichen. | This display name is not allowed. Use visible characters. | +| `authAgeNotConfirmed` | Shuffle ist erst ab 16 Jahren nutzbar. | Shuffle can only be used from age 16. | +| `authTermsNotAccepted` | Bitte stimme Datenschutzerklärung und Nutzungsbedingungen zu. | Please agree to the privacy policy and the terms of use. | +| `authFieldEmailLabel` | E-Mail-Adresse | Email address | +| `authFieldPasswordLabel` | Passwort | Password | +| `authFieldNewPasswordLabel` | Neues Passwort | New password | +| `authFieldDisplayNameLabel` | Anzeigename | Display name | +| `authPasswordShow` | Passwort anzeigen | Show password | +| `authPasswordHide` | Passwort verbergen | Hide password | +| `authCheckboxAgeLabel` | Ich bin mindestens 16 Jahre alt | I am at least 16 years old | +| `authCheckboxTermsPrefix` | Ich habe die | I have read the | +| `authCheckboxTermsPrivacyLink` | Datenschutzerklärung | privacy policy | +| `authCheckboxTermsMiddle` | gelesen und stimme den | and agree to the | +| `authCheckboxTermsTermsLink` | Nutzungsbedingungen | terms of use | +| `authCheckboxTermsSuffix` | zu | (leer) | +| `authBannerTypeError` | Fehler | Error | +| `authBannerTypeInfo` | Hinweis | Note | +| `authSubmitting` | Bitte warten … | Please wait … | + +Hinweis zur Checkbox „Nutzungsbedingungen“: Satzbau ist je Sprache verschieden. Der Satz wird deshalb aus den +Segmenten `Prefix`, `PrivacyLink`, `Middle`, `TermsLink`, `Suffix` zusammengesetzt (`AppCheckboxField.rich`); +im Englischen bleibt `Suffix` leer. Alternativ ein ICU-Satz mit Platzhaltern, den der Screen an den Linkstellen +teilt – Entscheidung beim flutter-dev (A2). + +### 3.3 Screen-Texte + +| Schlüssel | de | en | +|---|---|---| +| `authRegisterTitle` | Konto erstellen | Create account | +| `authRegisterSubmit` | Konto erstellen | Create account | +| `authRegisterHaveAccount` | Schon ein Konto? Anmelden | Already have an account? Sign in | +| `authCheckEmailTitle` | Prüfe dein Postfach | Check your inbox | +| `authCheckEmailBody` | Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren. | We sent you an email if the address is not registered yet. Follow the link in it to continue. | +| `authCheckEmailBodyReset` | Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen geschickt, falls zu dieser Adresse ein Konto besteht. | We sent you an email with a reset link if an account exists for this address. | +| `authCheckEmailDelayHint` | Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach. | The email can take a few minutes. Also check your spam folder. | +| `authCheckEmailValidity` | Der Link ist {duration} gültig. | The link is valid for {duration}. | +| `authCheckEmailValidityOneHour` | 1 Stunde | 1 hour | +| `authCheckEmailResend` | E-Mail erneut senden | Resend email | +| `authCheckEmailResent` | Wir haben die E-Mail erneut gesendet, falls es zu dieser Adresse etwas zu senden gibt. | We resent the email, if there is something to send for this address. | +| `authCheckEmailDifferentAddress` | Andere E-Mail-Adresse eingeben | Enter a different email address | +| `authBackToLogin` | Zurück zur Anmeldung | Back to sign in | +| `authLoginTitle` | Anmelden | Sign in | +| `authLoginSubmit` | Anmelden | Sign in | +| `authLoginForgotPassword` | Passwort vergessen? | Forgot password? | +| `authLoginCreateAccount` | Konto erstellen | Create account | +| `authForgotTitle` | Passwort zurücksetzen | Reset password | +| `authForgotBody` | Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen. | Enter the email address of your account. We will send you a reset link. | +| `authForgotSubmit` | Link senden | Send link | +| `authConfirmTitle` | E-Mail-Adresse bestätigen | Confirm email address | +| `authConfirmBody` | Ein Klick genügt, um deine E-Mail-Adresse zu bestätigen. | One click is enough to confirm your email address. | +| `authConfirmSubmit` | E-Mail-Adresse bestätigen | Confirm email address | +| `authConfirmSuccess` | E-Mail bestätigt. | Email confirmed. | +| `authConfirmSuccessMobileWeb` | Du kannst dich jetzt auch in der App anmelden. | You can now also sign in in the app. | +| `authRecoveryTitle` | Neues Passwort festlegen | Set a new password | +| `authRecoveryBody` | Ein Klick genügt, um ein neues Passwort festzulegen. | One click is enough to set a new password. | +| `authRecoverySubmit` | Weiter zum neuen Passwort | Continue to new password | +| `authResetTitle` | Neues Passwort festlegen | Set a new password | +| `authResetSubmit` | Passwort speichern | Save password | +| `authResetCancel` | Abbrechen | Cancel | +| `authPasswordChanged` | Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern. | Password changed. Other devices will be signed out; this can take up to an hour. | + +## 4. Barrierefreiheit pro Screen (Checkliste für A2–A4 und T2) + +- Jedes Feld hat ein sichtbares Label (kein Platzhalter-Label); Fehler per `errorText` (Icon + Text + Live-Region). +- Banner und Fehler werden beim Erscheinen vorgelesen (Live-Region); keine reinen Farb-Signale. +- Passwortfelder: `AppPasswordField` mit `isNewPassword: true` bei Register/Reset, sonst `false`; E-Mail mit + `AutofillHints.email` (Login: `AutofillHints.username`/`email`), `TextInputType.emailAddress`. +- 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).