Render startup immediately and defer native work with a smooth home transition
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
const appVersion = '0.6.0';
|
||||
const appBuildNumber = 15;
|
||||
const appVersion = '0.6.1';
|
||||
const appBuildNumber = 16;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,78 +21,79 @@ enum SchoolConnectionState {
|
||||
}
|
||||
|
||||
class SchoolSession extends ChangeNotifier {
|
||||
SchoolSession() {
|
||||
web = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: (request) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
final upgrade = uri == null || !request.isMainFrame
|
||||
? null
|
||||
: SchoolNavigationPolicy.secureIdentityRedirect(uri);
|
||||
if (upgrade != null) {
|
||||
final generation = _pageGeneration;
|
||||
try {
|
||||
await web.loadRequest(upgrade);
|
||||
} catch (_) {
|
||||
if (generation == _pageGeneration) {
|
||||
_failure('学校身份认证暂时无法打开,请重试。');
|
||||
}
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
if (uri == null ||
|
||||
!SchoolNavigationPolicy.canNavigate(
|
||||
uri,
|
||||
isMainFrame: request.isMainFrame,
|
||||
)) {
|
||||
if (kDebugMode && request.isMainFrame) {
|
||||
debugPrint(
|
||||
'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
|
||||
);
|
||||
}
|
||||
if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
_poll?.cancel();
|
||||
_manualRead = false;
|
||||
_pageGeneration++;
|
||||
_reader.invalidate();
|
||||
pendingDraft = null;
|
||||
pendingExams = null;
|
||||
if (!hasOpened || _routeClosed) return;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
},
|
||||
onPageFinished: _loaded,
|
||||
onWebResourceError: (error) async {
|
||||
if (error.isForMainFrame != true || error.url == null) return;
|
||||
SchoolSession();
|
||||
// Creating Android WebView starts its browser engine. Do it on school access,
|
||||
// not while the student is opening an offline timetable.
|
||||
late final WebViewController web = _createWeb();
|
||||
WebViewController _createWeb() => WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: (request) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
final upgrade = uri == null || !request.isMainFrame
|
||||
? null
|
||||
: SchoolNavigationPolicy.secureIdentityRedirect(uri);
|
||||
if (upgrade != null) {
|
||||
final generation = _pageGeneration;
|
||||
final current = await web.currentUrl();
|
||||
if (hasOpened &&
|
||||
generation == _pageGeneration &&
|
||||
error.url == current) {
|
||||
_failure('学校页面暂时未能加载,请检查网络后重试。');
|
||||
try {
|
||||
await web.loadRequest(upgrade);
|
||||
} catch (_) {
|
||||
if (generation == _pageGeneration) {
|
||||
_failure('学校身份认证暂时无法打开,请重试。');
|
||||
}
|
||||
}
|
||||
},
|
||||
onHttpError: (error) async {
|
||||
final generation = _pageGeneration;
|
||||
final current = Uri.tryParse(await web.currentUrl() ?? '');
|
||||
if (hasOpened &&
|
||||
generation == _pageGeneration &&
|
||||
error.request != null &&
|
||||
error.request!.uri == current) {
|
||||
_failure('学校请求未完成,请稍后重试。');
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
if (uri == null ||
|
||||
!SchoolNavigationPolicy.canNavigate(
|
||||
uri,
|
||||
isMainFrame: request.isMainFrame,
|
||||
)) {
|
||||
if (kDebugMode && request.isMainFrame) {
|
||||
debugPrint(
|
||||
'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
late final WebViewController web;
|
||||
if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
_poll?.cancel();
|
||||
_manualRead = false;
|
||||
_pageGeneration++;
|
||||
_reader.invalidate();
|
||||
pendingDraft = null;
|
||||
pendingExams = null;
|
||||
if (!hasOpened || _routeClosed) return;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
},
|
||||
onPageFinished: _loaded,
|
||||
onWebResourceError: (error) async {
|
||||
if (error.isForMainFrame != true || error.url == null) return;
|
||||
final generation = _pageGeneration;
|
||||
final current = await web.currentUrl();
|
||||
if (hasOpened &&
|
||||
generation == _pageGeneration &&
|
||||
error.url == current) {
|
||||
_failure('学校页面暂时未能加载,请检查网络后重试。');
|
||||
}
|
||||
},
|
||||
onHttpError: (error) async {
|
||||
final generation = _pageGeneration;
|
||||
final current = Uri.tryParse(await web.currentUrl() ?? '');
|
||||
if (hasOpened &&
|
||||
generation == _pageGeneration &&
|
||||
error.request != null &&
|
||||
error.request!.uri == current) {
|
||||
_failure('学校请求未完成,请稍后重试。');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
final _reader = SchoolReader(SchoolPageAdapter());
|
||||
final _flow = SchoolLoginFlow();
|
||||
Timer? _timeout;
|
||||
|
||||
+27
-15
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
@@ -25,21 +27,31 @@ Widget buildApplication(
|
||||
);
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
tzdata.initializeTimeZones();
|
||||
final selection = ValueNotifier<String?>(null);
|
||||
final gateway = PluginReminderGateway(onTap: (key) => selection.value = key);
|
||||
final controller = AppController(
|
||||
FileSnapshotStore(await getApplicationSupportDirectory()),
|
||||
LocalNotificationService(gateway),
|
||||
AndroidCalendarService(MethodCalendarGateway()),
|
||||
DateTime.now,
|
||||
);
|
||||
AppController? controller;
|
||||
await launchApplication(
|
||||
ready: controller.initialize(),
|
||||
child: buildApplication(
|
||||
controller,
|
||||
notificationSelection: selection,
|
||||
school: SchoolSession(),
|
||||
),
|
||||
bootstrap: () async {
|
||||
tzdata.initializeTimeZones();
|
||||
final selection = ValueNotifier<String?>(null);
|
||||
final gateway = PluginReminderGateway(
|
||||
onTap: (key) => selection.value = key,
|
||||
);
|
||||
final value = AppController(
|
||||
FileSnapshotStore(await getApplicationSupportDirectory()),
|
||||
LocalNotificationService(gateway),
|
||||
AndroidCalendarService(MethodCalendarGateway()),
|
||||
DateTime.now,
|
||||
);
|
||||
await value.initialize(reconcileServices: false);
|
||||
controller = value;
|
||||
return buildApplication(
|
||||
value,
|
||||
notificationSelection: selection,
|
||||
school: SchoolSession(),
|
||||
);
|
||||
},
|
||||
onHomeReady: () {
|
||||
final value = controller;
|
||||
if (value != null) unawaited(value.refreshServices());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user