implemented post-login splash screen with Lottie animations and integrated background data prefetching during the login transition
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+29
-1
@@ -41,6 +41,7 @@ import 'theming/dark_app_theme.dart';
|
||||
import 'theming/light_app_theme.dart';
|
||||
import 'utils/app_paths.dart';
|
||||
import 'view/login/login.dart';
|
||||
import 'view/login/post_login_splash.dart';
|
||||
import 'widget/app_progress_indicator.dart';
|
||||
import 'widget/breaker/breaker.dart';
|
||||
import 'widget/debug/cache_view.dart';
|
||||
@@ -188,6 +189,9 @@ class Main extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MainState extends State<Main> {
|
||||
bool _showPostLoginSplash = false;
|
||||
bool _appMounted = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -215,6 +219,12 @@ class _MainState extends State<Main> {
|
||||
/// password has been rotated server-side; the validator wipes the local
|
||||
/// session and we flip the account bloc back to `loggedOut`, which sends
|
||||
/// 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) {
|
||||
unawaited(
|
||||
SessionValidator.probeStored(
|
||||
@@ -282,6 +292,12 @@ class _MainState extends State<Main> {
|
||||
unawaited(
|
||||
context.read<NextcloudCapabilitiesCubit>().load(),
|
||||
);
|
||||
_showPostLoginSplash = true;
|
||||
_appMounted = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _appMounted = true);
|
||||
});
|
||||
_prefetchBaseData(context);
|
||||
}
|
||||
if (accountState.status != AccountStatus.loggedOut) return;
|
||||
// A pending share would otherwise survive logout and be
|
||||
@@ -328,7 +344,19 @@ class _MainState extends State<Main> {
|
||||
builder: (context, accountState) {
|
||||
switch (accountState.status) {
|
||||
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:
|
||||
return const Login();
|
||||
case AccountStatus.undefined:
|
||||
|
||||
+56
-39
@@ -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()],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ dependencies:
|
||||
json_annotation: ^4.9.0
|
||||
loader_overlay: ^5.0.0
|
||||
localstore: ^1.4.0
|
||||
lottie: ^3.4.0
|
||||
nextcloud:
|
||||
git:
|
||||
path: packages/nextcloud
|
||||
|
||||
Reference in New Issue
Block a user