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
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/tpschedule_gradient" />
<item android:width="120dp" android:height="120dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
<item android:drawable="@color/tpschedule_blue" />
<item android:width="188.8dp" android:height="188.8dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
</layer-list>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/tpschedule_gradient" />
<item android:width="120dp" android:height="120dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
<item android:drawable="@color/tpschedule_blue" />
<item android:width="188.8dp" android:height="188.8dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
</layer-list>
+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,
),
);
}
+70 -69
View File
@@ -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
View File
@@ -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());
},
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.6.0+15
version: 0.6.1+16
environment:
sdk: ^3.13.5
dependencies:
+74
View File
@@ -0,0 +1,74 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/app_controller.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/course_occurrence.dart';
import 'package:hepan_everyday/services/notification_service.dart';
import '../support/fakes.dart';
class WaitingNotifications extends FakeNotifications {
final pending = Completer<ReminderResult>();
@override
Future<ReminderResult> reconcile(
List<CourseOccurrence> events,
AppSettings settings,
DateTime now,
) {
reconciliations++;
return pending.future;
}
}
void main() {
test(
'data-only startup finishes before slow native reminder work starts',
() async {
final notifications = WaitingNotifications();
final calendar = FakeCalendar();
final snapshot = AppSnapshot();
final controller = AppController(
MemoryStore(snapshot),
notifications,
calendar,
DateTime.now,
);
await controller.initialize(reconcileServices: false);
expect(controller.snapshot, same(snapshot));
expect(notifications.reconciliations, 0);
expect(calendar.synchronizations, 0);
final background = controller.refreshServices();
await Future<void>.delayed(Duration.zero);
expect(notifications.reconciliations, 1);
notifications.pending.complete(const ReminderResult(ReminderStatus.off));
await background;
controller.dispose();
},
);
test(
'failed saved-data read still exposes recovery without starting services',
() async {
final store = BrokenStore();
final notifications = FakeNotifications();
final controller = AppController(
store,
notifications,
FakeCalendar(),
DateTime.now,
);
await controller.initialize(reconcileServices: false);
expect(controller.storageReady, isFalse);
expect(controller.lastError, contains('保存文件读取失败'));
expect(notifications.reconciliations, 0);
controller.dispose();
},
);
}
class BrokenStore extends MemoryStore {
@override
Future<AppSnapshot?> load() => Future.error(const FormatException('invalid'));
}
@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
import 'package:hepan_everyday/features/school/school_session.dart';
import 'package:hepan_everyday/domain/school_term.dart';
import '../support/fake_school_web.dart';
class CountingSchoolPlatform extends FakeSchoolPlatform {
int creations = 0;
@override
PlatformWebViewController createPlatformWebViewController(
PlatformWebViewControllerCreationParams params,
) {
creations++;
return super.createPlatformWebViewController(params);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('local timetable startup does not construct the school browser', () {
final platform = CountingSchoolPlatform();
WebViewPlatform.instance = platform;
final session = SchoolSession();
try {
expect(platform.creations, 0);
expect(session.connectionState, SchoolConnectionState.signedOut);
expect(session.hasOpened, isFalse);
} finally {
session.dispose();
}
});
test(
'opening school creates one reusable browser with navigation policy',
() async {
final platform = CountingSchoolPlatform();
WebViewPlatform.instance = platform;
final session = SchoolSession();
try {
await session.openTimetable(term: SchoolTerm(73));
expect(platform.creations, 1);
final controller = session.web;
expect(session.web, same(controller));
expect(platform.creations, 1);
expect(platform.web.loads, isNotEmpty);
final decision = await platform.delegate.navigation(
NavigationRequest(
url: 'https://unrelated.example/',
isMainFrame: true,
),
);
expect(decision, NavigationDecision.prevent);
} finally {
session.dispose();
}
},
);
}
@@ -66,9 +66,12 @@ void main() {
WebViewPlatform.instance = platform;
final session = SchoolSession();
addTearDown(session.dispose);
// A previously authenticated session has already opened its browser.
final connectedBrowser = session.web;
session.connectionState = SchoolConnectionState.connected;
await session.openTimetable(term: SchoolTerm(72), chooseTerm: true);
expect(session.needsTermSelection, isTrue);
expect(session.web, same(connectedBrowser));
expect(platform.web.loads, isEmpty);
expect(session.selectedTerm, SchoolTerm(72));
expect(session.pendingDraft, isNull);
@@ -0,0 +1,31 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/tp_startup.dart';
import 'package:hepan_everyday/app/tp_launch_page.dart';
void main() {
testWidgets(
'slow initialization paints branded frames instead of waiting without a Flutter UI',
(tester) async {
final ready = Completer<void>();
final startup = launchApplication(
ready: ready.future,
child: const MaterialApp(home: Text('已读取课表')),
);
try {
await tester.pump();
await tester.pump(const Duration(seconds: 3));
expect(find.byType(TpLaunchPage), findsOneWidget);
expect(find.text('已读取课表'), findsNothing);
expect(tester.binding.sendFramesToEngine, isTrue);
} finally {
ready.complete();
await startup;
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
}
},
);
}
@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/tp_startup.dart';
import 'package:hepan_everyday/app/tp_launch_page.dart';
void main() {
testWidgets(
'home paints before gradual reveal and cannot be tapped until complete',
(tester) async {
final ready = Completer<Widget>();
var serviceStarts = 0, taps = 0, boots = 0;
await tester.pumpWidget(
TpStartup(
bootstrap: () {
boots++;
expect(find.byType(TpLaunchPage), findsOneWidget);
return ready.future;
},
onHomeReady: () {
serviceStarts++;
expect(find.text('课程详情'), findsOneWidget);
},
),
);
ready.complete(
MaterialApp(
home: Center(
child: TextButton(
onPressed: () => taps++,
child: const Text('课程详情'),
),
),
),
);
await tester.pump();
await tester.pump();
expect(serviceStarts, 1);
await tester.pump(const Duration(milliseconds: 100));
final overlay = tester.widget<Opacity>(
find.byKey(const Key('startup-brand-overlay')),
);
expect(overlay.opacity, inExclusiveRange(0, 1));
await tester.tap(find.text('课程详情'), warnIfMissed: false);
expect(taps, 0);
await tester.pumpAndSettle();
expect(find.byType(TpLaunchPage), findsNothing);
await tester.tap(find.text('课程详情'));
expect(taps, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(boots, 1);
expect(serviceStarts, 1);
expect(find.byType(TpLaunchPage), findsNothing);
},
);
testWidgets(
'bootstrap failure presents a working retry instead of a blank screen',
(tester) async {
var attempts = 0;
await tester.pumpWidget(
TpStartup(
bootstrap: () async {
if (++attempts == 1) throw StateError('plugin unavailable');
return const MaterialApp(home: Text('已恢复启动'));
},
),
);
await tester.pumpAndSettle();
expect(find.text('启动未完成,请重试'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('重新打开'));
await tester.pumpAndSettle();
expect(find.text('已恢复启动'), findsOneWidget);
expect(attempts, 2);
expect(tester.takeException(), isNull);
},
);
testWidgets('reduced motion reveals home without a timed fade', (
tester,
) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: TpStartup(
bootstrap: () async => const MaterialApp(home: Text('首页')),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('首页'), findsOneWidget);
expect(find.byType(TpLaunchPage), findsNothing);
});
testWidgets(
'late bootstrap completion after disposal does not start services',
(tester) async {
final ready = Completer<Widget>();
var serviceStarts = 0;
await tester.pumpWidget(
TpStartup(
bootstrap: () => ready.future,
onHomeReady: () => serviceStarts++,
),
);
await tester.pumpWidget(const SizedBox());
ready.complete(const MaterialApp(home: Text('旧首页')));
await tester.pumpAndSettle();
expect(serviceStarts, 0);
expect(find.text('旧首页'), findsNothing);
expect(tester.takeException(), isNull);
},
);
}
+7 -5
View File
@@ -3,9 +3,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/tp_startup.dart';
import 'package:hepan_everyday/app/tp_launch_page.dart';
void main() {
testWidgets('waiting initialization does not replace the native launch', (
testWidgets('waiting initialization keeps painted brand frames', (
tester,
) async {
await tester.pumpWidget(const SizedBox());
@@ -16,13 +17,14 @@ void main() {
child: const MaterialApp(home: Text('已准备好课表')),
).then((_) => startupFinished = true);
await tester.pump();
expect(startupFinished, isFalse);
expect(startupFinished, isTrue);
expect(find.byType(TpLaunchPage), findsOneWidget);
expect(tester.binding.sendFramesToEngine, isTrue);
expect(find.text('TPSchedule'), findsNothing);
expect(find.text('已准备好课表'), findsNothing);
ready.complete();
await startup;
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('已准备好课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
expect(tester.binding.sendFramesToEngine, isTrue);
@@ -37,7 +39,7 @@ void main() {
ready: ready,
child: const MaterialApp(home: Text('保存的课表')),
);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('保存的课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
@@ -57,7 +59,7 @@ void main() {
);
ready.completeError(StateError('storage failed'));
await startup;
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('恢复保存文件'), findsOneWidget);
expect(tester.binding.sendFramesToEngine, isTrue);
expect(tester.takeException(), isNull);