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 '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:
|
||||||
|
|||||||
@@ -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,37 +24,52 @@ 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(
|
||||||
|
opacity: _fade,
|
||||||
|
child: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) => SingleChildScrollView(
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
@@ -96,6 +112,7 @@ class _LoginState extends State<Login> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user