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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -41,6 +41,7 @@ import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart'; import 'theming/light_app_theme.dart';
import 'utils/app_paths.dart'; import 'utils/app_paths.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
import 'view/login/post_login_splash.dart';
import 'widget/app_progress_indicator.dart'; import 'widget/app_progress_indicator.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart'; import 'widget/debug/cache_view.dart';
@@ -188,6 +189,9 @@ class Main extends StatefulWidget {
} }
class _MainState extends State<Main> { class _MainState extends State<Main> {
bool _showPostLoginSplash = false;
bool _appMounted = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -215,6 +219,12 @@ class _MainState extends State<Main> {
/// password has been rotated server-side; the validator wipes the local /// password has been rotated server-side; the validator wipes the local
/// session and we flip the account bloc back to `loggedOut`, which sends /// session and we flip the account bloc back to `loggedOut`, which sends
/// the user to the login screen. /// the user to the login screen.
void _prefetchBaseData(BuildContext context) {
context.read<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(ListFilesCache.prefetchRootListing());
}
void _scheduleSessionValidation(AccountBloc accountBloc) { void _scheduleSessionValidation(AccountBloc accountBloc) {
unawaited( unawaited(
SessionValidator.probeStored( SessionValidator.probeStored(
@@ -282,6 +292,12 @@ class _MainState extends State<Main> {
unawaited( unawaited(
context.read<NextcloudCapabilitiesCubit>().load(), context.read<NextcloudCapabilitiesCubit>().load(),
); );
_showPostLoginSplash = true;
_appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _appMounted = true);
});
_prefetchBaseData(context);
} }
if (accountState.status != AccountStatus.loggedOut) return; if (accountState.status != AccountStatus.loggedOut) return;
// A pending share would otherwise survive logout and be // A pending share would otherwise survive logout and be
@@ -328,7 +344,19 @@ class _MainState extends State<Main> {
builder: (context, accountState) { builder: (context, accountState) {
switch (accountState.status) { switch (accountState.status) {
case AccountStatus.loggedIn: case AccountStatus.loggedIn:
return const App(); return Stack(
fit: StackFit.expand,
children: [
if (_appMounted) const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () => setState(
() => _showPostLoginSplash = false,
),
),
],
);
case AccountStatus.loggedOut: case AccountStatus.loggedOut:
return const Login(); return const Login();
case AccountStatus.undefined: case AccountStatus.undefined:
+56 -39
View File
@@ -13,6 +13,7 @@ import '../../theming/light_app_theme.dart';
import '../../utils/haptics.dart'; import '../../utils/haptics.dart';
import '../pages/settings/widgets/endpoint_picker.dart'; import '../pages/settings/widgets/endpoint_picker.dart';
import 'login_controller.dart'; import 'login_controller.dart';
import 'post_login_splash.dart';
import 'widgets/login_branding.dart'; import 'widgets/login_branding.dart';
import 'widgets/login_card.dart'; import 'widgets/login_card.dart';
@@ -23,73 +24,89 @@ class Login extends StatefulWidget {
State<Login> createState() => _LoginState(); State<Login> createState() => _LoginState();
} }
class _LoginState extends State<Login> { class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
static const _marianumRed = LightAppTheme.marianumRed; static const _marianumRed = LightAppTheme.marianumRed;
final LoginController _controller = LoginController(); final LoginController _controller = LoginController();
late final AnimationController _fade = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
value: 1,
);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
precacheImage(const AssetImage('assets/logo/icon.png'), context); precacheImage(const AssetImage('assets/logo/icon.png'), context);
unawaited(PostLoginSplash.precache());
} }
@override @override
void dispose() { void dispose() {
_fade.dispose();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
void _onLoginSuccess() { void _onLoginSuccess() {
Haptics.heavyAccent(); Haptics.heavyAccent();
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
// Re-register the periodic refresh (cancelAll runs on logout) and kick // Re-register the periodic refresh (cancelAll runs on logout) and kick
// off an immediate one-off so the widget populates within seconds // off an immediate one-off so the widget populates within seconds
// instead of waiting up to 30 minutes for the next periodic slot. // instead of waiting up to 30 minutes for the next periodic slot.
unawaited(WidgetBackgroundTask.initialize()); unawaited(WidgetBackgroundTask.initialize());
unawaited(WidgetBackgroundTask.requestImmediateRefresh()); 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 @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
backgroundColor: _marianumRed, backgroundColor: _marianumRed,
body: SafeArea( body: FadeTransition(
child: LayoutBuilder( opacity: _fade,
builder: (context, constraints) => SingleChildScrollView( child: SafeArea(
padding: const EdgeInsets.symmetric(horizontal: 24), child: LayoutBuilder(
child: Center( builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox( padding: const EdgeInsets.symmetric(horizontal: 24),
constraints: BoxConstraints( child: Center(
minHeight: constraints.maxHeight, child: ConstrainedBox(
maxWidth: 420, constraints: BoxConstraints(
), minHeight: constraints.maxHeight,
// spaceBetween statt Spacer-in-IntrinsicHeight: bei jeder maxWidth: 420,
// Inhaltsänderung im unteren Block (z.B. EndpointLink mit ),
// dynamischem Label) würde IntrinsicHeight sonst die Column // spaceBetween statt Spacer-in-IntrinsicHeight: bei jeder
// an die intrinsic-Höhe pinnen und ein paar Pixel Overflow // Inhaltsänderung im unteren Block (z.B. EndpointLink mit
// produzieren. spaceBetween fügt nur den verbleibenden Gap // dynamischem Label) würde IntrinsicHeight sonst die Column
// ein und schrumpft sauber auf 0, wenn der Inhalt zu hoch // an die intrinsic-Höhe pinnen und ein paar Pixel Overflow
// wird — dann übernimmt der äußere ScrollView. // produzieren. spaceBetween fügt nur den verbleibenden Gap
child: Column( // ein und schrumpft sauber auf 0, wenn der Inhalt zu hoch
mainAxisAlignment: MainAxisAlignment.spaceBetween, // wird — dann übernimmt der äußere ScrollView.
children: [ child: Column(
Column( mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const LoginHeader(), Column(
const SizedBox(height: 28), children: [
LoginCard( const LoginHeader(),
controller: _controller, const SizedBox(height: 28),
onSuccess: _onLoginSuccess, LoginCard(
), controller: _controller,
const SizedBox(height: 18), onSuccess: _onLoginSuccess,
const LoginDisclaimer(), ),
], const SizedBox(height: 18),
), const LoginDisclaimer(),
const Column( ],
mainAxisSize: MainAxisSize.min, ),
children: [_EndpointLink(), LoginFooter()], 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,
),
),
),
),
),
);
},
),
);
}
}
+1
View File
@@ -58,6 +58,7 @@ dependencies:
json_annotation: ^4.9.0 json_annotation: ^4.9.0
loader_overlay: ^5.0.0 loader_overlay: ^5.0.0
localstore: ^1.4.0 localstore: ^1.4.0
lottie: ^3.4.0
nextcloud: nextcloud:
git: git:
path: packages/nextcloud path: packages/nextcloud