feat(flutter): 重设计登录页视觉

This commit is contained in:
Zhou Weidong committed 2026-10-17 17:14:56 +08:00
1 parent 6cc5bfc3be
commit 45c3c645ff
4 files changed
+989 -229

No files matched your search

+132 -229
View File
@@ -13,7 +13,26 @@ import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
part 'auth_widgets.dart';
part 'auth_form_widgets.dart';
const double _minTapTarget = 44;
const double _brandMarkSize = 48;
const double _contentMaxWidth = 440;
const double _cardRadius = 20;
const double _cardPadding = 20;
const double _cardBorderWidth = 1;
const double _fieldHeight = 52;
const double _fieldRadius = 14;
const double _fieldBorderWidth = 1.5;
const double _buttonHeight = 52;
const double _buttonRadius = 26;
const double _glowOpacity = 0.18;
const double _errorOpacity = 0.10;
const double _errorBorderOpacity = 0.28;
const double _enterSlideDistance = 16;
const Duration _enterDuration = Duration(milliseconds: 220);
const Duration _segmentDuration = Duration(milliseconds: 160);
/// 登录 / 注册页:邮箱 + 密码。
class AuthPage extends StatefulWidget {
@@ -112,245 +131,129 @@ class _AuthPageState extends State<AuthPage> {
child: Scaffold(
resizeToAvoidBottomInset: true,
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
elevation: 0,
foregroundColor: AppColors.onPrimary,
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
),
body: SafeArea(
child: AutofillGroup(
child: ListView(
key: const Key('auth-scroll'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
20,
12,
20,
28 + MediaQuery.viewInsetsOf(context).bottom,
),
children: [
_AuthBrand(tagline: l10n.authBrandTagline),
const SizedBox(height: 26),
Text(
_logic.signUpMode
? l10n.authSignUpTitle
: l10n.authSignInTitle,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 24,
fontWeight: FontWeight.w800,
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 420,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-glow'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.blue.withValues(alpha: _glowOpacity),
AppColors.blue.withValues(alpha: 0.05),
AppColors.ink.withValues(alpha: 0),
],
stops: const [0, 0.55, 1],
),
),
),
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
_emailField(l10n),
const SizedBox(height: 14),
_passwordField(l10n),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
_AuthError(message: _logic.errorMessage!),
],
const SizedBox(height: 22),
_submitButton(l10n),
const SizedBox(height: 10),
_switchModeButton(l10n),
],
),
),
),
),
);
}
Widget _emailField(AppLocalizations l10n) => _field(
child: TextField(
key: const Key('auth-email'),
controller: _emailController,
focusNode: _emailFocus,
enabled: !_logic.busy,
autofocus: true,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
onChanged: (value) => _logic.email = value,
onSubmitted: (_) => _passwordFocus.requestFocus(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(l10n.authEmailLabel, 'name@example.com'),
),
);
Widget _passwordField(AppLocalizations l10n) => _field(
child: TextField(
key: const Key('auth-password'),
controller: _passwordController,
focusNode: _passwordFocus,
enabled: !_logic.busy,
obscureText: _obscurePassword,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onChanged: (value) => _logic.password = value,
onSubmitted: (_) => _submit(),
style: const TextStyle(color: AppColors.onPrimary, fontSize: 15),
decoration: _decoration(
l10n.authPasswordLabel,
l10n.authPasswordHint,
suffix: IconButton(
key: const Key('auth-password-visibility'),
constraints: const BoxConstraints.tightFor(
width: _minTapTarget,
height: _minTapTarget,
),
tooltip: _obscurePassword ? l10n.showPassword : l10n.hidePassword,
onPressed: _logic.busy
? null
: () => setState(() => _obscurePassword = !_obscurePassword),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppColors.muted,
),
),
),
),
);
Widget _field({required Widget child}) =>
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [child]);
InputDecoration _decoration(String label, String hint, {Widget? suffix}) {
final scheme = Theme.of(context).colorScheme;
return InputDecoration(
labelText: label,
hintText: hint,
hintStyle: TextStyle(color: scheme.onSurfaceVariant, fontSize: 14),
suffixIcon: suffix,
filled: true,
fillColor: AppColors.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 17),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
);
}
Widget _submitButton(AppLocalizations l10n) => SizedBox(
height: 50,
child: FilledButton(
key: const Key('auth-submit'),
onPressed: _logic.busy ? null : _submit,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
),
child: _logic.busy
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onPrimary,
),
)
: Text(
_logic.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
);
Widget _switchModeButton(AppLocalizations l10n) => TextButton(
key: const Key('auth-switch-mode'),
style: TextButton.styleFrom(
minimumSize: const Size(0, _minTapTarget),
foregroundColor: AppColors.blueSoft,
),
onPressed: _logic.busy ? null : () => setState(() => _logic.toggleMode()),
child: Text(
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp,
style: const TextStyle(fontSize: 13),
),
);
}
class _AuthBrand extends StatelessWidget {
const _AuthBrand({required this.tagline});
final String tagline;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 30,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.appName,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
SafeArea(
child: AutofillGroup(
child: ListView(
key: const Key('auth-scroll'),
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
20,
12,
20,
28 + MediaQuery.viewInsetsOf(context).bottom,
),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: _contentMaxWidth,
),
child: TweenAnimationBuilder<double>(
key: const Key('auth-enter-animation'),
tween: Tween(begin: 0, end: 1),
duration: _enterDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) => Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(
0,
_enterSlideDistance * (1 - value),
),
child: child,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (Navigator.of(context).canPop())
Align(
alignment: Alignment.centerLeft,
child: IconButton(
key: const Key('auth-back'),
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip,
onPressed: () =>
Navigator.of(context).pop(),
icon: const Icon(
Icons.arrow_back_rounded,
color: AppColors.onPrimary,
),
),
),
_AuthBrand(tagline: l10n.authBrandTagline),
const SizedBox(height: 28),
_authCard(l10n),
],
),
),
),
),
],
),
),
const SizedBox(height: 2),
Text(
tagline,
style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
),
],
),
),
],
),
],
),
);
}
}
class _AuthError extends StatelessWidget {
const _AuthError({required this.message});
Widget _authCard(AppLocalizations l10n) => _AuthCard(
state: _AuthCardState(
l10n: l10n,
status: _AuthFormStatus(
signUpMode: _logic.signUpMode,
busy: _logic.busy,
errorMessage: _logic.errorMessage,
obscurePassword: _obscurePassword,
),
fields: _AuthFieldControllers(
email: _emailController,
password: _passwordController,
emailFocus: _emailFocus,
passwordFocus: _passwordFocus,
),
),
callbacks: _AuthCardCallbacks(
onModeSelected: _selectMode,
onEmailChanged: (value) => _logic.email = value,
onPasswordChanged: (value) => _logic.password = value,
onTogglePasswordVisibility: () =>
setState(() => _obscurePassword = !_obscurePassword),
onSubmit: _submit,
),
);
final String message;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.error;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: color, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
key: const Key('auth-error'),
style: TextStyle(color: color, fontSize: 13),
),
),
],
);
void _selectMode(bool signUpMode) {
if (_logic.busy || _logic.signUpMode == signUpMode) return;
setState(() => _logic.toggleMode());
}
}