// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/formatting/error_text.dart'; import '../../core/data/services/app_config_controller.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/centered_toast.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_legal_links.dart'; import 'auth_logic.dart'; import 'phone_auth_sheet.dart'; part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; const double _minTapTarget = 44; const double _brandMarkSize = 88; const double _compactBrandMarkSize = 48; const double _brandMarkRadius = 26; const double _compactBrandMarkRadius = 14; const String _appLogoAsset = 'assets/icon/ymb.png'; const double _contentMaxWidth = 440; const double _fieldHeight = 52; const double _fieldRadius = 12; const double _fieldBorderWidth = 1; const double _buttonHeight = 50; const double _buttonRadius = 25; const double _errorOpacity = 0.10; const double _errorBorderOpacity = 0.28; const double _enterSlideDistance = 16; const double _topContentPadding = 56; const double _bottomContentPadding = 28; const double _compactTopContentPadding = 40; const double _compactHeightBreakpoint = 720; const double _compactBrandGap = 16; const double _entryButtonHeight = 52; const double _entryButtonRadius = 26; const double _entryButtonFontSize = 15; const double _entryIconSize = 20; const Duration _enterDuration = Duration(milliseconds: 220); Widget _brandMarkWidget({ Key? key, required double size, required double radius, }) { return ClipRRect( key: key, borderRadius: BorderRadius.circular(radius), child: Image.asset( _appLogoAsset, width: size, height: size, fit: BoxFit.cover, ), ); } /// 登录 / 注册页:邮箱 + 密码。 class AuthPage extends StatefulWidget { const AuthPage({super.key, this.signUpMode = false}); final bool signUpMode; @override State createState() => _AuthPageState(); } class _AuthPageState extends State { late AuthLogic _logic; late final TextEditingController _emailController; late final TextEditingController _passwordController; late final FocusNode _emailFocus; late final FocusNode _passwordFocus; late final ScrollController _scrollController; final _keyboardInset = KeyboardInsetNotifier.instance; double _lastKeyboardInset = 0; bool _obscurePassword = true; bool _emailEntryOpen = false; @override void initState() { super.initState(); _logic = AuthLogic(signUpMode: widget.signUpMode); _emailController = TextEditingController(); _passwordController = TextEditingController(); _emailFocus = FocusNode(debugLabel: 'auth-email-focus'); _passwordFocus = FocusNode(debugLabel: 'auth-password-focus'); _scrollController = ScrollController(); _emailFocus.addListener(_onFocusChanged); _passwordFocus.addListener(_onFocusChanged); _keyboardInset.addListener(_onKeyboardInsetChanged); unawaited(_keyboardInset.start()); } @override void didChangeDependencies() { super.didChangeDependencies(); final inset = effectiveKeyboardInset(context, _keyboardInset); if ((inset - _lastKeyboardInset).abs() >= 1) { _lastKeyboardInset = inset; _scheduleRevealFocusedField(); } } @override void dispose() { _keyboardInset.removeListener(_onKeyboardInsetChanged); _emailFocus.removeListener(_onFocusChanged); _passwordFocus.removeListener(_onFocusChanged); _emailController.dispose(); _passwordController.dispose(); _emailFocus.dispose(); _passwordFocus.dispose(); _scrollController.dispose(); super.dispose(); } void _onKeyboardInsetChanged() { if (!mounted) return; setState(() {}); _scheduleRevealFocusedField(); } void _onFocusChanged() { if (_emailFocus.hasFocus || _passwordFocus.hasFocus) { _scheduleRevealFocusedField(); } } /// 点在当前邮箱或密码框之外时取消焦点。 void _releaseFieldFocus(PointerDownEvent event) { final focus = FocusManager.instance.primaryFocus; if (focus != _emailFocus && focus != _passwordFocus) return; final renderObject = focus?.context?.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { focus?.unfocus(); return; } final bounds = renderObject.localToGlobal(Offset.zero) & renderObject.size; if (!bounds.contains(event.position)) focus?.unfocus(); } void _scheduleRevealFocusedField() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _revealFocusedField(); }); } void _revealFocusedField() { if (!_scrollController.hasClients) return; final focus = _passwordFocus.hasFocus ? _passwordFocus : _emailFocus.hasFocus ? _emailFocus : null; final fieldContext = focus?.context; final renderObject = fieldContext?.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) return; final keyboardInset = effectiveKeyboardInset(context, _keyboardInset); if (keyboardInset <= 0) return; final fieldBottom = renderObject .localToGlobal(Offset(0, renderObject.size.height)) .dy; final visibleBottom = MediaQuery.sizeOf(context).height - keyboardInset - 24; final overlap = fieldBottom - visibleBottom; if (overlap <= 0) return; final position = _scrollController.position; final target = (position.pixels + overlap) .clamp(position.minScrollExtent, position.maxScrollExtent) .toDouble(); if (target <= position.pixels + 0.5) return; unawaited( _scrollController.animateTo( target, duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, ), ); } String _errorText(AppLocalizations l10n, AuthFieldError error) { switch (error) { case AuthFieldError.emailRequired: return l10n.authEmailRequired; case AuthFieldError.emailInvalid: return l10n.authEmailInvalid; case AuthFieldError.passwordRequired: return l10n.authPasswordRequired; case AuthFieldError.passwordLength: return l10n.authPasswordLength; case AuthFieldError.passwordWeak: return l10n.authPasswordWeak; } } Future _submit() async { if (_logic.busy) return; final l10n = AppLocalizations.of(context)!; final invalid = _logic.validate(); if (invalid != null) { setState(() => _logic.errorMessage = _errorText(l10n, invalid)); return; } unawaited(context.read().lightImpact()); FocusScope.of(context).unfocus(); setState(() { _logic.busy = true; _logic.errorMessage = null; }); final auth = context.read(); String? failureMessage; try { if (_logic.signUpMode) { await auth.register( email: _logic.normalizedEmail, password: _logic.password, ); } else { await auth.signIn( email: _logic.normalizedEmail, password: _logic.password, ); } if (!mounted) return; Navigator.of(context).maybePop(); } on ApiException catch (error) { failureMessage = userFacingApiMessage(l10n, error.code, error.message); } catch (_) { failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _logic.busy = false); } if (!mounted || failureMessage == null) return; showCenteredToast(context, failureMessage); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.dark(), child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: AppColors.ink, body: Stack( children: [ const Positioned.fill( child: IgnorePointer( child: DecoratedBox( key: Key('auth-page-background'), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ AppColors.followingGlow, AppColors.followingGlowMid, AppColors.appBackground, ], stops: [0, 0.14, 0.3], ), ), ), ), ), // 全屏海报背景:压暗保证文字可读,顶部再叠品牌红光。 Positioned.fill(child: IgnorePointer(child: _AuthPosterBackdrop())), const Positioned.fill( child: IgnorePointer( child: DecoratedBox( key: Key('auth-poster-scrim'), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0x66000000), Color(0x14000000), Color(0xE0000000), ], stops: [0, 0.42, 1], ), ), ), ), ), const Positioned.fill( child: IgnorePointer( child: DecoratedBox( key: Key('auth-red-glow'), decoration: BoxDecoration( gradient: RadialGradient( center: Alignment(0, -1.15), radius: 1.3, colors: [AppColors.primaryBorder, AppColors.transparent], ), ), ), ), ), SafeArea( child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxHeight < _compactHeightBreakpoint; final top = compact ? _compactTopContentPadding : _topContentPadding; final bottom = _bottomContentPadding + effectiveKeyboardInset(context, _keyboardInset); final available = constraints.maxHeight - top - bottom; final minHeight = available > 0 ? available : 0.0; return Listener( behavior: HitTestBehavior.translucent, onPointerDown: _releaseFieldFocus, child: AutofillGroup( child: SingleChildScrollView( key: const Key('auth-scroll'), controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB( AppDimens.pageHorizontal, top, AppDimens.pageHorizontal, bottom, ), child: ConstrainedBox( constraints: BoxConstraints(minHeight: minHeight), child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: const BoxConstraints( maxWidth: _contentMaxWidth, ), child: _content( l10n, compact: compact, minHeight: minHeight, ), ), ), ), ), ), ); }, ), ), if (Navigator.of(context).canPop()) Positioned( top: 0, left: 0, right: 0, child: SafeArea( child: Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.only(left: 14, top: 8), child: AppSubpageBackButton( backKey: const Key('auth-back'), onBack: () { if (_emailEntryOpen) { setState(() => _emailEntryOpen = false); } else { Navigator.of(context).pop(); } }, ), ), ), ), ), ], ), ), ); } Widget _content( AppLocalizations l10n, { required bool compact, required double minHeight, }) => TweenAnimationBuilder( 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: _emailEntryOpen ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), SizedBox(height: compact ? _compactBrandGap : 28), _authCard(l10n), ], ) : _landing(l10n, minHeight: minHeight), ); /// 落地页内容需要有限高度才能用 Spacer 三段分割,这里显式钉住可用高度。 Widget _landing(AppLocalizations l10n, {required double minHeight}) => SizedBox( height: minHeight, child: Column( key: const Key('auth-landing'), crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Spacer(flex: 5), Center( child: _brandMarkWidget( key: const Key('auth-brand-logo'), size: _brandMarkSize, radius: _brandMarkRadius, ), ), const Spacer(flex: 2), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, primary: true, onTap: _openPhoneSheet, ), const SizedBox(height: 12), _AuthEntryButton( key: const Key('auth-entry-email'), label: l10n.authEmailSignIn, icon: Icons.mail_outline_rounded, primary: false, onTap: () => setState(() => _emailEntryOpen = true), ), const SizedBox(height: 14), const Opacity(opacity: 0.75, child: AuthLegalLinks()), const Spacer(flex: 6), ], ), ); Future _openPhoneSheet() async { final signedIn = await showPhoneAuthSheet( context, signUp: _logic.signUpMode, ); if (signedIn == true && mounted) Navigator.of(context).maybePop(); } Widget _authCard(AppLocalizations l10n) => _AuthCard( state: _AuthCardState( l10n: l10n, status: _AuthFormStatus( signUpMode: _logic.signUpMode, busy: _logic.busy, errorMessage: _logic.errorMessage, fieldError: _logic.fieldError, obscurePassword: _obscurePassword, ), fields: _AuthFieldControllers( email: _emailController, password: _passwordController, emailFocus: _emailFocus, passwordFocus: _passwordFocus, ), ), callbacks: _AuthCardCallbacks( onModeSelected: _selectMode, onEmailChanged: (value) => setState(() { _logic.email = value; _logic.clearFieldError(); }), onPasswordChanged: (value) => setState(() { _logic.password = value; _logic.clearFieldError(); }), onTogglePasswordVisibility: () => setState(() => _obscurePassword = !_obscurePassword), onSubmit: _submit, ), ); void _selectMode(bool signUpMode) { if (_logic.busy || _logic.signUpMode == signUpMode) return; setState(() => _logic.toggleMode()); } } class _AuthEntryButton extends StatelessWidget { const _AuthEntryButton({ super.key, required this.label, required this.primary, required this.onTap, this.icon, }); final String label; final bool primary; final VoidCallback onTap; final IconData? icon; @override Widget build(BuildContext context) { // 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%),Apple 风格。 final background = primary ? AppColors.brandRed : Colors.white.withValues(alpha: 0.06); final foreground = primary ? AppColors.onPrimary : AppColors.onSurface; return Material( color: background, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_entryButtonRadius), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(_entryButtonRadius), child: SizedBox( height: _entryButtonHeight, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) Icon(icon, size: _entryIconSize, color: foreground), if (icon != null) const SizedBox(width: 8), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: foreground, fontSize: _entryButtonFontSize, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), ); } } /// 登录页全屏海报背景(单张,无拼缝)。 /// /// 优先用后台下发的 `loginBackgroundUrl`(后台未配置或加载失败时回落到本地默认图)。 class _AuthPosterBackdrop extends StatelessWidget { const _AuthPosterBackdrop(); static const String _defaultAsset = 'assets/auth/bg_3.jpg'; @override Widget build(BuildContext context) { final remoteUrl = context.watch().loginBackgroundUrl; return RemoteImage( key: const Key('auth-poster-backdrop'), remoteUrl, fit: BoxFit.cover, fallbackWidget: Image.asset( _defaultAsset, key: const Key('auth-poster-default'), fit: BoxFit.cover, alignment: Alignment.center, ), ); } }