Files
Aq-Accounting-Flutter/lib/pages/login/login_page.dart
T
2026-09-21 17:37:53 +08:00

528 lines
16 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:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../api/auth_api.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/local_storage.dart';
import '../../providers/auth_provider.dart';
import '../../utils/crypto.dart';
import '../../utils/toast.dart';
import '../shell/main_shell.dart';
/// 登录/注册页(复刻 uniApp pages/login/login.vue)
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage>
with SingleTickerProviderStateMixin {
bool _isRegister = false;
bool _showPassword = false;
bool _loading = false;
bool _agreeProtocol = false;
bool _rememberMe = false;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _nicknameController = TextEditingController();
final _formKey = GlobalKey<FormState>();
late final AnimationController _floatController = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat(reverse: true);
@override
void initState() {
super.initState();
final saved = LocalStorage.instance.getRememberedAccount();
if (saved != null) {
_usernameController.text = saved.username;
_passwordController.text = decryptPassword(saved.password);
_rememberMe = true;
}
}
@override
void dispose() {
_floatController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_nicknameController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_loading) return;
final username = _usernameController.text.trim();
final password = _passwordController.text;
if (username.isEmpty || password.isEmpty) {
showToast(context, '请输入用户名和密码', error: true);
return;
}
if (_isRegister) {
if (password.length < 6) {
showToast(context, '密码至少6位', error: true);
return;
}
if (!_agreeProtocol) {
showToast(context, '请先阅读并同意用户协议');
return;
}
}
setState(() => _loading = true);
try {
final api = ref.read(authApiProvider);
final response = _isRegister
? await api.register(
username,
password,
_nicknameController.text.trim().isEmpty
? username
: _nicknameController.text.trim(),
)
: await api.login(username, password);
// 记住密码
if (!_isRegister && _rememberMe) {
await LocalStorage.instance.saveRememberedPassword(
username,
encryptPassword(password),
);
} else if (!_isRegister && !_rememberMe) {
await LocalStorage.instance.clearRememberedPassword();
}
await ref.read(authProvider.notifier).loginSuccess(response);
if (!mounted) return;
showToast(context, _isRegister ? '🎉 注册成功' : '🚀 登录成功');
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const MainShell()),
(route) => false,
);
} on ApiException catch (e) {
if (mounted) showToast(context, e.message, error: true);
} catch (_) {
if (mounted) showToast(context, '操作失败,请重试', error: true);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: Stack(
children: [
..._buildBackgroundBalls(),
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
const SizedBox(height: 32),
_buildFormCard(),
],
),
),
),
),
],
),
),
);
}
// ---- 背景装饰圆球 ----
List<Widget> _buildBackgroundBalls() {
final t = _floatController.value;
Offset offset(double phase) =>
Offset(20 * _wave(t + phase), 30 * _wave(t + phase + 0.25));
Widget ball({
required double size,
required List<Color> colors,
required Offset pos,
required double phase,
}) {
return Positioned(
left: pos.dx,
top: pos.dy,
child: Opacity(
opacity: 0.3,
child: AnimatedBuilder(
animation: _floatController,
builder: (context, _) => Transform.translate(
offset: offset(phase),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: colors),
),
),
),
),
),
);
}
return [
ball(
size: 280,
colors: const [Color(0xFFC8956E), Color(0xFFFFB6C1)],
pos: const Offset(-70, -70),
phase: 0,
),
ball(
size: 200,
colors: const [Color(0xFFF5D59E), Color(0xFFFFD700)],
pos: const Offset(-40, 180),
phase: 0.25,
),
ball(
size: 240,
colors: const [Color(0xFFFFB6C1), Color(0xFFC8956E)],
pos: const Offset(40, -180),
phase: 0.5,
),
ball(
size: 170,
colors: const [Color(0xFFFFD700), Color(0xFFF5D59E)],
pos: const Offset(240, 420),
phase: 0.75,
),
];
}
double _wave(double x) {
final v = (x % 1) * 2;
return v <= 1 ? v : 2 - v;
}
// ---- Logo + 标题 ----
Widget _buildHeader() {
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.all(10),
child: Image.asset('assets/images/logo.png', fit: BoxFit.contain),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_decoLine(),
const SizedBox(width: 10),
const Text(
'强宝爱记账',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
letterSpacing: 2,
shadows: [
Shadow(
color: Color(0x66FFD700),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
),
const SizedBox(width: 10),
_decoLine(),
],
),
const SizedBox(height: 6),
const Text(
'智能记账,轻松管理',
style: TextStyle(fontSize: 14, color: Color(0xFF8D6E63)),
),
],
);
}
Widget _decoLine() => Container(
width: 40,
height: 4,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFFFD700)],
),
),
);
// ---- 表单卡片(毛玻璃) ----
Widget _buildFormCard() {
return ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: _glassFilter,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFC8956E), width: 1.5),
),
child: Form(
key: _formKey,
child: Column(
children: [
_buildSegmentTabs(),
const SizedBox(height: 20),
_inputField(
icon: '👤',
controller: _usernameController,
hint: '请输入用户名',
maxLength: 20,
),
const SizedBox(height: 14),
_inputField(
icon: '🔒',
controller: _passwordController,
hint: '请输入密码',
maxLength: 20,
obscure: !_showPassword,
suffix: IconButton(
icon: Text(
_showPassword ? '👁️' : '👁️‍🗨️',
style: const TextStyle(fontSize: 16),
),
onPressed: () =>
setState(() => _showPassword = !_showPassword),
),
),
if (_isRegister) ...[
const SizedBox(height: 14),
_inputField(
icon: '✨',
controller: _nicknameController,
hint: '请输入昵称(可选)',
maxLength: 20,
),
],
if (!_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _rememberMe,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _rememberMe = v ?? false),
),
),
const SizedBox(width: 6),
const Text(
'记住密码',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
],
),
],
if (_isRegister) ...[
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 22,
height: 22,
child: Checkbox(
value: _agreeProtocol,
activeColor: const Color(0xFFC8956E),
onChanged: (v) =>
setState(() => _agreeProtocol = v ?? false),
),
),
const Expanded(
child: Text(
'我已阅读并同意用户协议',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8D6E63),
),
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(
_isRegister ? '注 册' : '登 录',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: 4,
),
),
),
),
],
),
),
),
),
);
}
static final ImageFilter _glassFilter = ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
);
// ---- 登录/注册切换 ----
Widget _buildSegmentTabs() {
return Container(
decoration: BoxDecoration(
color: const Color(0x33C8956E),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
_segment(
'登录',
!_isRegister,
() => setState(() => _isRegister = false),
),
_segment('注册', _isRegister, () => setState(() => _isRegister = true)),
],
),
);
}
Widget _segment(String text, bool active, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: active ? const Color(0xFFC8956E) : Colors.transparent,
),
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: active ? Colors.white : const Color(0xFF8D6E63),
),
),
),
),
),
);
}
Widget _inputField({
required String icon,
required TextEditingController controller,
required String hint,
int maxLength = 50,
bool obscure = false,
Widget? suffix,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE0C9A6)),
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
obscureText: obscure,
maxLength: maxLength,
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
color: Color(0xFFBCAAA4),
fontSize: 14,
),
border: InputBorder.none,
counterText: '',
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
?suffix,
],
),
);
}
}