523 lines
18 KiB
Dart
523 lines
18 KiB
Dart
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../../core/exceptions/app_exception.dart';
|
|
import '../../../core/validators/string_validator.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../../services/server_service.dart';
|
|
import '../../../router/app_router.dart';
|
|
import 'forgot_password_page.dart';
|
|
import 'register_page.dart';
|
|
import '../../widgets/toast_helper.dart';
|
|
|
|
class LoginPage extends StatefulWidget {
|
|
const LoginPage({super.key});
|
|
|
|
@override
|
|
State<LoginPage> createState() => _LoginPageState();
|
|
}
|
|
|
|
class _LoginPageState extends State<LoginPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
final _focusNode = FocusNode();
|
|
bool _obscurePassword = true;
|
|
bool _rememberMe = false;
|
|
bool _isLoading = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadRememberedInfo();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadRememberedInfo() async {
|
|
final server = ServerService.instance.currentServer;
|
|
if (server != null) {
|
|
setState(() {
|
|
if (server.email != null) {
|
|
_emailController.text = server.email!;
|
|
}
|
|
if (server.password != null && server.rememberMe) {
|
|
_passwordController.text = server.password!;
|
|
}
|
|
_rememberMe = server.rememberMe;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _login() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
final navigator = Navigator.of(context);
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final success = await authProvider.passwordLogin(
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text,
|
|
rememberMe: _rememberMe,
|
|
).timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () => throw Exception('请求超时'),
|
|
);
|
|
|
|
if (mounted) setState(() => _isLoading = false);
|
|
|
|
if (success && mounted) {
|
|
_focusNode.unfocus();
|
|
ToastHelper.success('登录成功');
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
if (mounted) navigator.pushReplacementNamed(RouteNames.home);
|
|
} else if (mounted) {
|
|
final errorMessage = authProvider.errorMessage;
|
|
if (errorMessage != null && errorMessage.isNotEmpty) {
|
|
final errorMsg = _parseErrorMessage(errorMessage);
|
|
ToastHelper.failure(errorMsg);
|
|
} else {
|
|
ToastHelper.failure('登录失败');
|
|
}
|
|
}
|
|
} on TwoFactorRequiredException catch (e) {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
_showTwoFactorDialog(e.sessionId);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
final errorMsg = _parseErrorMessage(e.toString());
|
|
ToastHelper.failure(errorMsg);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showTwoFactorDialog(String sessionId) async {
|
|
final success = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => _TwoFactorDialog(
|
|
sessionId: sessionId,
|
|
email: _emailController.text.trim(),
|
|
password: _passwordController.text,
|
|
rememberMe: _rememberMe,
|
|
),
|
|
);
|
|
|
|
if (success != true || !mounted) return;
|
|
|
|
_focusNode.unfocus();
|
|
ToastHelper.success('登录成功');
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
if (mounted) Navigator.of(context).pushReplacementNamed(RouteNames.home);
|
|
}
|
|
|
|
String _parseErrorMessage(String error) {
|
|
if (error.startsWith('Exception(') || error.startsWith('AppException(')) {
|
|
final startIdx = error.indexOf('(');
|
|
final endIdx = error.lastIndexOf(')');
|
|
if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) {
|
|
return error.substring(startIdx + 1, endIdx).trim();
|
|
}
|
|
}
|
|
if (error.contains(':')) {
|
|
final parts = error.split(':');
|
|
if (parts.length > 1) {
|
|
final msg = parts.sublist(1).join(':').trim();
|
|
if (msg.isNotEmpty) return '登录失败: $msg';
|
|
}
|
|
}
|
|
if (error.contains('"') && error.split('"').length >= 2) {
|
|
final parts = error.split('"');
|
|
if (parts.length >= 2) {
|
|
final msg = parts[1].trim();
|
|
if (msg.isNotEmpty && msg != 'login') return '登录失败: $msg';
|
|
}
|
|
}
|
|
return error.isEmpty ? '登录失败: 未知原因' : '登录失败: $error';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: DesktopConstrained(
|
|
maxContentWidth: 480,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(child: _buildLogo()),
|
|
const SizedBox(height: 32),
|
|
Center(
|
|
child: Text(
|
|
'公云存储',
|
|
style: theme.textTheme.headlineMedium,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
Card(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 邮箱
|
|
TextFormField(
|
|
controller: _emailController,
|
|
keyboardType: TextInputType.emailAddress,
|
|
textInputAction: TextInputAction.next,
|
|
validator: StringValidator.validateEmail,
|
|
decoration: const InputDecoration(
|
|
labelText: '邮箱',
|
|
hintText: '请输入邮箱地址',
|
|
prefixIcon: Icon(LucideIcons.mail),
|
|
),
|
|
onFieldSubmitted: (_) => _focusNode.requestFocus(),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// 密码
|
|
TextFormField(
|
|
controller: _passwordController,
|
|
focusNode: _focusNode,
|
|
obscureText: _obscurePassword,
|
|
validator: StringValidator.validatePassword,
|
|
decoration: InputDecoration(
|
|
labelText: '密码',
|
|
hintText: '请输入密码',
|
|
prefixIcon: const Icon(LucideIcons.lock),
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscurePassword
|
|
? LucideIcons.eye
|
|
: LucideIcons.eyeOff,
|
|
size: 20,
|
|
),
|
|
onPressed: () {
|
|
setState(() => _obscurePassword = !_obscurePassword);
|
|
},
|
|
),
|
|
),
|
|
onFieldSubmitted: (_) => _login(),
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// 记住我
|
|
InkWell(
|
|
onTap: () => setState(() => _rememberMe = !_rememberMe),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: Checkbox(
|
|
value: _rememberMe,
|
|
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text('记住我'),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// 链接按钮
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => const ForgotPasswordPage(),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('忘记密码?'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => const RegisterPage(),
|
|
),
|
|
);
|
|
},
|
|
child: const Text('注册账号'),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// 登录按钮
|
|
FilledButton(
|
|
onPressed: _isLoading ? null : _login,
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size(double.infinity, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('登录'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLogo() {
|
|
return ClipOval(
|
|
child: Image.asset(
|
|
'assets/images/app_logo.png',
|
|
width: 96,
|
|
height: 96,
|
|
fit: BoxFit.cover,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 两步验证输入对话框
|
|
class _TwoFactorDialog extends StatefulWidget {
|
|
final String sessionId;
|
|
final String email;
|
|
final String password;
|
|
final bool rememberMe;
|
|
|
|
const _TwoFactorDialog({
|
|
required this.sessionId,
|
|
required this.email,
|
|
required this.password,
|
|
required this.rememberMe,
|
|
});
|
|
|
|
@override
|
|
State<_TwoFactorDialog> createState() => _TwoFactorDialogState();
|
|
}
|
|
|
|
class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
|
with SingleTickerProviderStateMixin {
|
|
final _controller = TextEditingController();
|
|
final _focusNode = FocusNode();
|
|
bool _isSubmitting = false;
|
|
late final AnimationController _shakeController;
|
|
late final Animation<double> _shakeAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_shakeController = AnimationController(
|
|
duration: const Duration(milliseconds: 400),
|
|
vsync: this,
|
|
);
|
|
_shakeAnimation = TweenSequence<double>([
|
|
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
|
|
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
|
|
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
|
|
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
|
|
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
|
|
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
|
|
]).animate(CurvedAnimation(
|
|
parent: _shakeController,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
_controller.addListener(_onTextChanged);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_focusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_onTextChanged);
|
|
_controller.dispose();
|
|
_focusNode.dispose();
|
|
_shakeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onTextChanged() {
|
|
if (_controller.text.length == 6 && !_isSubmitting) {
|
|
_submit();
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
final code = _controller.text.trim();
|
|
if (code.length != 6 || int.tryParse(code) == null) return;
|
|
|
|
setState(() => _isSubmitting = true);
|
|
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
try {
|
|
final success = await authProvider.twoFactorLogin(
|
|
otp: code,
|
|
sessionId: widget.sessionId,
|
|
email: widget.email,
|
|
password: widget.password,
|
|
rememberMe: widget.rememberMe,
|
|
).timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () => throw Exception('请求超时'),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (success) {
|
|
Navigator.of(context).pop(true);
|
|
} else {
|
|
_onVerifyFailed(authProvider.errorMessage ?? '验证码错误');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) _onVerifyFailed(_parse2FAError(e.toString()));
|
|
}
|
|
}
|
|
|
|
void _onVerifyFailed(String message) {
|
|
setState(() => _isSubmitting = false);
|
|
_controller.clear();
|
|
_focusNode.requestFocus();
|
|
_shakeController.forward(from: 0);
|
|
ToastHelper.failure(message);
|
|
}
|
|
|
|
String _parse2FAError(String error) {
|
|
if (error.startsWith('Exception(') || error.startsWith('AppException(')) {
|
|
final startIdx = error.indexOf('(');
|
|
final endIdx = error.lastIndexOf(')');
|
|
if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) {
|
|
return error.substring(startIdx + 1, endIdx).trim();
|
|
}
|
|
}
|
|
return error.isEmpty ? '验证码错误' : error;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
Icon(LucideIcons.shieldCheck, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
const Text('两步验证'),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'请输入身份验证器中的6位数字验证码',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
AnimatedBuilder(
|
|
animation: _shakeAnimation,
|
|
builder: (context, child) {
|
|
return Transform.translate(
|
|
offset: Offset(_shakeAnimation.value, 0),
|
|
child: child,
|
|
);
|
|
},
|
|
child: TextField(
|
|
controller: _controller,
|
|
focusNode: _focusNode,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 6,
|
|
textAlign: TextAlign.center,
|
|
enabled: !_isSubmitting,
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 8,
|
|
),
|
|
decoration: InputDecoration(
|
|
counterText: '',
|
|
hintText: '------',
|
|
hintStyle: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 8,
|
|
color: theme.colorScheme.outline.withValues(alpha: 0.4),
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
],
|
|
onSubmitted: (_) => _submit(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
|
child: const Text('取消'),
|
|
),
|
|
FilledButton(
|
|
onPressed: _isSubmitting ? null : _submit,
|
|
child: _isSubmitting
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('验证'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|