475 lines
15 KiB
Dart
475 lines
15 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/theme/app_colors.dart';
|
|
import '../../core/theme/app_dimens.dart';
|
|
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;
|
|
// Auth keeps its approved 20px card silhouette.
|
|
const double _cardRadius = AppDimens.authCardRadius;
|
|
const double _cardPadding = AppDimens.cardPadding;
|
|
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 double _topContentPadding = 64;
|
|
const double _bottomContentPadding = 28;
|
|
const double _compactTopContentPadding = 48;
|
|
const double _compactHeightBreakpoint = 720;
|
|
const double _compactBrandGap = 16;
|
|
const double _warmGlowSize = 260;
|
|
const double _warmGlowOpacity = 0.06;
|
|
const Duration _enterDuration = Duration(milliseconds: 220);
|
|
const Duration _segmentDuration = Duration(milliseconds: 160);
|
|
|
|
/// 登录 / 注册页:邮箱 + 密码。
|
|
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;
|
|
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');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_emailFocus.dispose();
|
|
_passwordFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
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>();
|
|
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) {
|
|
if (!mounted) return;
|
|
setState(() => _logic.errorMessage = error.message);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => _logic.errorMessage = l10n.authNetworkError);
|
|
} finally {
|
|
if (mounted) setState(() => _logic.busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Theme(
|
|
data: AppTheme.dark(),
|
|
child: Scaffold(
|
|
resizeToAvoidBottomInset: true,
|
|
backgroundColor: AppColors.ink,
|
|
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],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 72,
|
|
right: -80,
|
|
width: _warmGlowSize,
|
|
height: _warmGlowSize,
|
|
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 +
|
|
MediaQuery.viewInsetsOf(context).bottom;
|
|
final available = constraints.maxHeight - top - bottom;
|
|
final minHeight = available > 0 ? available : 0.0;
|
|
return AutofillGroup(
|
|
child: SingleChildScrollView(
|
|
key: const Key('auth-scroll'),
|
|
keyboardDismissBehavior:
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
padding: EdgeInsets.fromLTRB(
|
|
AppDimens.pageHorizontal,
|
|
top,
|
|
AppDimens.pageHorizontal,
|
|
bottom,
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: minHeight),
|
|
child: Center(
|
|
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: 4, top: 4),
|
|
child: IconButton(
|
|
key: const Key('auth-back'),
|
|
tooltip: MaterialLocalizations.of(
|
|
context,
|
|
).backButtonTooltip,
|
|
onPressed: () {
|
|
if (_emailEntryOpen) {
|
|
setState(() => _emailEntryOpen = false);
|
|
} else {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
icon: const Icon(
|
|
Icons.arrow_back_rounded,
|
|
color: AppColors.onPrimary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
key: const Key('auth-brand-logo'),
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(18),
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [AppColors.blue, AppColors.secondary],
|
|
),
|
|
),
|
|
child: const Icon(
|
|
Icons.play_arrow_rounded,
|
|
color: AppColors.onPrimary,
|
|
size: 32,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
l10n.fruitShortsBrand,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'Outfit',
|
|
color: AppColors.onSurface,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
l10n.authLandingSubtitle,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
|
|
),
|
|
const SizedBox(height: 22),
|
|
_AuthEntryButton(
|
|
key: const Key('auth-entry-google'),
|
|
label: 'G ${l10n.authContinueGoogle}',
|
|
background: Colors.white,
|
|
foreground: const Color(0xFF1A1A1A),
|
|
leadingColor: const Color(0xFF4285F4),
|
|
onTap: () => _comingSoon(l10n),
|
|
),
|
|
const SizedBox(height: 11),
|
|
_AuthEntryButton(
|
|
key: const Key('auth-entry-phone'),
|
|
label: l10n.authPhoneSignIn,
|
|
icon: Icons.phone_android_rounded,
|
|
background: AppColors.input,
|
|
foreground: AppColors.onSurface,
|
|
onTap: () => _comingSoon(l10n),
|
|
),
|
|
const SizedBox(height: 11),
|
|
_AuthEntryButton(
|
|
key: const Key('auth-entry-email'),
|
|
label: l10n.authEmailSignIn,
|
|
icon: Icons.mail_outline_rounded,
|
|
background: AppColors.input,
|
|
foreground: AppColors.onSurface,
|
|
onTap: () => setState(() => _emailEntryOpen = true),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
l10n.authLegal,
|
|
key: const Key('auth-legal'),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: Color(0xFF6B6772),
|
|
fontSize: 9,
|
|
height: 1.6,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
void _comingSoon(AppLocalizations l10n) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.comingSoon)));
|
|
}
|
|
|
|
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.background,
|
|
required this.foreground,
|
|
required this.onTap,
|
|
this.icon,
|
|
this.leadingColor,
|
|
});
|
|
|
|
final String label;
|
|
final Color background;
|
|
final Color foreground;
|
|
final VoidCallback onTap;
|
|
final IconData? icon;
|
|
final Color? leadingColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(9),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
height: 44,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (icon != null)
|
|
Icon(icon, size: 16, color: leadingColor ?? foreground),
|
|
if (icon != null) const SizedBox(width: 7),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: foreground,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|