Render startup immediately and defer native work with a smooth home transition

This commit is contained in:
Codex
2026-10-14 17:26:24 +08:00
parent bb7a7a2e58
commit ce21cdc10c
17 changed files with 641 additions and 144 deletions
+2 -2
View File
@@ -57,7 +57,7 @@ class AppController extends ChangeNotifier {
lastError = null;
}
Future<void> initialize() => _run(() async {
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
try {
snapshot = await store.load() ?? AppSnapshot();
storageReady = true;
@@ -65,7 +65,7 @@ class AppController extends ChangeNotifier {
storageReady = false;
throw const FormatException('保存文件读取失败,可恢复最近备份');
}
await _reconcile();
if (reconcileServices) await _reconcile();
});
Future<void> restoreBackup() => _run(() async {
snapshot = await store.restoreBackup();
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.6.0';
const appBuildNumber = 15;
const appVersion = '0.6.1';
const appBuildNumber = 16;
+39 -32
View File
@@ -3,43 +3,50 @@ import 'package:flutter/services.dart';
import '../widgets/tp_logo.dart';
/// The first Flutter frame while saved data and reminder services initialize.
/// Matching native artwork while saved data initializes.
class TpLaunchPage extends StatelessWidget {
const TpLaunchPage({super.key});
const TpLaunchPage({super.key, this.nativeContinuity = false});
final bool nativeContinuity;
@override
Widget build(BuildContext context) => AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Color(0xFF38B3FF),
systemNavigationBarIconBrightness: Brightness.light,
),
child: Scaffold(
backgroundColor: const Color(0xFF38B3FF),
body: DecoratedBox(
decoration: const BoxDecoration(gradient: tpBrandGradient),
child: SizedBox.expand(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const TpLogo(size: 120, background: false),
const SizedBox(height: 20),
Text(
'TPSchedule',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
Widget build(BuildContext context) => nativeContinuity
? const ColoredBox(
color: Color(0xFF38B3FF),
child: Center(child: TpLogo(size: 188.8, background: false)),
)
: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Color(0xFF38B3FF),
systemNavigationBarIconBrightness: Brightness.light,
),
child: Scaffold(
backgroundColor: const Color(0xFF38B3FF),
body: DecoratedBox(
decoration: const BoxDecoration(gradient: tpBrandGradient),
child: SizedBox.expand(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const TpLogo(size: 120, background: false),
const SizedBox(height: 20),
Text(
'TPSchedule',
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
),
),
],
),
),
],
),
),
),
),
),
),
);
);
}
+174 -13
View File
@@ -1,26 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// The native launch screen remains until runApp submits the initialized app.
/// Use normal bootstrap instead of a second splash or deferred warm-up frames.
import 'app_theme.dart';
import 'tp_launch_page.dart';
/// Paint a light first frame before asking plugins to read saved data.
Future<void> launchApplication({
required Future<void> ready,
required Widget child,
Future<void>? ready,
Widget? child,
Future<Widget> Function()? bootstrap,
VoidCallback? onHomeReady,
}) async {
assert(bootstrap != null || (ready != null && child != null));
// Attach error handling immediately, even if a caller already started work.
final legacy = bootstrap == null ? _legacyReady(ready!, child!) : null;
runApp(
TpStartup(bootstrap: bootstrap ?? () => legacy!, onHomeReady: onHomeReady),
);
}
Future<Widget> _legacyReady(Future<void> ready, Widget child) async {
try {
await ready;
} catch (_) {
// AppController exposes initialization failures in its recovery UI.
// Preserve access to that UI instead of leaving the native launch visible.
// AppController exposes failed storage reads through the recovery screen.
}
runApp(
AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: const Color(0xFFF3F7FF),
systemNavigationBarIconBrightness: Brightness.dark,
return child;
}
class TpStartup extends StatefulWidget {
const TpStartup({super.key, required this.bootstrap, this.onHomeReady});
final Future<Widget> Function() bootstrap;
final VoidCallback? onHomeReady;
@override
State<TpStartup> createState() => _TpStartupState();
}
class _TpStartupState extends State<TpStartup>
with SingleTickerProviderStateMixin {
late final _fade =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addStatusListener((status) {
if (status == AnimationStatus.completed && mounted) {
setState(() => _revealed = true);
}
});
Widget? _home;
bool _failed = false, _revealed = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _bootstrap();
});
}
void _bootstrap() {
setState(() => _failed = false);
Future<Widget>.sync(widget.bootstrap).then(
(home) {
if (!mounted) return;
setState(() => _home = home);
// Paint home behind an opaque brand frame before fading it out.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final reduced =
MediaQuery.maybeOf(context)?.disableAnimations ??
WidgetsBinding
.instance
.platformDispatcher
.accessibilityFeatures
.disableAnimations;
if (reduced) {
_fade.value = 1;
} else {
_fade.forward();
}
widget.onHomeReady?.call();
});
},
onError: (Object error, StackTrace stack) {
if (mounted) setState(() => _failed = true);
},
);
}
@override
void dispose() {
_fade.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget content = Directionality(
textDirection: TextDirection.ltr,
child: Theme(
data: hepanTheme(),
child: AnimatedBuilder(
animation: _fade,
builder: (context, _) {
final progress = Curves.easeOutCubic.transform(_fade.value);
return AnnotatedRegion<SystemUiOverlayStyle>(
value:
(progress < 0.5
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark)
.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Color.lerp(
const Color(0xFF38B3FF),
canvas,
progress,
),
),
child: ColoredBox(
color: const Color(0xFF38B3FF),
child: Stack(
fit: StackFit.expand,
children: [
if (_home != null)
ExcludeSemantics(
excluding: !_revealed,
child: ExcludeFocus(
excluding: !_revealed,
child: IgnorePointer(
ignoring: !_revealed,
child: Transform.translate(
offset: Offset(0, 6 * (1 - progress)),
child: _home,
),
),
),
),
if (!_revealed)
IgnorePointer(
ignoring: !_failed,
child: Opacity(
key: const Key('startup-brand-overlay'),
opacity: 1 - progress,
child: _failed
? _recovery()
: const TpLaunchPage(nativeContinuity: true),
),
),
],
),
),
);
},
),
),
);
if (MediaQuery.maybeOf(context) == null) {
content = MediaQuery.fromView(view: View.of(context), child: content);
}
return content;
}
Widget _recovery() => Material(
color: const Color(0xFF38B3FF),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'启动未完成,请重试',
style: TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 20),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: river,
),
onPressed: _bootstrap,
child: const Text('重新打开'),
),
],
),
child: child,
),
);
}