Files
ReelShort/lib/pages/auth/auth_view.dart

570 lines
18 KiB
Dart

// 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/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/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 = 72;
const double _compactBrandMarkSize = 48;
const double _brandMarkRadius = 20;
const double _compactBrandMarkRadius = 14;
const double _landingLogoTopGap = 28;
const String _appLogoAsset = 'assets/icon/fruitshorts.png';
const double _contentMaxWidth = 440;
// Auth keeps its approved 20px card silhouette.
const double _cardRadius = AppDimens.authCardRadius;
const double _cardPadding = 20;
const double _fieldHeight = 52;
const double _fieldRadius = 14;
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 _glowSize = 280;
const double _warmGlowOpacity = 0.18;
const double _entryButtonHeight = 50;
const double _entryButtonRadius = 25;
const double _entryButtonFontSize = 15;
const double _entryIconSize = 20;
const Duration _enterDuration = Duration(milliseconds: 220);
const Duration _segmentDuration = Duration(milliseconds: 160);
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<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
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<void> _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<Haptics>().lightImpact());
FocusScope.of(context).unfocus();
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
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: AppTheme.pageGradient),
),
),
),
Positioned(
top: -90,
right: -70,
width: _glowSize,
height: _glowSize,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-glow'),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.surfaceTop,
AppColors.surfaceTop.withValues(alpha: 0),
],
),
),
),
),
),
Positioned(
left: -110,
bottom: -90,
width: _glowSize,
height: _glowSize,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-warm-glow'),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.gold.withValues(alpha: _warmGlowOpacity),
AppColors.gold.withValues(alpha: 0),
],
),
),
),
),
),
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),
),
),
),
),
),
);
},
),
),
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}) =>
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: _emailEntryOpen
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
SizedBox(height: compact ? _compactBrandGap : 28),
_authCard(l10n),
],
)
: _landing(l10n),
);
Widget _landing(AppLocalizations l10n) => Column(
key: const Key('auth-landing'),
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: _landingLogoTopGap),
Center(
child: _brandMarkWidget(
key: const Key('auth-brand-logo'),
size: _brandMarkSize,
radius: _brandMarkRadius,
),
),
const SizedBox(height: 16),
Text(
l10n.fruitShortsBrand,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 24,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
Text(
l10n.authLandingSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 28),
_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: 18),
const AuthLegalLinks(),
],
);
Future<void> _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) {
final background = primary ? AppColors.blue : AppColors.card;
final foreground = primary ? AppColors.onPrimary : AppColors.onSurface;
return Material(
color: background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_entryButtonRadius),
side: primary
? BorderSide.none
: const BorderSide(color: AppColors.border),
),
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.w800,
),
),
),
],
),
),
),
);
}
}