启动动画和登录页面的动画

This commit is contained in:
2026-09-23 16:48:48 +08:00
parent 32397f8899
commit d230279f4f
3 changed files with 229 additions and 115 deletions

View File

@@ -30,13 +30,19 @@ GoRouter createRouter(AuthCubit authCubit) {
return GoRouter( return GoRouter(
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
initialLocation: RoutePaths.login, initialLocation: RoutePaths.splash,
refreshListenable: GoRouterRefreshStream(authCubit.stream), refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) { redirect: (context, state) {
final loggedIn = authCubit.state is AuthAuthenticated; final loggedIn = authCubit.state is AuthAuthenticated;
final onSplash = state.matchedLocation == RoutePaths.splash;
final loggingIn = state.matchedLocation == RoutePaths.login; final loggingIn = state.matchedLocation == RoutePaths.login;
final registering = state.matchedLocation == RoutePaths.register; final registering = state.matchedLocation == RoutePaths.register;
// 启动屏:交给 SplashPage 自己根据就绪信号跳转,路由层不干预
if (onSplash) {
return null;
}
// 如果未登录,只允许访问 login 和 register 页面 // 如果未登录,只允许访问 login 和 register 页面
if (!loggedIn && !loggingIn && !registering) { if (!loggedIn && !loggingIn && !registering) {
return RoutePaths.login; return RoutePaths.login;

View File

@@ -18,19 +18,47 @@ class LoginPage extends StatefulWidget {
State<LoginPage> createState() => _LoginPageState(); State<LoginPage> createState() => _LoginPageState();
} }
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
final _userCtrl = TextEditingController(); final _userCtrl = TextEditingController();
final _pwdCtrl = TextEditingController(); final _pwdCtrl = TextEditingController();
bool _isAgreed = false; // 协议勾选状态 bool _isAgreed = false; // 协议勾选状态
bool _obscurePwd = true; // 密码可见性 bool _obscurePwd = true; // 密码可见性
// 进场动画控制器
late final AnimationController _entranceController;
@override
void initState() {
super.initState();
_entranceController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
// 等首帧渲染 + 图片解码完成后再播放,避开启动初始化争抢导致的掉帧
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) _entranceController.forward();
});
});
}
@override @override
void dispose() { void dispose() {
_entranceController.dispose();
_userCtrl.dispose(); _userCtrl.dispose();
_pwdCtrl.dispose(); _pwdCtrl.dispose();
super.dispose(); super.dispose();
} }
/// 生成一个错峰区间的动画(0.0 ~ 1.0)
Animation<double> _interval(double begin, double end) {
return CurvedAnimation(
parent: _entranceController,
curve: Interval(begin, end, curve: Curves.easeOut),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -64,130 +92,94 @@ class _LoginPageState extends State<LoginPage> {
// 顶部区域:图片 + 认证信息 + 标题(压缩空间) // 顶部区域:图片 + 认证信息 + 标题(压缩空间)
const SizedBox(height: 10), const SizedBox(height: 10),
// 顶部居中图片和认证信息 // ① 顶部居中图片和认证信息(缩放淡入)
Center( _Entrance(
child: Column( animation: _interval(0.00, 0.35),
children: [ scale: true,
Image.asset( child: Center(
'assets/images/huawei.png', child: Column(
width: 80, children: [
height: 80, Image.asset(
fit: BoxFit.contain, 'assets/images/huawei.png',
), width: 80,
const SizedBox(height: 6), height: 80,
Padding( fit: BoxFit.contain,
padding: const EdgeInsets.symmetric(horizontal: 20.0), ),
child: Text( const SizedBox(height: 6),
"通过华为云生态技术认证,请放心使用,证书编号C202605301", Padding(
textAlign: TextAlign.center, padding: const EdgeInsets.symmetric(horizontal: 20.0),
style: TextStyle( child: Text(
fontSize: 10, "通过华为云生态技术认证,请放心使用,证书编号C202605301",
color: context.appColors.textTertiary, textAlign: TextAlign.center,
height: 1.3, style: TextStyle(
fontSize: 10,
color: context.appColors.textTertiary,
height: 1.3,
),
), ),
), ),
), ],
],
),
),
const SizedBox(height: 16),
Text(
"账号登录",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
),
const SizedBox(height: 8),
Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
const SizedBox(height: 24),
// 表单区域
// 账号输入框
_buildInputLabel("账号"),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请输入用户名"),
),
const SizedBox(height: 20),
// 密码输入框
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请输入密码",
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 协议勾选区域 // ② 标题区(上滑淡入)
_buildProtocolSection(), _Entrance(
animation: _interval(0.10, 0.45),
const SizedBox(height: 30), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// 底部区域:登录按钮 + 注册链接
SizedBox(
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
bool isLoading = state is LoginLoading;
return CCPrimaryButton(
onPressed: isLoading
? null
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
);
},
),
),
const SizedBox(height: 16),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
"没有账号吗?", "账号登录",
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary), style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
), ),
InkWell( const SizedBox(height: 8),
onTap: () { Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
debugPrint(' 点击了立即注册'); ],
debugPrint('👉 跳转路径: ${RoutePaths.register}'); ),
context.go(RoutePaths.register); ),
},
borderRadius: BorderRadius.circular(4), const SizedBox(height: 24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // ③ 表单区域
child: Text( // 账号输入框(上滑淡入)
"立即注册", _Entrance(
style: TextStyle( animation: _interval(0.20, 0.55),
fontSize: 14, child: Column(
color: context.appColors.textPrimary, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, children: [
), _buildInputLabel("账号"),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请输入用户名"),
),
],
),
),
const SizedBox(height: 20),
// 密码输入框(上滑淡入)
_Entrance(
animation: _interval(0.28, 0.63),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请输入密码",
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
), ),
), ),
), ),
@@ -195,6 +187,83 @@ class _LoginPageState extends State<LoginPage> {
), ),
), ),
const SizedBox(height: 16),
// ④ 协议勾选区域(上滑淡入)
_Entrance(
animation: _interval(0.36, 0.71),
child: _buildProtocolSection(),
),
const SizedBox(height: 30),
// ⑤ 底部区域:登录按钮(上滑淡入)
_Entrance(
animation: _interval(0.45, 0.82),
child: SizedBox(
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
bool isLoading = state is LoginLoading;
return CCPrimaryButton(
onPressed: isLoading
? null
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
);
},
),
),
),
const SizedBox(height: 16),
// 注册链接(上滑淡入,最后登场)
_Entrance(
animation: _interval(0.55, 0.95),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"没有账号吗?",
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
),
InkWell(
onTap: () {
debugPrint(' 点击了立即注册');
debugPrint('👉 跳转路径: ${RoutePaths.register}');
context.go(RoutePaths.register);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
"立即注册",
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
), ),
@@ -320,3 +389,37 @@ class _LoginPageState extends State<LoginPage> {
); );
} }
} }
/// 进场动画包装组件:淡入 + 上滑(默认)或淡入 + 缩放(scale=true)
class _Entrance extends StatelessWidget {
const _Entrance({
required this.animation,
required this.child,
this.scale = false,
this.slideOffset = const Offset(0, 0.15),
});
final Animation<double> animation;
final Widget child;
final bool scale;
final Offset slideOffset;
@override
Widget build(BuildContext context) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
if (scale) {
final scaleAnim = Tween<double>(begin: 0.85, end: 1.0).animate(curved);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: scaleAnim, child: child),
);
}
final slideAnim = Tween<Offset>(begin: slideOffset, end: Offset.zero).animate(curved);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: slideAnim, child: child),
);
}
}

View File

@@ -3,10 +3,15 @@ import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart';
import '../pages/register_page.dart'; import '../pages/register_page.dart';
import '../pages/splash_page.dart';
class AuthRoutes { class AuthRoutes {
// 返回一个 List<RouteBase> // 返回一个 List<RouteBase>
static List<RouteBase> routes = [ static List<RouteBase> routes = [
GoRoute(
path: RoutePaths.splash,
builder: (context, state) => const SplashPage(),
),
GoRoute( GoRoute(
path: RoutePaths.login, path: RoutePaths.login,
builder: (context, state) => const LoginPage(), builder: (context, state) => const LoginPage(),