593 lines
19 KiB
Dart
593 lines
19 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/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_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 _brandIconSize = 36;
|
|
const double _compactBrandIconSize = 26;
|
|
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,
|
|
required double iconSize,
|
|
}) {
|
|
return Container(
|
|
key: key,
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(radius),
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [AppColors.blue, AppColors.secondary],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.blue.withValues(alpha: 0.24),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
Icons.play_arrow_rounded,
|
|
color: AppColors.onPrimary,
|
|
size: iconSize,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 登录 / 注册页:邮箱 + 密码。
|
|
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 = 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: [
|
|
Center(
|
|
child: _brandMarkWidget(
|
|
key: const Key('auth-brand-logo'),
|
|
size: _brandMarkSize,
|
|
radius: _brandMarkRadius,
|
|
iconSize: _brandIconSize,
|
|
),
|
|
),
|
|
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),
|
|
Text(
|
|
l10n.authLegal,
|
|
key: const Key('auth-legal'),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: 9,
|
|
height: 1.6,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|