Files
Aq-Accounting-Flutter/lib/pages/login/login_page.dart
T
2026-09-24 14:24:34 +08:00

432 lines
13 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';
import '../../widgets/cute_icon.dart';
/// 登录页(复刻 uniApp pages/login/login.vue)
///
/// **注册功能已下线**(2026-09-24):这是单人应用,开放注册等于任何人都能建号。
/// App 端的入口和界面都去掉了;后端 `/api/auth/register` 按用户要求保留着,
/// 真要加人时把入口加回来即可。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage>
with SingleTickerProviderStateMixin {
bool _showPassword = false;
bool _loading = false;
bool _rememberMe = false;
final _usernameController = TextEditingController();
final _passwordController = 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();
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;
}
setState(() => _loading = true);
try {
final response = await ref.read(authApiProvider).login(username, password);
// 记住密码
if (_rememberMe) {
await LocalStorage.instance.saveRememberedPassword(
username,
encryptPassword(password),
);
} else {
await LocalStorage.instance.clearRememberedPassword();
}
await ref.read(authProvider.notifier).loginSuccess(response);
if (!mounted) return;
showToast(context, '登录成功', icon: 'check');
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),
),
],
),
// 圆形容器默认不裁剪子组件,而方图的对角线比圆直径长 ——
// 换成不透明底的方图会从四个角戳出来,这里裁掉。
clipBehavior: Clip.antiAlias,
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: [
_inputField(
icon: 'user',
controller: _usernameController,
hint: '请输入用户名',
maxLength: 20,
),
const SizedBox(height: 14),
_inputField(
icon: 'lock',
controller: _passwordController,
hint: '请输入密码',
maxLength: 20,
obscure: !_showPassword,
suffix: IconButton(
// 一个图标用颜色表示状态:显示中=品牌色,隐藏中=浅灰
icon: CuteIcon(
'eye',
size: 18,
tint: _showPassword
? const Color(0xFFC8956E)
: const Color(0xFFBCAAA4),
),
onPressed: () =>
setState(() => _showPassword = !_showPassword),
),
),
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),
),
),
],
),
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,
),
)
: const Text(
'登 录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
letterSpacing: 4,
),
),
),
),
],
),
),
),
),
);
}
static final ImageFilter _glassFilter = ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
);
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: [
CuteIcon(icon, size: 20),
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,
],
),
);
}
}