Files
CloudHome-Ops/lib/screens/login/login_page.dart

510 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../app/theme.dart';
import '../../services/auth_service.dart';
import '../../utils/constants.dart';
import '../../utils/credential_storage.dart';
import '../../utils/i10n.dart';
import '../../config/app_config.dart';
import '../../widgets/cloud_card.dart';
/// 登录页。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _accountController = TextEditingController();
final _passwordController = TextEditingController();
final _captchaController = TextEditingController();
bool _obscurePassword = true;
/// 是否记住密码。
bool _rememberPassword = false;
/// 当前验证码图片数据。
Uint8List? _captchaBytes;
/// 当前验证码 UUID,登录时必须原样回传。
String _captchaUuid = '';
/// 是否正在加载验证码。
bool _captchaLoading = false;
/// 验证码加载/刷新错误提示。
String? _captchaError;
@override
void initState() {
super.initState();
_safeLoadSavedCredentials();
if (AppConfig.enableCaptcha) {
_safeRefreshCaptcha();
}
}
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
///
/// 包一层 try-catch:release 模式下任何同步/异步异常都不应让登录页崩成灰屏。
void _safeLoadSavedCredentials() {
try {
_loadSavedCredentials();
} catch (_) {
// 读取本地凭据失败时使用空表单,不阻塞登录。
}
}
/// 验证码加载包一层 try-catch,失败时通过 _refreshCaptcha 内部的 catch 兜底。
void _safeRefreshCaptcha() {
try {
_refreshCaptcha();
} catch (_) {
// 极端情况下同步抛错,确保页面仍可渲染。
if (mounted) {
setState(() {
_captchaError = '验证码加载失败,请重试';
});
}
}
}
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
void _loadSavedCredentials() {
final cs = CredentialStorage.instance;
if (cs.rememberPassword) {
_rememberPassword = true;
_accountController.text = cs.savedAccount ?? '';
_passwordController.text = cs.savedPassword ?? '';
}
}
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
_captchaController.dispose();
super.dispose();
}
Future<void> _refreshCaptcha() async {
if (!mounted) return;
setState(() {
_captchaLoading = true;
_captchaError = null;
});
try {
final captcha = await AuthService().fetchCaptcha();
if (!mounted) return;
setState(() {
_captchaUuid = captcha.uuid;
_captchaBytes = Uint8List.fromList(captcha.imageBytes);
_captchaLoading = false;
});
} on AuthException catch (e) {
if (!mounted) return;
setState(() {
_captchaError = e.message;
_captchaLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_captchaError = '验证码加载失败,点击重试';
_captchaLoading = false;
});
}
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
final success = await ref.read(authProvider.notifier).login(
account: _accountController.text.trim(),
password: _passwordController.text,
captcha: _captchaController.text.trim(),
uuid: _captchaUuid,
rememberPassword: _rememberPassword,
);
if (!success && mounted && AppConfig.enableCaptcha) {
// 登录失败后刷新验证码,并清空输入。
_captchaController.clear();
await _refreshCaptcha();
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final authState = ref.watch(authProvider);
// 跟随主题:原有硬编码深色是为了规避 M3 动态取色;现改为显式 ColorScheme,
// 直接取 token,浅色模式即为暖白品牌主场(与 logo 橙白一致)。
final scheme = Theme.of(context).colorScheme;
final inputFillColor = scheme.surfaceContainerHighest;
final inputBorderColor = scheme.outline;
final inputTextColor = scheme.onSurface;
final inputLabelColor = scheme.onSurfaceVariant;
final cardColor = scheme.surfaceContainerLow;
return Scaffold(
backgroundColor: scheme.surface,
body: Container(
decoration: BoxDecoration(
// 浅色模式铺 logo 橙→金渐变(品牌渗透);深色模式保持暖黑 surface。
gradient: scheme.brightness == Brightness.light
? AppBrand.cloudGradient
: null,
color: scheme.brightness == Brightness.light ? null : scheme.surface,
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
color: cardColor,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
'assets/images/logo.png',
width: 112,
height: 112,
),
const SizedBox(height: 16),
CloudCard(
child: Row(
children: [
const Icon(Icons.cloud, size: 36),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppConstants.appName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
t.appSubtitle,
style: const TextStyle(fontSize: 13),
),
],
),
),
],
),
),
const SizedBox(height: 32),
TextFormField(
controller: _accountController,
style: TextStyle(color: inputTextColor),
decoration: InputDecoration(
labelText: '账号',
labelStyle: TextStyle(color: inputLabelColor),
prefixIconColor: inputLabelColor,
filled: true,
fillColor: inputFillColor,
border: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: scheme.primary),
),
prefixIcon: const Icon(Icons.person_outline),
),
validator: (value) =>
(value == null || value.trim().isEmpty)
? '请输入账号'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: TextStyle(color: inputTextColor),
decoration: InputDecoration(
labelText: '密码',
labelStyle: TextStyle(color: inputLabelColor),
prefixIconColor: inputLabelColor,
suffixIconColor: inputLabelColor,
filled: true,
fillColor: inputFillColor,
border: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: inputBorderColor),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: scheme.primary),
),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
color: inputLabelColor,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
),
validator: (value) =>
(value == null || value.isEmpty) ? '请输入密码' : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
if (AppConfig.enableCaptcha) ...[
const SizedBox(height: 16),
// 验证码区域
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 5,
child: TextFormField(
controller: _captchaController,
style: TextStyle(color: inputTextColor),
decoration: InputDecoration(
labelText: '验证码',
labelStyle:
TextStyle(color: inputLabelColor),
prefixIconColor: inputLabelColor,
filled: true,
fillColor: inputFillColor,
border: OutlineInputBorder(
borderSide:
BorderSide(color: inputBorderColor),
),
enabledBorder: OutlineInputBorder(
borderSide:
BorderSide(color: inputBorderColor),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: scheme.primary),
),
prefixIcon: const Icon(Icons.security_outlined),
),
textInputAction: TextInputAction.done,
validator: (value) =>
(value == null || value.trim().isEmpty)
? '请输入验证码'
: null,
onFieldSubmitted: (_) => _submit(),
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: SizedBox(
height: 52,
child: InkWell(
onTap: _captchaLoading
? null
: _refreshCaptcha,
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
color: inputFillColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: inputBorderColor,
),
),
alignment: Alignment.center,
child: _buildCaptchaImage(),
),
),
),
),
],
),
if (_captchaError != null) ...[
const SizedBox(height: 8),
Text(
_captchaError!,
style: TextStyle(
color: scheme.error,
fontSize: 12,
),
),
],
],
if (authState.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
authState.errorMessage!,
style: TextStyle(
color: scheme.error,
),
),
],
const SizedBox(height: 12),
// 记住密码
Row(
children: [
Checkbox(
value: _rememberPassword,
onChanged: (v) => setState(
() => _rememberPassword = v ?? false,
),
fillColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? scheme.primary
: inputBorderColor,
),
checkColor: scheme.onPrimary,
),
GestureDetector(
onTap: () => setState(
() => _rememberPassword = !_rememberPassword,
),
child: Text(
'记住密码',
style: TextStyle(color: inputTextColor),
),
),
],
),
const SizedBox(height: 12),
FilledButton(
onPressed: authState.isLoading ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
disabledBackgroundColor:
scheme.onSurface.withOpacity(0.12),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: authState.isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onPrimary,
),
)
: const Text('登 录'),
),
const SizedBox(height: 16),
// 仅 mock 模式显示演示账号;真实模式下提示无意义
if (AppConstants.useMock)
Text(
'演示账号:${AppConstants.mockAccount} / '
'${AppConstants.mockPassword}',
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 8),
Text(
'v6-fix68',
textAlign: TextAlign.center,
style: TextStyle(
color: scheme.onSurfaceVariant.withOpacity(0.5),
fontSize: 10),
),
],
),
),
),
),
),
),
),
),
),
);
}
Widget _buildCaptchaImage() {
if (_captchaLoading) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
final bytes = _captchaBytes;
if (bytes != null && bytes.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
bytes,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
size: 20,
color: Color(0xFFB0B0B0),
),
SizedBox(height: 2),
Text(
'点击刷新',
style: TextStyle(
fontSize: 10,
color: Color(0xFFB0B0B0),
),
),
],
);
},
),
);
}
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.refresh,
size: 20,
color: Color(0xFFB0B0B0),
),
SizedBox(height: 2),
Text(
'点击刷新',
style: TextStyle(
fontSize: 10,
color: Color(0xFFB0B0B0),
),
),
],
);
}
}