implemented post-login splash screen with Lottie animations and integrated background data prefetching during the login transition

This commit is contained in:
2026-06-29 12:00:26 +02:00
parent 13f4f79829
commit 4bc7ffd37a
6 changed files with 256 additions and 40 deletions
+56 -39
View File
@@ -13,6 +13,7 @@ import '../../theming/light_app_theme.dart';
import '../../utils/haptics.dart';
import '../pages/settings/widgets/endpoint_picker.dart';
import 'login_controller.dart';
import 'post_login_splash.dart';
import 'widgets/login_branding.dart';
import 'widgets/login_card.dart';
@@ -23,73 +24,89 @@ class Login extends StatefulWidget {
State<Login> createState() => _LoginState();
}
class _LoginState extends State<Login> {
class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
static const _marianumRed = LightAppTheme.marianumRed;
final LoginController _controller = LoginController();
late final AnimationController _fade = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
value: 1,
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(const AssetImage('assets/logo/icon.png'), context);
unawaited(PostLoginSplash.precache());
}
@override
void dispose() {
_fade.dispose();
_controller.dispose();
super.dispose();
}
void _onLoginSuccess() {
Haptics.heavyAccent();
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
// Re-register the periodic refresh (cancelAll runs on logout) and kick
// off an immediate one-off so the widget populates within seconds
// instead of waiting up to 30 minutes for the next periodic slot.
unawaited(WidgetBackgroundTask.initialize());
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
// Fade the login content out before handing over to the post-login splash.
// Both share the red backdrop, so this reads as one continuous transition
// instead of an abrupt swap.
_fade.reverse().whenComplete(() {
if (!mounted) return;
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
});
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: _marianumRed,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
maxWidth: 420,
),
// spaceBetween statt Spacer-in-IntrinsicHeight: bei jeder
// Inhaltsänderung im unteren Block (z.B. EndpointLink mit
// dynamischem Label) würde IntrinsicHeight sonst die Column
// an die intrinsic-Höhe pinnen und ein paar Pixel Overflow
// produzieren. spaceBetween fügt nur den verbleibenden Gap
// ein und schrumpft sauber auf 0, wenn der Inhalt zu hoch
// wird — dann übernimmt der äußere ScrollView.
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
const LoginHeader(),
const SizedBox(height: 28),
LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
),
const SizedBox(height: 18),
const LoginDisclaimer(),
],
),
const Column(
mainAxisSize: MainAxisSize.min,
children: [_EndpointLink(), LoginFooter()],
),
],
body: FadeTransition(
opacity: _fade,
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
maxWidth: 420,
),
// spaceBetween statt Spacer-in-IntrinsicHeight: bei jeder
// Inhaltsänderung im unteren Block (z.B. EndpointLink mit
// dynamischem Label) würde IntrinsicHeight sonst die Column
// an die intrinsic-Höhe pinnen und ein paar Pixel Overflow
// produzieren. spaceBetween fügt nur den verbleibenden Gap
// ein und schrumpft sauber auf 0, wenn der Inhalt zu hoch
// wird — dann übernimmt der äußere ScrollView.
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
const LoginHeader(),
const SizedBox(height: 28),
LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
),
const SizedBox(height: 18),
const LoginDisclaimer(),
],
),
const Column(
mainAxisSize: MainAxisSize.min,
children: [_EndpointLink(), LoginFooter()],
),
],
),
),
),
),
+168
View File
@@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import '../../theming/app_theme.dart';
import '../../theming/light_app_theme.dart';
class PostLoginSplash extends StatefulWidget {
const PostLoginSplash({super.key, required this.onComplete});
static const String _darkAsset = 'assets/logo/logo-anim-white.json';
static const String _lightAsset = 'assets/logo/logo-anim-red.json';
static LottieComposition? _darkComposition;
static LottieComposition? _lightComposition;
static Future<void> precache() async {
try {
_darkComposition ??= await AssetLottie(_darkAsset).load();
_lightComposition ??= await AssetLottie(_lightAsset).load();
} catch (_) {}
}
final VoidCallback onComplete;
@override
State<PostLoginSplash> createState() => _PostLoginSplashState();
}
class _PostLoginSplashState extends State<PostLoginSplash>
with TickerProviderStateMixin {
static const Duration _holdAfterLogo = Duration(milliseconds: 450);
late final AnimationController _intro;
late final AnimationController _outro;
late final AnimationController _logo;
late final Animation<double> _introCurve;
late final Animation<double> _outroCurve;
Timer? _safety;
Timer? _hold;
LottieComposition? _composition;
bool _started = false;
bool _outroStarted = false;
bool _completed = false;
@override
void initState() {
super.initState();
_intro = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_outro = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 850),
);
_introCurve = CurvedAnimation(parent: _intro, curve: Curves.easeInOut);
_outroCurve = CurvedAnimation(parent: _outro, curve: Curves.easeInOutCubic);
_logo = AnimationController(vsync: this);
_logo.addStatusListener((status) {
if (status == AnimationStatus.completed) _scheduleOutro();
});
_safety = Timer(const Duration(seconds: 6), _startOutro);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_started || _outroStarted) return;
final isDark = AppTheme.isDarkMode(context);
final cached = isDark
? PostLoginSplash._darkComposition
: PostLoginSplash._lightComposition;
if (cached != null) {
_begin(cached);
return;
}
final asset = isDark
? PostLoginSplash._darkAsset
: PostLoginSplash._lightAsset;
AssetLottie(asset)
.load()
.then((c) {
if (mounted) _begin(c);
})
.catchError((_) {});
}
void _begin(LottieComposition composition) {
if (_started || _outroStarted || !mounted) return;
_started = true;
_logo.duration = composition.duration * 0.9;
setState(() => _composition = composition);
_intro.forward(from: 0);
_logo.forward(from: 0);
}
void _scheduleOutro() {
if (_outroStarted || _hold != null) return;
_hold = Timer(_holdAfterLogo, _startOutro);
}
void _startOutro() {
if (_outroStarted) return;
_outroStarted = true;
_safety?.cancel();
_hold?.cancel();
_outro.forward().whenComplete(() {
if (_completed || !mounted) return;
_completed = true;
widget.onComplete();
});
}
@override
void dispose() {
_safety?.cancel();
_hold?.cancel();
_intro.dispose();
_outro.dispose();
_logo.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final surface = Theme.of(context).scaffoldBackgroundColor;
return AbsorbPointer(
child: AnimatedBuilder(
animation: Listenable.merge([_introCurve, _outroCurve]),
builder: (context, _) {
final intro = _introCurve.value;
final outro = _outroCurve.value;
final background = Color.lerp(
LightAppTheme.marianumRed,
surface,
intro,
);
return Opacity(
opacity: 1 - outro,
child: ColoredBox(
color: background ?? surface,
child: Center(
child: _composition == null
? const SizedBox.shrink()
: Opacity(
opacity: intro,
child: Transform.scale(
scale: (0.94 + 0.06 * intro) * (1 + 0.05 * outro),
child: Lottie(
composition: _composition,
controller: _logo,
width: 220,
height: 220,
fit: BoxFit.contain,
),
),
),
),
),
);
},
),
);
}
}