260 lines
8.6 KiB
Dart
260 lines
8.6 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_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 {
|
|
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;
|
|
|
|
@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 {
|
|
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) {
|
|
setState(() => _logic.errorMessage = error.message);
|
|
} catch (_) {
|
|
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],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
|
|
void _selectMode(bool signUpMode) {
|
|
if (_logic.busy || _logic.signUpMode == signUpMode) return;
|
|
setState(() => _logic.toggleMode());
|
|
}
|
|
}
|