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:
parent
3158abc344
commit
ec8b2ab4c1
16 changed files with 1807 additions and 2 deletions
94
app/lib/core/widgets/app_banner.dart
Normal file
94
app/lib/core/widgets/app_banner.dart
Normal 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!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/lib/core/widgets/app_checkbox_field.dart
Normal file
168
app/lib/core/widgets/app_checkbox_field.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/lib/core/widgets/app_field_error.dart
Normal file
40
app/lib/core/widgets/app_field_error.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/lib/core/widgets/app_form_scaffold.dart
Normal file
65
app/lib/core/widgets/app_form_scaffold.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/lib/core/widgets/app_password_field.dart
Normal file
91
app/lib/core/widgets/app_password_field.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/lib/core/widgets/app_primary_button.dart
Normal file
69
app/lib/core/widgets/app_primary_button.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/lib/core/widgets/app_text_field.dart
Normal file
133
app/lib/core/widgets/app_text_field.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/test/core/widgets/app_banner_test.dart
Normal file
108
app/test/core/widgets/app_banner_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
206
app/test/core/widgets/app_checkbox_field_test.dart
Normal file
206
app/test/core/widgets/app_checkbox_field_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
140
app/test/core/widgets/app_form_scaffold_test.dart
Normal file
140
app/test/core/widgets/app_form_scaffold_test.dart
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
127
app/test/core/widgets/app_password_field_test.dart
Normal file
127
app/test/core/widgets/app_password_field_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
135
app/test/core/widgets/app_primary_button_test.dart
Normal file
135
app/test/core/widgets/app_primary_button_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
163
app/test/core/widgets/app_text_field_test.dart
Normal file
163
app/test/core/widgets/app_text_field_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
32
app/test/core/widgets/widget_harness.dart
Normal file
32
app/test/core/widgets/widget_harness.dart
Normal 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];
|
||||||
|
|
@ -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
214
docs/design/auth-screens.md
Normal 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).
|
||||||
Loading…
Reference in a new issue