feat(ui): add auth form components and screen layouts

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 00:03:11 +01:00
parent 3158abc344
commit ec8b2ab4c1
16 changed files with 1807 additions and 2 deletions

View file

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

View file

@ -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<bool>? onChanged;
final List<AppLabelSegment> 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 = <CustomSemanticsAction, VoidCallback>{
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),
);
}
}

View file

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

View file

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

View file

@ -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<String>? autofillHints;
final TextInputAction? textInputAction;
final bool enabled;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
@override
State<AppPasswordField> createState() => _AppPasswordFieldState();
}
class _AppPasswordFieldState extends State<AppPasswordField> {
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,
),
);
}
}

View file

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

View file

@ -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<String>? 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<TextInputFormatter>? inputFormatters;
final bool enabled;
final bool autofocus;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? 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,
),
),
),
],
);
}
}

View file

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

View file

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

View file

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

View file

@ -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<EditableText>(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<EditableText>(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<EditableText>(find.byType(EditableText)).autofillHints,
[AutofillHints.newPassword],
);
});
testWidgets('disables autocorrect and suggestions', (tester) async {
await pumpWidgetUnderTest(tester, _field());
final editable = tester.widget<EditableText>(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);
});
});
}

View file

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

View file

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

View file

@ -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<void> 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];

View file

@ -1,7 +1,7 @@
# Design-System (Theme-Basis, F6 / Issue #31) # Design-System (Theme-Basis, F6 / Issue #31)
Stand: Basis. Konkrete Komponenten (Formularfelder, Banner, Spiel-Kachel usw.) folgen in U1. 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`), Tests: `app/test/core/theme/`. 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 ## Theme
@ -60,3 +60,23 @@ wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth
FilledButton, ElevatedButton, OutlinedButton, TextButton, IconButton, Checkbox, InputDecoration 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). (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`.

214
docs/design/auth-screens.md Normal file
View file

@ -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 <Datenschutzerklärung> gelesen und stimme den <Nutzungsbedingungen> zu
Fehler: authTermsNotAccepted
( Konto erstellen )
<Schon ein Konto? Anmelden>
```
### 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
<Zurück zur Anmeldung> (Modus Reset: <Andere E-Mail-Adresse eingeben> -> /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 )
<Passwort vergessen?>
<Konto erstellen>
```
### 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 )
<Zurück zur Anmeldung>
```
### 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 )
<Abbrechen> -> 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).