diff --git a/README.md b/README.md index ebf6ebb..0205d46 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 @@ -20,6 +20,8 @@ 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。 ## 安装与学校登录 diff --git a/docs/tpschedule-0.6.1-validation.md b/docs/tpschedule-0.6.1-validation.md new file mode 100644 index 0000000..6b805e2 --- /dev/null +++ b/docs/tpschedule-0.6.1-validation.md @@ -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)。 diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml index 6bd4fdb..79bdbbf 100644 --- a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,5 +1,5 @@ - - + + diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml index 6bd4fdb..79bdbbf 100644 --- a/mobile/android/app/src/main/res/drawable/launch_background.xml +++ b/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -1,5 +1,5 @@ - - + + diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index d17938f..d47ac89 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -57,7 +57,7 @@ class AppController extends ChangeNotifier { lastError = null; } - Future initialize() => _run(() async { + Future 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 restoreBackup() => _run(() async { snapshot = await store.restoreBackup(); diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index c238c39..f73a03a 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appVersion = '0.6.0'; -const appBuildNumber = 15; +const appVersion = '0.6.1'; +const appBuildNumber = 16; diff --git a/mobile/lib/app/tp_launch_page.dart b/mobile/lib/app/tp_launch_page.dart index a06c236..b855b89 100644 --- a/mobile/lib/app/tp_launch_page.dart +++ b/mobile/lib/app/tp_launch_page.dart @@ -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( - 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( + 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, + ), + ), + ], ), ), - ], + ), ), ), - ), - ), - ), - ); + ); } diff --git a/mobile/lib/app/tp_startup.dart b/mobile/lib/app/tp_startup.dart index 21af5b8..ae073f9 100644 --- a/mobile/lib/app/tp_startup.dart +++ b/mobile/lib/app/tp_startup.dart @@ -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 launchApplication({ - required Future ready, - required Widget child, + Future? ready, + Widget? child, + Future 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 _legacyReady(Future 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( - 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 Function() bootstrap; + final VoidCallback? onHomeReady; + @override + State createState() => _TpStartupState(); +} + +class _TpStartupState extends State + 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.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( + 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, ), ); } diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index f319d79..63c959a 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -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; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 90fba03..1ac8209 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); - tzdata.initializeTimeZones(); - final selection = ValueNotifier(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(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()); + }, ); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a836a94..ea67128 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: diff --git a/mobile/test/app/startup_data_test.dart b/mobile/test/app/startup_data_test.dart new file mode 100644 index 0000000..001ce0e --- /dev/null +++ b/mobile/test/app/startup_data_test.dart @@ -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(); + @override + Future reconcile( + List 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.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 load() => Future.error(const FormatException('invalid')); +} diff --git a/mobile/test/school/lazy_school_session_test.dart b/mobile/test/school/lazy_school_session_test.dart new file mode 100644 index 0000000..2c2d343 --- /dev/null +++ b/mobile/test/school/lazy_school_session_test.dart @@ -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(); + } + }, + ); +} diff --git a/mobile/test/school/term_selection_test.dart b/mobile/test/school/term_selection_test.dart index 7b12a21..e95e5ce 100644 --- a/mobile/test/school/term_selection_test.dart +++ b/mobile/test/school/term_selection_test.dart @@ -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); diff --git a/mobile/test/widgets/startup_slow_test.dart b/mobile/test/widgets/startup_slow_test.dart new file mode 100644 index 0000000..233e6fe --- /dev/null +++ b/mobile/test/widgets/startup_slow_test.dart @@ -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(); + 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()); + } + }, + ); +} diff --git a/mobile/test/widgets/startup_transition_test.dart b/mobile/test/widgets/startup_transition_test.dart new file mode 100644 index 0000000..f587ce8 --- /dev/null +++ b/mobile/test/widgets/startup_transition_test.dart @@ -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(); + 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( + 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(); + 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); + }, + ); +} diff --git a/mobile/test/widgets/tp_startup_test.dart b/mobile/test/widgets/tp_startup_test.dart index b1da47c..1f8e574 100644 --- a/mobile/test/widgets/tp_startup_test.dart +++ b/mobile/test/widgets/tp_startup_test.dart @@ -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);