404 lines
12 KiB
Dart
404 lines
12 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
part of 'auth_view.dart';
|
|
|
|
class _AuthFields extends StatelessWidget {
|
|
const _AuthFields({required this.state, required this.callbacks});
|
|
|
|
final _AuthCardState state;
|
|
final _AuthCardCallbacks callbacks;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final error = state.status.fieldError;
|
|
final emailError =
|
|
error == AuthFieldError.emailRequired ||
|
|
error == AuthFieldError.emailInvalid;
|
|
final passwordError =
|
|
error == AuthFieldError.passwordRequired ||
|
|
error == AuthFieldError.passwordLength ||
|
|
error == AuthFieldError.passwordWeak;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_fieldLabel(state.l10n.authEmailLabel),
|
|
const SizedBox(height: 6),
|
|
_emailField(context, hasError: emailError),
|
|
const SizedBox(height: 14),
|
|
_fieldLabel(state.l10n.authPasswordLabel),
|
|
const SizedBox(height: 6),
|
|
_passwordField(context, hasError: passwordError),
|
|
if (state.status.signUpMode) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
state.l10n.authPasswordHint,
|
|
key: const Key('auth-password-rule'),
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: AppDimens.caption,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _emailField(BuildContext context, {required bool hasError}) {
|
|
final enabled = !state.status.busy;
|
|
return ListenableBuilder(
|
|
listenable: state.fields.emailFocus,
|
|
builder: (context, _) => SizedBox(
|
|
height: _fieldHeight,
|
|
child: TextField(
|
|
key: const Key('auth-email'),
|
|
controller: state.fields.email,
|
|
focusNode: state.fields.emailFocus,
|
|
enabled: enabled,
|
|
autofocus: true,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
autofillHints: const [AutofillHints.email],
|
|
onChanged: callbacks.onEmailChanged,
|
|
onSubmitted: (_) => state.fields.passwordFocus.requestFocus(),
|
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
|
cursorColor: AppColors.blue,
|
|
decoration: _decoration(
|
|
context: context,
|
|
hint: 'name@example.com',
|
|
icon: Icons.mail_outline_rounded,
|
|
focused: state.fields.emailFocus.hasFocus,
|
|
hasError: hasError,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _passwordField(BuildContext context, {required bool hasError}) {
|
|
final enabled = !state.status.busy;
|
|
final l10n = state.l10n;
|
|
return ListenableBuilder(
|
|
listenable: state.fields.passwordFocus,
|
|
builder: (context, _) => SizedBox(
|
|
height: _fieldHeight,
|
|
child: TextField(
|
|
key: const Key('auth-password'),
|
|
controller: state.fields.password,
|
|
focusNode: state.fields.passwordFocus,
|
|
enabled: enabled,
|
|
obscureText: state.status.obscurePassword,
|
|
keyboardType: TextInputType.visiblePassword,
|
|
textInputAction: TextInputAction.done,
|
|
autofillHints: const [AutofillHints.password],
|
|
onChanged: callbacks.onPasswordChanged,
|
|
onSubmitted: (_) => callbacks.onSubmit(),
|
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
|
cursorColor: AppColors.blue,
|
|
decoration: _decoration(
|
|
context: context,
|
|
hint: l10n.authPasswordHint,
|
|
icon: Icons.lock_outline_rounded,
|
|
focused: state.fields.passwordFocus.hasFocus,
|
|
hasError: hasError,
|
|
suffix: IconButton(
|
|
key: const Key('auth-password-visibility'),
|
|
constraints: const BoxConstraints.tightFor(
|
|
width: _minTapTarget,
|
|
height: _minTapTarget,
|
|
),
|
|
tooltip: state.status.obscurePassword
|
|
? l10n.showPassword
|
|
: l10n.hidePassword,
|
|
onPressed: enabled ? callbacks.onTogglePasswordVisibility : null,
|
|
icon: Icon(
|
|
state.status.obscurePassword
|
|
? Icons.visibility_outlined
|
|
: Icons.visibility_off_outlined,
|
|
color: AppColors.muted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _fieldLabel(String label) => Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
);
|
|
|
|
InputDecoration _decoration({
|
|
required BuildContext context,
|
|
required String hint,
|
|
required IconData icon,
|
|
required bool focused,
|
|
required bool hasError,
|
|
Widget? suffix,
|
|
}) {
|
|
final border = OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
|
borderSide: const BorderSide(
|
|
color: AppColors.border,
|
|
width: _fieldBorderWidth,
|
|
),
|
|
);
|
|
final errorColor = Theme.of(context).colorScheme.error;
|
|
final errorBorder = OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
|
borderSide: BorderSide(color: errorColor, width: _fieldBorderWidth),
|
|
);
|
|
return InputDecoration(
|
|
hintText: hint,
|
|
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 14),
|
|
prefixIcon: Icon(
|
|
icon,
|
|
size: 21,
|
|
color: focused ? AppColors.blue : AppColors.muted,
|
|
),
|
|
prefixIconConstraints: const BoxConstraints(
|
|
minWidth: _minTapTarget,
|
|
minHeight: _minTapTarget,
|
|
),
|
|
suffixIcon: suffix,
|
|
filled: true,
|
|
fillColor: AppColors.card,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.only(right: 16),
|
|
border: border,
|
|
enabledBorder: border,
|
|
disabledBorder: border,
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(_fieldRadius),
|
|
borderSide: const BorderSide(
|
|
color: AppColors.blue,
|
|
width: _fieldBorderWidth,
|
|
),
|
|
),
|
|
errorBorder: errorBorder,
|
|
focusedErrorBorder: errorBorder,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AuthSubmitButton extends StatelessWidget {
|
|
const _AuthSubmitButton({required this.state, required this.onSubmit});
|
|
|
|
final _AuthCardState state;
|
|
final VoidCallback onSubmit;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final status = state.status;
|
|
final l10n = state.l10n;
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
height: _buttonHeight,
|
|
child: FilledButton(
|
|
key: const Key('auth-submit'),
|
|
onPressed: status.busy ? null : onSubmit,
|
|
style: ButtonStyle(
|
|
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
|
if (states.contains(WidgetState.disabled)) {
|
|
return AppColors.blue.withValues(alpha: 0.5);
|
|
}
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return AppColors.bluePressed;
|
|
}
|
|
return AppColors.blue;
|
|
}),
|
|
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
|
|
elevation: const WidgetStatePropertyAll(2),
|
|
shadowColor: WidgetStatePropertyAll(
|
|
AppColors.blue.withValues(alpha: 0.22),
|
|
),
|
|
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
|
|
shape: WidgetStatePropertyAll(
|
|
RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(_buttonRadius),
|
|
),
|
|
),
|
|
),
|
|
child: status.busy
|
|
? const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.onPrimary,
|
|
),
|
|
)
|
|
: Text(
|
|
status.signUpMode ? l10n.signUp : l10n.signIn,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ModeSegment extends StatelessWidget {
|
|
const _ModeSegment({
|
|
required this.segmentKey,
|
|
required this.label,
|
|
required this.hiddenLabel,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final Key segmentKey;
|
|
final String label;
|
|
final String hiddenLabel;
|
|
final bool selected;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
key: segmentKey,
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: _segmentDuration,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected ? AppColors.blue : AppColors.transparent,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: ExcludeSemantics(
|
|
child: Opacity(
|
|
opacity: 0,
|
|
child: Center(
|
|
child: Text(
|
|
hiddenLabel,
|
|
maxLines: 1,
|
|
softWrap: false,
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AuthBrand extends StatelessWidget {
|
|
const _AuthBrand({required this.tagline, required this.compact});
|
|
|
|
final String tagline;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final copy = _brandCopy(context, centered: !compact);
|
|
if (compact) {
|
|
return Row(
|
|
key: const Key('auth-brand'),
|
|
children: [
|
|
_brandMark(),
|
|
const SizedBox(width: 14),
|
|
Expanded(child: copy),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
key: const Key('auth-brand'),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [_brandMark(), const SizedBox(height: 14), copy],
|
|
);
|
|
}
|
|
|
|
Widget _brandMark() => _brandMarkWidget(
|
|
key: const Key('auth-brand-mark'),
|
|
size: compact ? _compactBrandMarkSize : _brandMarkSize,
|
|
radius: compact ? _compactBrandMarkRadius : _brandMarkRadius,
|
|
iconSize: compact ? _compactBrandIconSize : _brandIconSize,
|
|
);
|
|
|
|
Widget _brandCopy(BuildContext context, {required bool centered}) => Column(
|
|
crossAxisAlignment: centered
|
|
? CrossAxisAlignment.center
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
AppLocalizations.of(context)!.appName,
|
|
textAlign: centered ? TextAlign.center : TextAlign.start,
|
|
style: const TextStyle(
|
|
color: AppColors.onSurface,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w900,
|
|
height: 1.1,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
tagline,
|
|
textAlign: centered ? TextAlign.center : TextAlign.start,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 13,
|
|
height: 1.35,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _AuthError extends StatelessWidget {
|
|
const _AuthError({required this.message});
|
|
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = Theme.of(context).colorScheme.error;
|
|
return Container(
|
|
key: const Key('auth-error-capsule'),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: _errorOpacity),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withValues(alpha: _errorBorderOpacity)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.error_outline_rounded, color: color, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
key: const Key('auth-error'),
|
|
style: TextStyle(color: color, fontSize: 12, height: 1.35),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|