Render startup immediately and defer native work with a smooth home transition
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# TPSchedule
|
# TPSchedule
|
||||||
|
|
||||||
学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.0+15,Android 7.0 及以上。
|
学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.1+16,Android 7.0 及以上。
|
||||||
|
|
||||||
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
0.6.0将Dock光晕改为白光,换周增加过渡,修复通知查询失败阻断提醒,并增加通知测试与诊断。我的页面新增反馈与共建。服务器仍需配置部署,详见[0.6.0验证记录](docs/tpschedule-0.6.0-validation.md)与[中文1Panel部署说明](server/feedback/部署说明.md)。
|
0.6.0将Dock光晕改为白光,换周增加过渡,修复通知查询失败阻断提醒,并增加通知测试与诊断。我的页面新增反馈与共建。服务器仍需配置部署,详见[0.6.0验证记录](docs/tpschedule-0.6.0-validation.md)与[中文1Panel部署说明](server/feedback/部署说明.md)。
|
||||||
|
|
||||||
|
0.6.1优化启动:先绘制与原生开屏衔接的品牌帧,读取本地课表后以300ms过渡进入首页,通知与日历随后同步;学校浏览器首次访问时才初始化,后台返回不重播开屏。基础和旧版Android开屏资源统一蓝底与图标尺寸。详见[0.6.1启动验证记录](docs/tpschedule-0.6.1-validation.md)。
|
||||||
|
|
||||||
0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。
|
0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。
|
||||||
|
|
||||||
## 安装与学校登录
|
## 安装与学校登录
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# TPSchedule 0.6.1+16:启动衔接与过渡
|
||||||
|
|
||||||
|
2026-10-14。用户报告偶发开屏后黑屏2–3秒,希望增加进入首页的过渡。
|
||||||
|
|
||||||
|
## 调查与改动
|
||||||
|
|
||||||
|
旧入口在runApp之前等待完整controller.initialize,包括本地存储、通知及日历。延迟初始化回归在旧实现确认等待期间没有Flutter界面。另一个回归确认SchoolSession构造时就创建WebView,即使只查看本地课表也启动浏览器引擎。这两项均已先观察失败,再修复。
|
||||||
|
|
||||||
|
- 先正常runApp绘制轻量品牌帧,随后初始化时区并读取本地课表。没有使用deferFirstFrame/allowFirstFrame,也没有增加固定停留时间。
|
||||||
|
- 启动时使用initialize(reconcileServices:false),本地数据就绪后准备首页;通知与日历在首页场景提交后独立同步。默认initialize行为保留,其他业务调用不变。
|
||||||
|
- SchoolSession的WebViewController改为首次学校访问时创建并复用;导航限制、HTTPS升级、身份验证、读取、学期选择、退出清理逻辑保留。
|
||||||
|
- 首页先在不透明品牌覆盖层下布局和提交场景,再用300ms淡出品牌,首页轻微上移6逻辑像素。完成前禁止点击、焦点与首页无障碍操作;后台返回不重播该过渡,减少动画时直接显示。
|
||||||
|
- 首个Flutter品牌帧与原生启动屏衔接:统一#38B3FF底色,原图标居中,前景画布188.8dp。基础及drawable-v21启动资源都已同步。应用图标及原SVG不改。
|
||||||
|
- 引导阶段的插件异常可重试;保存文件读取失败仍进入原有恢复界面。没有用超时清空课表或重建保存文件。
|
||||||
|
|
||||||
|
不透明覆盖层确认首页完成布局并提交场景,不保证隐藏内容已经全部由GPU渲染;手机最终效果仍受设备渲染影响。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 197项Flutter测试通过,静态分析无问题。
|
||||||
|
- 覆盖持续等待仍显示品牌、逐步过渡/期间防误触、失败重试、减少动画、页面销毁后异步回调、后台返回不重播、数据先于慢提醒服务就绪、坏文件恢复、学校浏览器延迟创建和重用及导航策略。
|
||||||
|
- 独立代码审阅启动调度、状态保留、通知点击及学校登录逻辑。审阅指出Android24–30还使用drawable-v21覆盖文件,已同步修复;复查未发现其他需修改问题。
|
||||||
|
- 发布/调试APK构建并核对原证书。API36模拟器覆盖安装保留原课表,冷启动无需触碰自动进入首页;确认首页完成后返回桌面并再打开,检查过渡没有重播。
|
||||||
|
- 实际录屏在dist/screenshots/tpschedule-061-startup-warm.mp4,采样帧在tpschedule-061-startup-frames.png。工具输出在本地.tools/tpschedule-061-*,不随源码分发。模拟器采用软件兼容渲染,不能代替vivo X200 Pro真机复测,也不能将系统首屏计时解释为首页全部渲染耗时。
|
||||||
|
- 包名com.tpschedule.app、原签名、公私钥、0.5.4登记冻结及V1.0材料保持。反馈服务及雷池部署步骤不变。
|
||||||
|
|
||||||
|
参考:[Flutter原生启动屏衔接](https://docs.flutter.dev/platform-integration/android/splash-screen)。
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@drawable/tpschedule_gradient" />
|
<item android:drawable="@color/tpschedule_blue" />
|
||||||
<item android:width="120dp" android:height="120dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
|
<item android:width="188.8dp" android:height="188.8dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@drawable/tpschedule_gradient" />
|
<item android:drawable="@color/tpschedule_blue" />
|
||||||
<item android:width="120dp" android:height="120dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
|
<item android:width="188.8dp" android:height="188.8dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class AppController extends ChangeNotifier {
|
|||||||
lastError = null;
|
lastError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initialize() => _run(() async {
|
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
|
||||||
try {
|
try {
|
||||||
snapshot = await store.load() ?? AppSnapshot();
|
snapshot = await store.load() ?? AppSnapshot();
|
||||||
storageReady = true;
|
storageReady = true;
|
||||||
@@ -65,7 +65,7 @@ class AppController extends ChangeNotifier {
|
|||||||
storageReady = false;
|
storageReady = false;
|
||||||
throw const FormatException('保存文件读取失败,可恢复最近备份');
|
throw const FormatException('保存文件读取失败,可恢复最近备份');
|
||||||
}
|
}
|
||||||
await _reconcile();
|
if (reconcileServices) await _reconcile();
|
||||||
});
|
});
|
||||||
Future<void> restoreBackup() => _run(() async {
|
Future<void> restoreBackup() => _run(() async {
|
||||||
snapshot = await store.restoreBackup();
|
snapshot = await store.restoreBackup();
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
const appVersion = '0.6.0';
|
const appVersion = '0.6.1';
|
||||||
const appBuildNumber = 15;
|
const appBuildNumber = 16;
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import 'package:flutter/services.dart';
|
|||||||
|
|
||||||
import '../widgets/tp_logo.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 {
|
class TpLaunchPage extends StatelessWidget {
|
||||||
const TpLaunchPage({super.key});
|
const TpLaunchPage({super.key, this.nativeContinuity = false});
|
||||||
|
final bool nativeContinuity;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => AnnotatedRegion<SystemUiOverlayStyle>(
|
Widget build(BuildContext context) => nativeContinuity
|
||||||
|
? const ColoredBox(
|
||||||
|
color: Color(0xFF38B3FF),
|
||||||
|
child: Center(child: TpLogo(size: 188.8, background: false)),
|
||||||
|
)
|
||||||
|
: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: const SystemUiOverlayStyle(
|
value: const SystemUiOverlayStyle(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
statusBarIconBrightness: Brightness.light,
|
statusBarIconBrightness: Brightness.light,
|
||||||
@@ -29,7 +35,8 @@ class TpLaunchPage extends StatelessWidget {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'TPSchedule',
|
'TPSchedule',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
|
|||||||
+173
-12
@@ -1,26 +1,187 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// The native launch screen remains until runApp submits the initialized app.
|
import 'app_theme.dart';
|
||||||
/// Use normal bootstrap instead of a second splash or deferred warm-up frames.
|
import 'tp_launch_page.dart';
|
||||||
|
|
||||||
|
/// Paint a light first frame before asking plugins to read saved data.
|
||||||
Future<void> launchApplication({
|
Future<void> launchApplication({
|
||||||
required Future<void> ready,
|
Future<void>? ready,
|
||||||
required Widget child,
|
Widget? child,
|
||||||
|
Future<Widget> Function()? bootstrap,
|
||||||
|
VoidCallback? onHomeReady,
|
||||||
}) async {
|
}) 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 {
|
try {
|
||||||
await ready;
|
await ready;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// AppController exposes initialization failures in its recovery UI.
|
// AppController exposes failed storage reads through the recovery screen.
|
||||||
// Preserve access to that UI instead of leaving the native launch visible.
|
|
||||||
}
|
}
|
||||||
runApp(
|
return child;
|
||||||
AnnotatedRegion<SystemUiOverlayStyle>(
|
}
|
||||||
value: SystemUiOverlayStyle.dark.copyWith(
|
|
||||||
|
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,
|
statusBarColor: Colors.transparent,
|
||||||
systemNavigationBarColor: const Color(0xFFF3F7FF),
|
systemNavigationBarColor: Color.lerp(
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
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,8 +21,11 @@ enum SchoolConnectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SchoolSession extends ChangeNotifier {
|
class SchoolSession extends ChangeNotifier {
|
||||||
SchoolSession() {
|
SchoolSession();
|
||||||
web = WebViewController()
|
// 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)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
NavigationDelegate(
|
||||||
@@ -91,8 +94,6 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
late final WebViewController web;
|
|
||||||
final _reader = SchoolReader(SchoolPageAdapter());
|
final _reader = SchoolReader(SchoolPageAdapter());
|
||||||
final _flow = SchoolLoginFlow();
|
final _flow = SchoolLoginFlow();
|
||||||
Timer? _timeout;
|
Timer? _timeout;
|
||||||
|
|||||||
+19
-7
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tzdata;
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
@@ -25,21 +27,31 @@ Widget buildApplication(
|
|||||||
);
|
);
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
AppController? controller;
|
||||||
|
await launchApplication(
|
||||||
|
bootstrap: () async {
|
||||||
tzdata.initializeTimeZones();
|
tzdata.initializeTimeZones();
|
||||||
final selection = ValueNotifier<String?>(null);
|
final selection = ValueNotifier<String?>(null);
|
||||||
final gateway = PluginReminderGateway(onTap: (key) => selection.value = key);
|
final gateway = PluginReminderGateway(
|
||||||
final controller = AppController(
|
onTap: (key) => selection.value = key,
|
||||||
|
);
|
||||||
|
final value = AppController(
|
||||||
FileSnapshotStore(await getApplicationSupportDirectory()),
|
FileSnapshotStore(await getApplicationSupportDirectory()),
|
||||||
LocalNotificationService(gateway),
|
LocalNotificationService(gateway),
|
||||||
AndroidCalendarService(MethodCalendarGateway()),
|
AndroidCalendarService(MethodCalendarGateway()),
|
||||||
DateTime.now,
|
DateTime.now,
|
||||||
);
|
);
|
||||||
await launchApplication(
|
await value.initialize(reconcileServices: false);
|
||||||
ready: controller.initialize(),
|
controller = value;
|
||||||
child: buildApplication(
|
return buildApplication(
|
||||||
controller,
|
value,
|
||||||
notificationSelection: selection,
|
notificationSelection: selection,
|
||||||
school: SchoolSession(),
|
school: SchoolSession(),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
onHomeReady: () {
|
||||||
|
final value = controller;
|
||||||
|
if (value != null) unawaited(value.refreshServices());
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: hepan_everyday
|
name: hepan_everyday
|
||||||
description: TPSchedule 学生课表
|
description: TPSchedule 学生课表
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.6.0+15
|
version: 0.6.1+16
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -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;
|
WebViewPlatform.instance = platform;
|
||||||
final session = SchoolSession();
|
final session = SchoolSession();
|
||||||
addTearDown(session.dispose);
|
addTearDown(session.dispose);
|
||||||
|
// A previously authenticated session has already opened its browser.
|
||||||
|
final connectedBrowser = session.web;
|
||||||
session.connectionState = SchoolConnectionState.connected;
|
session.connectionState = SchoolConnectionState.connected;
|
||||||
await session.openTimetable(term: SchoolTerm(72), chooseTerm: true);
|
await session.openTimetable(term: SchoolTerm(72), chooseTerm: true);
|
||||||
expect(session.needsTermSelection, isTrue);
|
expect(session.needsTermSelection, isTrue);
|
||||||
|
expect(session.web, same(connectedBrowser));
|
||||||
expect(platform.web.loads, isEmpty);
|
expect(platform.web.loads, isEmpty);
|
||||||
expect(session.selectedTerm, SchoolTerm(72));
|
expect(session.selectedTerm, SchoolTerm(72));
|
||||||
expect(session.pendingDraft, isNull);
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hepan_everyday/app/tp_startup.dart';
|
import 'package:hepan_everyday/app/tp_startup.dart';
|
||||||
|
import 'package:hepan_everyday/app/tp_launch_page.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('waiting initialization does not replace the native launch', (
|
testWidgets('waiting initialization keeps painted brand frames', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
@@ -16,13 +17,14 @@ void main() {
|
|||||||
child: const MaterialApp(home: Text('已准备好课表')),
|
child: const MaterialApp(home: Text('已准备好课表')),
|
||||||
).then((_) => startupFinished = true);
|
).then((_) => startupFinished = true);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(startupFinished, isFalse);
|
expect(startupFinished, isTrue);
|
||||||
|
expect(find.byType(TpLaunchPage), findsOneWidget);
|
||||||
expect(tester.binding.sendFramesToEngine, isTrue);
|
expect(tester.binding.sendFramesToEngine, isTrue);
|
||||||
expect(find.text('TPSchedule'), findsNothing);
|
expect(find.text('TPSchedule'), findsNothing);
|
||||||
expect(find.text('已准备好课表'), findsNothing);
|
expect(find.text('已准备好课表'), findsNothing);
|
||||||
ready.complete();
|
ready.complete();
|
||||||
await startup;
|
await startup;
|
||||||
await tester.pump();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('已准备好课表'), findsOneWidget);
|
expect(find.text('已准备好课表'), findsOneWidget);
|
||||||
expect(find.text('TPSchedule'), findsNothing);
|
expect(find.text('TPSchedule'), findsNothing);
|
||||||
expect(tester.binding.sendFramesToEngine, isTrue);
|
expect(tester.binding.sendFramesToEngine, isTrue);
|
||||||
@@ -37,7 +39,7 @@ void main() {
|
|||||||
ready: ready,
|
ready: ready,
|
||||||
child: const MaterialApp(home: Text('保存的课表')),
|
child: const MaterialApp(home: Text('保存的课表')),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('保存的课表'), findsOneWidget);
|
expect(find.text('保存的课表'), findsOneWidget);
|
||||||
expect(find.text('TPSchedule'), findsNothing);
|
expect(find.text('TPSchedule'), findsNothing);
|
||||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||||
@@ -57,7 +59,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
ready.completeError(StateError('storage failed'));
|
ready.completeError(StateError('storage failed'));
|
||||||
await startup;
|
await startup;
|
||||||
await tester.pump();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('恢复保存文件'), findsOneWidget);
|
expect(find.text('恢复保存文件'), findsOneWidget);
|
||||||
expect(tester.binding.sendFramesToEngine, isTrue);
|
expect(tester.binding.sendFramesToEngine, isTrue);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|||||||
Reference in New Issue
Block a user