diff --git a/README.md b/README.md index 68ef6ca..390b1b8 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,109 @@ AppUpgradeSimple.instance.configure(const UpgradeConfig( - **代码块**:`` `version 2.0` `` - **高亮**:`[特别注意]` +支持嵌套组合使用,例如: + +- `**[特别注意]**` +- `__**重要说明**__` +- `请使用 \`version 2.0\` 进行灰度验证` + ```text 1. 新增 **深色模式** 支持 2. 修复 `Login` 页面崩溃问题 3. [推荐] 性能大幅优化 ``` +### 头部说明块 `/{ ... /}` + +从当前版本开始,`updateContent` 支持一个可选的头部说明区域,用于展示“本次版本概述 / 重点提示 / 总结说明”等内容。 + +头部块语法: + +- 起始标记:`/{` +- 结束标记:`/}` +- 头部块是可选的 +- 头部块可以为空 +- 头部块内继续支持现有富文本标记与嵌套组合 + +#### 多行写法 + +```text +/{ +本次 **V2.2.0** 版本围绕[使用体验]与[学习管理]进行了多项优化。 +__请优先关注__ 工作录入与成长中心改动。 +/} +1. 新增草稿箱功能 +2. 优化多账号登录机制 +``` + +#### 单行写法 + +```text +/{ 本次版本重点优化登录体验与录入效率 /} +1. 新增草稿箱功能 +2. 修复已知问题 +``` + +#### 空头部块写法 + +```text +/{ /} +1. 第一条更新说明 +2. 第二条更新说明 +``` + +#### 头部块中的嵌套示例 + +```text +/{ +**重要内容** +__[特别注意]__ +`version 2.0` +普通说明与 **[高亮重点]** 混合展示 +/} +``` + +### 推荐的 `updateContent` 写法 + +建议服务端直接返回结构清晰的字符串,头部概述和正文分开: + +```text +/{ +本次 **V2.2.0** 版本围绕[使用体验]、[账号安全]、[家校联动]、[学习管理]等方面进行多项优化与功能新增,具体更新内容如下: +/} +1. 工作录入新增**草稿箱功能**,录入内容自动暂存; +2. 优化**多账号登录机制**,同一手机号绑定多个账号时可手动选择; +3. 全新上线**家校互动板块**; +4. **成长中心**全面调整优化。 +``` + +### 解析规则说明 + +- 只有当 `updateContent` 的第一段内容以 `/{` 开始时,才会被识别为头部说明块 +- 头部说明块中的内容会单独显示在正文上方 +- 头部中的 `** / __ / \` / []` 标记会继续走现有富文本渲染逻辑 +- 如果正文行本身已经包含 `1.`、`一、`、`-`、`•` 等前缀,插件不会再额外补一个圆点,避免双重列表样式 +- 如果缺少闭合标记 `/}`,插件会回退为普通正文处理,不会抛出异常 + +### 调试与预解析 + +如果你希望在展示前先检查解析结果,可以使用: + +```dart +final parsed = AppUpgradeSimple.parseUpdateContent(updateContent); + +debugPrint('hasExplicitHeaderBlock: ${parsed.hasExplicitHeaderBlock}'); +debugPrint('header: ${parsed.header}'); +debugPrint('bodyItems: ${parsed.bodyItems.map((e) => e.text).toList()}'); +``` + +返回结果说明: + +- `parsed.header`:头部说明内容 +- `parsed.hasExplicitHeaderBlock`:是否显式使用了 `/{ ... /}` 头部块 +- `parsed.bodyItems`:正文条目列表 +- `parsed.bodyItems[i].hasLeadingMarker`:该条正文是否已经自带列表前缀 + ### 对话框特性 - **发现新版本**(强制/非强制) diff --git a/android/build.gradle b/android/build.gradle index 65322be..12cae66 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,7 +9,7 @@ buildscript { } dependencies { - classpath("com.android.tools.build:gradle:8.7.3") + classpath("com.android.tools.build:gradle:8.9.1") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") } } diff --git a/example/.gitignore b/example/.gitignore index 79c113f..b6323af 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/example/.vscode/settings.json b/example/.vscode/settings.json new file mode 100644 index 0000000..7dd17c0 --- /dev/null +++ b/example/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.35.5" +} \ No newline at end of file diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts index ab39a10..43394ed 100644 --- a/example/android/settings.gradle.kts +++ b/example/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/example/lib/main.dart b/example/lib/main.dart index 956c79e..9478f68 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -67,13 +67,13 @@ class _HomePageState extends State { context: context, future: () async { final updateAppEvent = await _getUpdateAppEvent(); - print("获取最新版本: $updateAppEvent"); + debugPrint("获取最新版本: $updateAppEvent"); if (updateAppEvent == null) return null; return _convertToAppUpgradeVersion(updateAppEvent); }, - onComplete: (bool val) { - print("更新插件执行完成....: $val"); + onComplete: () { + debugPrint("更新插件执行完成...."); }, config: UpgradeConfig.development, // showNoUpdateToast: false, @@ -99,10 +99,14 @@ class _HomePageState extends State { "id": 708608950206533, "version": 307, "versionName": "1.0.9", - "remark": """ - **布置工作:**上级可以向分属团队`布置任务`,设定类型和时间。\n - **工作管理上:**__级可集中查看下级`所有工作`的详情与进度。__\n - **学生详情:**__在[学生管理]中添加详情页,支持记录家长信息、学生备注等,完善学生档案。__\n""", + "remark": """班级活动 / 文创更新,支持**多选班级**; +新增成长中心; +学科辅助可**全选学生**; +学习习惯抽查打分方式优化; +1 对 1 谈话选学生时,可直观查看本月已谈话次数; +上传图片支持多选,操作优化; +解决方案搜索移除年级限制; +总部长 / 部长可一键清除已读状态,布置任务可撤回重发。""", "imageBase": null, "updatetype": 1, "isActive": 1, @@ -158,7 +162,7 @@ class _HomePageState extends State { final upgradeInfo = await AppUpgradeSimple.instance.silentCheckUpdate( future: () async { final updateAppEvent = await _getUpdateAppEvent(); - print("获取最新版本: $updateAppEvent"); + debugPrint("获取最新版本: $updateAppEvent"); if (updateAppEvent == null) return null; return _convertToAppUpgradeVersion(updateAppEvent); @@ -167,7 +171,9 @@ class _HomePageState extends State { // 2. 根据结果处理 if (upgradeInfo != null && upgradeInfo.hasUpdate) { // 有新版本,可以显示红点或在用户点击时调用弹窗 - print('发现新版本: ${upgradeInfo.versionName}'); + debugPrint('发现新版本: ${upgradeInfo.versionName}'); + + if (!mounted) return; // 在需要展示弹窗的时机(如用户点击按钮): AppUpgradeSimple.instance.showPreparedUpgrade( diff --git a/example/pubspec.lock b/example/pubspec.lock index 3c74ba5..7acb6e5 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.flutter-io.cn" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -45,26 +45,26 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.8" + version: "1.0.9" dio: dependency: transitive description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.flutter-io.cn" source: hosted - version: "5.9.0" + version: "5.9.2" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.1" + version: "2.1.2" fake_async: dependency: transitive description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -224,10 +224,10 @@ packages: dependency: transitive description: name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.flutter-io.cn" source: hosted - version: "9.0.0" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -256,10 +256,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: @@ -373,10 +373,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.flutter-io.cn" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -445,10 +445,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" url: "https://pub.flutter-io.cn" source: hosted - version: "6.3.28" + version: "6.3.29" url_launcher_ios: dependency: transitive description: @@ -509,10 +509,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" url: "https://pub.flutter-io.cn" source: hosted - version: "15.0.2" + version: "15.1.0" web: dependency: transitive description: @@ -551,7 +551,7 @@ packages: path: ".." relative: true source: path - version: "1.0.0" + version: "1.0.5" sdks: dart: ">=3.9.0 <4.0.0" flutter: ">=3.35.0" diff --git a/lib/app_upgrade_simple.dart b/lib/app_upgrade_simple.dart index c163903..513ed58 100644 --- a/lib/app_upgrade_simple.dart +++ b/lib/app_upgrade_simple.dart @@ -23,8 +23,10 @@ class _SimpleAppUpgradePlugin { return AppUpgradePluginPlatform.instance.checkUpdate(url, params: params); } - Future downloadApk(String url, {Function(DownloadProgress)? onProgress}) { - return AppUpgradePluginPlatform.instance.downloadApk(url, onProgress: onProgress); + Future downloadApk(String url, + {Function(DownloadProgress)? onProgress}) { + return AppUpgradePluginPlatform.instance + .downloadApk(url, onProgress: onProgress); } Future installApk(String filePath) { @@ -32,7 +34,8 @@ class _SimpleAppUpgradePlugin { } Future goToAppStore(String url, {required BuildContext context}) { - return AppUpgradePluginPlatform.instance.goToAppStore(url, context: context); + return AppUpgradePluginPlatform.instance + .goToAppStore(url, context: context); } Future> getAppInfo() { @@ -110,6 +113,32 @@ class UpgradeConfig { ); } +class ParsedUpgradeContent { + const ParsedUpgradeContent({ + this.header, + this.hasExplicitHeaderBlock = false, + this.bodyItems = const [], + }); + + final String? header; + final bool hasExplicitHeaderBlock; + final List bodyItems; + + bool get hasHeader => header?.isNotEmpty ?? false; + + bool get hasBodyItems => bodyItems.isNotEmpty; +} + +class ParsedUpgradeContentItem { + const ParsedUpgradeContentItem({ + required this.text, + this.hasLeadingMarker = false, + }); + + final String text; + final bool hasLeadingMarker; +} + /// 简化版App升级管理器 /// 提供最简单的API,一行代码即可实现App升级功能 class AppUpgradeSimple { @@ -127,7 +156,8 @@ class AppUpgradeSimple { } @visibleForTesting - AppUpgradeSimple.private({_SimpleAppUpgradePlugin? plugin}) : _plugin = plugin ?? _SimpleAppUpgradePlugin.instance; + AppUpgradeSimple.private({_SimpleAppUpgradePlugin? plugin}) + : _plugin = plugin ?? _SimpleAppUpgradePlugin.instance; AppUpgradeSimple._() : _plugin = _SimpleAppUpgradePlugin.instance; @@ -157,7 +187,10 @@ class AppUpgradeSimple { if (downloadPath != null) { final dir = Directory(downloadPath); if (await dir.exists()) { - final files = await dir.list().where((file) => file.path.endsWith('.apk')).toList(); + final files = await dir + .list() + .where((file) => file.path.endsWith('.apk')) + .toList(); for (final file in files) { try { await file.delete(); @@ -232,12 +265,14 @@ class AppUpgradeSimple { }) async { // 使用传入的配置或默认配置 final effectiveConfig = config ?? _config; - final finalShowNoUpdateToast = showNoUpdateToast ?? effectiveConfig.showNoUpdateToast; + final finalShowNoUpdateToast = + showNoUpdateToast ?? effectiveConfig.showNoUpdateToast; final finalAutoInstall = autoInstall ?? effectiveConfig.autoInstall; try { assert(_canShowMaterialDialog(context), '请在 MaterialApp 环境内调用'); - final info = await _prepareUpgradeInfo(future: future, config: effectiveConfig); + final info = + await _prepareUpgradeInfo(future: future, config: effectiveConfig); if (info == null) { return; } @@ -281,7 +316,8 @@ class AppUpgradeSimple { }) async { final effectiveConfig = config ?? _config; try { - final info = await _prepareUpgradeInfo(future: future, config: effectiveConfig); + final info = + await _prepareUpgradeInfo(future: future, config: effectiveConfig); if (effectiveConfig.enableDebugLog) { if (info == null) { debugPrint('🔕 静默检查结果: 未返回版本信息'); @@ -377,13 +413,15 @@ class AppUpgradeSimple { if (versionBuildNumber > currentBuildNumber) { hasUpdate = true; } else { - if (versionName != null && compareVersionStrings(versionName, currentVersionName) > 0) { + if (versionName != null && + compareVersionStrings(versionName, currentVersionName) > 0) { hasUpdate = true; } } } else { // 只比较版本名 - if (versionName != null && compareVersionStrings(versionName, currentVersionName) > 0) { + if (versionName != null && + compareVersionStrings(versionName, currentVersionName) > 0) { hasUpdate = true; } } @@ -412,7 +450,8 @@ class AppUpgradeSimple { // 构建 UpgradeInfo // 兜底处理,避免 serverInfo 里的可空字段传入非空参数导致崩溃 final safeVersionName = serverInfo.versionName ?? currentVersionName; - final safeVersionBuildNumber = serverInfo.versionBuildNumber ?? currentBuildNumber; + final safeVersionBuildNumber = + serverInfo.versionBuildNumber ?? currentBuildNumber; return UpgradeInfo( hasUpdate: hasUpdate, @@ -428,7 +467,11 @@ class AppUpgradeSimple { apkMd5: serverInfo.apkMd5, appMarkets: serverInfo.appMarkets, supportedMethods: serverInfo.supportedMethods ?? - const [AppUpgradeMethod.market, AppUpgradeMethod.browser, AppUpgradeMethod.inApp], + const [ + AppUpgradeMethod.market, + AppUpgradeMethod.browser, + AppUpgradeMethod.inApp + ], ); } @@ -479,12 +522,43 @@ class AppUpgradeSimple { return await _plugin.getAppInfo(); } + static ParsedUpgradeContent parseUpdateContent(String content) { + final normalizedContent = + content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').trim(); + + if (normalizedContent.isEmpty) { + return const ParsedUpgradeContent(); + } + + final extraction = _extractUpgradeContentHeader( + normalizedContent.split('\n'), + ); + + final bodyItems = extraction.bodyLines + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .map( + (line) => ParsedUpgradeContentItem( + text: line, + hasLeadingMarker: _contentLineMarkerRegExp.hasMatch(line), + ), + ) + .toList(growable: false); + + return ParsedUpgradeContent( + header: extraction.header, + hasExplicitHeaderBlock: extraction.hasExplicitHeaderBlock, + bodyItems: bodyItems, + ); + } + static int compareVersionStrings(String v1, String v2) { try { final v1Parts = v1.split('.').map((e) => int.tryParse(e) ?? 0).toList(); final v2Parts = v2.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - final maxLength = v1Parts.length > v2Parts.length ? v1Parts.length : v2Parts.length; + final maxLength = + v1Parts.length > v2Parts.length ? v1Parts.length : v2Parts.length; for (int i = 0; i < maxLength; i++) { final part1 = i < v1Parts.length ? v1Parts[i] : 0; @@ -529,7 +603,8 @@ class AppUpgradeSimple { autoInstall: autoInstall, onUpdateLater: onUpdateLater, config: effectiveConfig, - showToast: (message) => _showToast(message, context, effectiveConfig), + showToast: (message) => + _showToast(message, context, effectiveConfig), ); } }, @@ -537,7 +612,8 @@ class AppUpgradeSimple { } /// 使用 Overlay 显示 Toast(不依赖 Scaffold) - void _showOverlayToast(BuildContext context, String message, UpgradeConfig config) { + void _showOverlayToast( + BuildContext context, String message, UpgradeConfig config) { if (!context.mounted) { debugPrint('Toast消息(context已卸载): $message'); return; @@ -572,7 +648,8 @@ class AppUpgradeSimple { } /// 尝试使用 ScaffoldMessenger 显示 SnackBar(如果可用) - void _tryShowSnackBar(BuildContext context, String message, UpgradeConfig config) { + void _tryShowSnackBar( + BuildContext context, String message, UpgradeConfig config) { if (!context.mounted) { _showOverlayToast(context, message, config); return; @@ -598,8 +675,9 @@ class AppUpgradeSimple { } // 优先使用根 context 的 ScaffoldMessenger - final messenger = - rootContext != null && rootContext.mounted ? ScaffoldMessenger.maybeOf(rootContext) : scaffoldMessenger; + final messenger = rootContext != null && rootContext.mounted + ? ScaffoldMessenger.maybeOf(rootContext) + : scaffoldMessenger; if (messenger == null) { _showOverlayToast(context, message, config); @@ -624,7 +702,8 @@ class AppUpgradeSimple { } /// 显示Toast提示 - void _showToast(String message, BuildContext context, [UpgradeConfig? config]) { + void _showToast(String message, BuildContext context, + [UpgradeConfig? config]) { final effectiveConfig = config ?? _config; if (effectiveConfig.customToast != null) { effectiveConfig.customToast!(message); @@ -637,7 +716,9 @@ class AppUpgradeSimple { bool _canShowMaterialDialog(BuildContext context) { if (!context.mounted) return false; try { - return Localizations.of(context, MaterialLocalizations) != null; + return Localizations.of( + context, MaterialLocalizations) != + null; } catch (_) { return false; } @@ -669,7 +750,8 @@ mixin _UpgradeDialogLogic on State { } void onAppLifecycleStateChanged(AppLifecycleState state) { - debugPrint('🔄 应用生命周期状态变化: $state, _isWaitingForInstallation=$_isWaitingForInstallation'); + debugPrint( + '🔄 应用生命周期状态变化: $state, _isWaitingForInstallation=$_isWaitingForInstallation'); if (_isWaitingForInstallation && state == AppLifecycleState.resumed) { debugPrint('⚡ 应用回到前台,检查安装状态'); @@ -726,12 +808,14 @@ mixin _UpgradeDialogLogic on State { if (!Platform.isAndroid || info.downloadUrl == null) return; if (!mounted) return; - final hasStorage = await PermissionHelper.checkAndRequestStoragePermission(context: context); + final hasStorage = await PermissionHelper.checkAndRequestStoragePermission( + context: context); if (!hasStorage) { showToast('缺少存储权限,无法下载'); return; } - await PermissionHelper.checkAndRequestNotificationPermission(context: context); + await PermissionHelper.checkAndRequestNotificationPermission( + context: context); setState(() { _isDownloading = true; @@ -794,7 +878,9 @@ mixin _UpgradeDialogLogic on State { if (config.requireInstallPermission) { debugPrint('🔐 检查安装权限(配置要求)'); - final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context); + final hasPermission = + await PermissionHelper.checkAndRequestInstallPermission( + context: context); if (!hasPermission) { if (mounted) { setState(() { @@ -862,7 +948,8 @@ mixin _UpgradeDialogLogic on State { Future _checkInstallationResult() async { if (!mounted || !_isWaitingForInstallation) { - debugPrint('跳过安装结果检查: mounted=$mounted, _isWaitingForInstallation=$_isWaitingForInstallation'); + debugPrint( + '跳过安装结果检查: mounted=$mounted, _isWaitingForInstallation=$_isWaitingForInstallation'); return; } @@ -871,23 +958,30 @@ mixin _UpgradeDialogLogic on State { try { final appInfo = await _plugin.getAppInfo(); final currentVersion = appInfo['version'] ?? ''; - final currentBuildNumber = int.tryParse(appInfo['buildNumber'] ?? '0') ?? 0; + final currentBuildNumber = + int.tryParse(appInfo['buildNumber'] ?? '0') ?? 0; debugPrint('📱 当前版本: $currentVersion, 构建号: $currentBuildNumber'); - debugPrint('🎯 目标版本: ${info.versionName}, 构建号: ${info.versionBuildNumber}'); + debugPrint( + '🎯 目标版本: ${info.versionName}, 构建号: ${info.versionBuildNumber}'); bool isUpdated = false; if (info.versionBuildNumber > 0) { if (currentBuildNumber > info.versionBuildNumber) { isUpdated = true; } else { - isUpdated = AppUpgradeSimple.compareVersionStrings(currentVersion, info.versionName) > 0; + isUpdated = AppUpgradeSimple.compareVersionStrings( + currentVersion, info.versionName) > + 0; } debugPrint( '📊 构建号比较: $currentBuildNumber vs ${info.versionBuildNumber}, 版本比较(如需): ${info.versionName} -> $isUpdated'); } else { - isUpdated = AppUpgradeSimple.compareVersionStrings(currentVersion, info.versionName) > 0; - debugPrint('📊 版本号比较: $currentVersion vs ${info.versionName} = $isUpdated'); + isUpdated = AppUpgradeSimple.compareVersionStrings( + currentVersion, info.versionName) > + 0; + debugPrint( + '📊 版本号比较: $currentVersion vs ${info.versionName} = $isUpdated'); } if (isUpdated) { @@ -941,7 +1035,9 @@ mixin _UpgradeDialogLogic on State { } if (_statusText == '权限被拒绝' && config.requireInstallPermission) { - final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context); + final hasPermission = + await PermissionHelper.checkAndRequestInstallPermission( + context: context); if (!hasPermission) { showToast('仍未获得安装权限,请在设置中手动开启'); return; @@ -1010,7 +1106,8 @@ mixin _UpgradeDialogLogic on State { if (info.isForceUpdate) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: colorScheme.error, borderRadius: BorderRadius.circular(4), @@ -1052,7 +1149,8 @@ mixin _UpgradeDialogLogic on State { icon: Icons.update, label: '已安装版本', // 显示版本号 - value: '${info.currentVersionName} +${info.currentBuildNumber}', + value: + '${info.currentVersionName} +${info.currentBuildNumber}', colorScheme: colorScheme, ), ), @@ -1116,8 +1214,8 @@ mixin _UpgradeDialogLogic on State { Widget _buildUpdateContent(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final changeItems = - info.updateContent.split(RegExp(r'\r?\n')).map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); + final parsedContent = + AppUpgradeSimple.parseUpdateContent(info.updateContent); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1154,7 +1252,7 @@ mixin _UpgradeDialogLogic on State { ), ), child: SingleChildScrollView( - child: changeItems.isEmpty + child: !parsedContent.hasBodyItems && !parsedContent.hasHeader ? _buildRichText( info.updateContent, colorScheme, @@ -1162,32 +1260,48 @@ mixin _UpgradeDialogLogic on State { : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: changeItems.asMap().entries.map((entry) { - final index = entry.key; - final line = entry.value; - return Container( - width: double.infinity, - margin: EdgeInsets.only( - bottom: index < changeItems.length - 1 ? 8 : 0, + children: [ + if (parsedContent.hasHeader) ...[ + _buildRichText( + parsedContent.header!, + colorScheme, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - margin: const EdgeInsets.only(top: 6), - width: 4, - height: 4, - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, + if (parsedContent.hasBodyItems) + const SizedBox(height: 10), + ], + ...parsedContent.bodyItems.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return Container( + width: double.infinity, + margin: EdgeInsets.only( + bottom: index < parsedContent.bodyItems.length - 1 + ? 8 + : 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!item.hasLeadingMarker) ...[ + Container( + margin: const EdgeInsets.only(top: 6), + width: 4, + height: 4, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: _buildRichText(item.text, colorScheme), ), - ), - const SizedBox(width: 8), - Expanded(child: _buildRichText(line, colorScheme)), - ], - ), - ); - }).toList(), + ], + ), + ); + }), + ], ), ), ), @@ -1271,10 +1385,12 @@ mixin _UpgradeDialogLogic on State { // [高亮] if (currentChar == '[') { - final innerResult = _parseRichTextInternal(text, styles, index + 1, ']'); + final innerResult = + _parseRichTextInternal(text, styles, index + 1, ']'); if (innerResult.closed) { flushBuffer(); - final innerText = text.substring(index + 1, innerResult.nextIndex - 1); + final innerText = + text.substring(index + 1, innerResult.nextIndex - 1); spans.addAll(_applyStyleToSpans( innerResult.spans, styles.highlightStyle, @@ -1310,10 +1426,12 @@ mixin _UpgradeDialogLogic on State { } // **粗体** else if (text.startsWith('**', index)) { - final innerResult = _parseRichTextInternal(text, styles, index + 2, '**'); + final innerResult = + _parseRichTextInternal(text, styles, index + 2, '**'); if (innerResult.closed) { flushBuffer(); - final innerText = text.substring(index + 2, innerResult.nextIndex - 2); + final innerText = + text.substring(index + 2, innerResult.nextIndex - 2); spans.addAll(_applyStyleToSpans( innerResult.spans, styles.boldStyle, @@ -1330,10 +1448,12 @@ mixin _UpgradeDialogLogic on State { } // __斜体__ else if (text.startsWith('__', index)) { - final innerResult = _parseRichTextInternal(text, styles, index + 2, '__'); + final innerResult = + _parseRichTextInternal(text, styles, index + 2, '__'); if (innerResult.closed) { flushBuffer(); - final innerText = text.substring(index + 2, innerResult.nextIndex - 2); + final innerText = + text.substring(index + 2, innerResult.nextIndex - 2); spans.addAll(_applyStyleToSpans( innerResult.spans, styles.italicStyle, @@ -1358,7 +1478,8 @@ mixin _UpgradeDialogLogic on State { return _RichTextParseResult(spans, index, false); } - List _applyStyleToSpans(List spans, TextStyle style, String fallbackText) { + List _applyStyleToSpans( + List spans, TextStyle style, String fallbackText) { if (spans.isEmpty) { return [ TextSpan( @@ -1372,8 +1493,10 @@ mixin _UpgradeDialogLogic on State { } TextSpan _mergeTextSpanStyle(TextSpan span, TextStyle style) { - final mergedChildren = - span.children?.map((child) => child is TextSpan ? _mergeTextSpanStyle(child, style) : child).toList(); + final mergedChildren = span.children + ?.map((child) => + child is TextSpan ? _mergeTextSpanStyle(child, style) : child) + .toList(); final mergedStyle = span.style != null ? style.merge(span.style) : style; @@ -1384,7 +1507,8 @@ mixin _UpgradeDialogLogic on State { ); } - Widget _buildEnhancedDownloadProgress(BuildContext context, ColorScheme colorScheme) { + Widget _buildEnhancedDownloadProgress( + BuildContext context, ColorScheme colorScheme) { final bool showRetryButton = _downloadedFilePath != null && !_isDownloading && !_isInstalling && @@ -1488,7 +1612,8 @@ mixin _UpgradeDialogLogic on State { child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: colorScheme.primaryContainer.withOpacity(0.2), + color: colorScheme.primaryContainer + .withOpacity(0.2), borderRadius: BorderRadius.circular(8), border: Border.all( color: colorScheme.primary.withOpacity(0.3), @@ -1517,7 +1642,8 @@ mixin _UpgradeDialogLogic on State { '系统将自动检测安装结果', style: TextStyle( fontSize: 11, - color: colorScheme.onSurface.withOpacity(0.7), + color: colorScheme.onSurface + .withOpacity(0.7), ), textAlign: TextAlign.center, ), @@ -1534,9 +1660,11 @@ mixin _UpgradeDialogLogic on State { child: ElevatedButton.icon( onPressed: _retryInstall, icon: Icon(_getRetryButtonIcon(), size: 16), - label: Text(_getRetryButtonText(), style: const TextStyle(fontSize: 12)), + label: Text(_getRetryButtonText(), + style: const TextStyle(fontSize: 12)), style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, backgroundColor: _getRetryButtonColor(colorScheme), @@ -1545,7 +1673,8 @@ mixin _UpgradeDialogLogic on State { ), ], ), - if (_isDownloading || (_downloadProgress > 0 && _downloadProgress < 1.0)) ...[ + if (_isDownloading || + (_downloadProgress > 0 && _downloadProgress < 1.0)) ...[ const SizedBox(height: 16), ClipRRect( borderRadius: BorderRadius.circular(4), @@ -1620,7 +1749,9 @@ mixin _UpgradeDialogLogic on State { return Icons.cancel_outlined; } else if (_statusText == '安装超时' || _statusText == '检测失败') { return Icons.schedule; - } else if (_statusText == '安装失败' || _statusText == '安装异常' || _statusText == '权限被拒绝') { + } else if (_statusText == '安装失败' || + _statusText == '安装异常' || + _statusText == '权限被拒绝') { return Icons.error_outline; } else if (_downloadProgress >= 1.0) { return Icons.check_circle_outline; @@ -1646,7 +1777,9 @@ mixin _UpgradeDialogLogic on State { return colorScheme.secondary.withOpacity(0.8); } else if (_statusText == '安装超时' || _statusText == '检测失败') { return colorScheme.secondary.withOpacity(0.7); - } else if (_statusText == '安装失败' || _statusText == '安装异常' || _statusText == '权限被拒绝') { + } else if (_statusText == '安装失败' || + _statusText == '安装异常' || + _statusText == '权限被拒绝') { return colorScheme.error; } else if (_downloadProgress >= 1.0) { return colorScheme.tertiary; @@ -1698,7 +1831,10 @@ mixin _UpgradeDialogLogic on State { return colorScheme.secondary; } else if (_statusText == '安装失败' || _statusText == '安装异常') { return colorScheme.error; - } else if (_statusText == '安装超时' || _statusText == '安装被取消' || _statusText == '检测失败' || _statusText == '等待安装中') { + } else if (_statusText == '安装超时' || + _statusText == '安装被取消' || + _statusText == '检测失败' || + _statusText == '等待安装中') { return colorScheme.secondary.withOpacity(0.8); } else if (_statusText == '请完成安装') { return colorScheme.secondary; @@ -1758,7 +1894,8 @@ mixin _UpgradeDialogLogic on State { Future _handleIosAction(BuildContext context) async { if (info.appStoreUrl != null) { - final success = await _plugin.goToAppStore(info.appStoreUrl!, context: context); + final success = + await _plugin.goToAppStore(info.appStoreUrl!, context: context); if (!success) { showToast('无法打开App Store,请稍后重试'); } @@ -1822,7 +1959,9 @@ mixin _UpgradeDialogLogic on State { debugPrint('配置的应用市场白名单: ${info.appMarkets}'); // 筛选出设备上已安装且在白名单中的应用市场 - final availableMarkets = info.appMarkets!.where((market) => installedMarkets.contains(market.name)).toList(); + final availableMarkets = info.appMarkets! + .where((market) => installedMarkets.contains(market.name)) + .toList(); debugPrint('可用的应用市场: $availableMarkets'); @@ -1843,7 +1982,8 @@ mixin _UpgradeDialogLogic on State { final appInfo = await _plugin.getAppInfo(); final pkg = appInfo['packageName'] ?? ''; if (pkg.isNotEmpty) { - final success = await _plugin.goToAppStore('market://details?id=$pkg', context: context); + final success = await _plugin.goToAppStore('market://details?id=$pkg', + context: context); if (!success) { showToast('当前APP没有上架当前设备对应的应用市场,请选择其他方式更新'); } @@ -1858,7 +1998,8 @@ mixin _UpgradeDialogLogic on State { // 移除关闭弹窗代码 } - Future _showDownloadChoiceSheet(List availableMethods) async { + Future _showDownloadChoiceSheet( + List availableMethods) async { if (!mounted) return; final choice = await showModalBottomSheet( @@ -1897,7 +2038,9 @@ mixin _UpgradeDialogLogic on State { Expanded( child: Text('选择更新方式', textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold))), ], ), Positioned( @@ -1913,23 +2056,30 @@ mixin _UpgradeDialogLogic on State { if (availableMethods.contains(AppUpgradeMethod.market)) ListTile( leading: const Icon(Icons.storefront_outlined), - title: const Text('前往应用市场更新', style: TextStyle(fontSize: 16)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: + const Text('前往应用市场更新', style: TextStyle(fontSize: 16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), onTap: () => Navigator.of(ctx).pop(AppUpgradeMethod.market), ), if (availableMethods.contains(AppUpgradeMethod.inApp)) ListTile( leading: const Icon(Icons.system_update), title: const Text('APP内更新', style: TextStyle(fontSize: 16)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), onTap: () => Navigator.of(ctx).pop(AppUpgradeMethod.inApp), ), if (availableMethods.contains(AppUpgradeMethod.browser)) ListTile( leading: const Icon(Icons.download_for_offline_outlined), - title: const Text('前往浏览器下载安装包', textAlign: TextAlign.left, style: TextStyle(fontSize: 16)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - onTap: () => Navigator.of(ctx).pop(AppUpgradeMethod.browser), + title: const Text('前往浏览器下载安装包', + textAlign: TextAlign.left, + style: TextStyle(fontSize: 16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + onTap: () => + Navigator.of(ctx).pop(AppUpgradeMethod.browser), ), const Divider(height: 24), SizedBox( @@ -1938,7 +2088,8 @@ mixin _UpgradeDialogLogic on State { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), backgroundColor: Colors.white, - foregroundColor: Theme.of(context).textTheme.bodyLarge?.color, + foregroundColor: + Theme.of(context).textTheme.bodyLarge?.color, elevation: 2, shadowColor: Colors.grey.withOpacity(0.5), shape: RoundedRectangleBorder( @@ -1947,7 +2098,9 @@ mixin _UpgradeDialogLogic on State { ), ), onPressed: () => Navigator.of(ctx).pop(), - child: const Text('取消', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + child: const Text('取消', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w500)), ), ), ], @@ -1993,7 +2146,8 @@ class _SimpleUpgradeDialog extends StatefulWidget { State<_SimpleUpgradeDialog> createState() => _SimpleUpgradeDialogState(); } -class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog> with _UpgradeDialogLogic, WidgetsBindingObserver { +class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog> + with _UpgradeDialogLogic, WidgetsBindingObserver { @override UpgradeInfo get info => widget.info; @override @@ -2068,11 +2222,13 @@ class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog> with _Upgrad crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), - _buildVersionInfoCard(context, Theme.of(context).colorScheme), + _buildVersionInfoCard( + context, Theme.of(context).colorScheme), const SizedBox(height: 16), _buildUpdateContent(context), if (_isDownloading || _downloadedFilePath != null) - _buildEnhancedDownloadProgress(context, Theme.of(context).colorScheme), + _buildEnhancedDownloadProgress( + context, Theme.of(context).colorScheme), ], ), ), @@ -2120,12 +2276,13 @@ class _ForceUpgradeDialog extends StatefulWidget { State<_ForceUpgradeDialog> createState() => _ForceUpgradeDialogState(); } -class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog> with _UpgradeDialogLogic, WidgetsBindingObserver { +class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog> + with _UpgradeDialogLogic, WidgetsBindingObserver { @override UpgradeInfo get info => widget.info; @override - void Function(String) get showToast => - (message) => AppUpgradeSimple.instance._showToast(message, context, widget.config); + void Function(String) get showToast => (message) => + AppUpgradeSimple.instance._showToast(message, context, widget.config); @override bool get autoInstall => widget.autoInstall; @override @@ -2175,7 +2332,8 @@ class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog> with _UpgradeD const Expanded( child: Text( '发现新版本', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.w600), textAlign: TextAlign.center, ), ), @@ -2190,11 +2348,13 @@ class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog> with _UpgradeD crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), - _buildVersionInfoCard(context, Theme.of(context).colorScheme), + _buildVersionInfoCard( + context, Theme.of(context).colorScheme), const SizedBox(height: 16), _buildUpdateContent(context), if (_isDownloading || _downloadedFilePath != null) - _buildEnhancedDownloadProgress(context, Theme.of(context).colorScheme), + _buildEnhancedDownloadProgress( + context, Theme.of(context).colorScheme), ], ), ), @@ -2247,7 +2407,8 @@ class _ToastWidget extends StatelessWidget { ); }, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(8), @@ -2324,3 +2485,105 @@ class _RichTextParseResult { const _RichTextParseResult(this.spans, this.nextIndex, this.closed); } + +final RegExp _contentLineMarkerRegExp = RegExp( + r'^(?:(?:\d+|[一二三四五六七八九十]+)[\.\、\.\)](?=\s|[^\d]|$)\s*|(?:[-*+•●○▪▫■□◆◇])(?=\s|$)\s*)', +); + +class _UpgradeContentHeaderExtraction { + const _UpgradeContentHeaderExtraction({ + required this.bodyLines, + this.header, + this.hasExplicitHeaderBlock = false, + }); + + final List bodyLines; + final String? header; + final bool hasExplicitHeaderBlock; +} + +_UpgradeContentHeaderExtraction _extractUpgradeContentHeader( + List rawLines, +) { + const startMarker = '/{'; + const endMarker = '/}'; + + if (rawLines.isEmpty) { + return const _UpgradeContentHeaderExtraction(bodyLines: []); + } + + final firstLine = rawLines.first.trim(); + if (!firstLine.startsWith(startMarker)) { + return _UpgradeContentHeaderExtraction( + bodyLines: List.from(rawLines), + ); + } + + final headerLines = []; + final bodyLines = []; + var currentLine = firstLine.substring(startMarker.length).trimLeft(); + var endFound = false; + var endLineIndex = 0; + var pendingBlankLine = false; + + void addHeaderLine(String value) { + final normalizedLine = value.trim(); + if (normalizedLine.isEmpty) { + pendingBlankLine = headerLines.isNotEmpty; + return; + } + + if (pendingBlankLine && + headerLines.isNotEmpty && + headerLines.last.isNotEmpty) { + headerLines.add(''); + } + headerLines.add(normalizedLine); + pendingBlankLine = false; + } + + bool closeHeader(String value) { + final endIndex = value.indexOf(endMarker); + if (endIndex < 0) { + return false; + } + + addHeaderLine(value.substring(0, endIndex)); + final trailingBody = value.substring(endIndex + endMarker.length).trim(); + if (trailingBody.isNotEmpty) { + bodyLines.add(trailingBody); + } + endFound = true; + return true; + } + + if (!closeHeader(currentLine) && currentLine.isNotEmpty) { + addHeaderLine(currentLine); + } + + for (var index = 1; index < rawLines.length && !endFound; index++) { + currentLine = rawLines[index].trim(); + endLineIndex = index; + if (closeHeader(currentLine)) { + break; + } + + addHeaderLine(currentLine); + } + + if (!endFound) { + return _UpgradeContentHeaderExtraction( + bodyLines: List.from(rawLines), + ); + } + + if (endLineIndex + 1 < rawLines.length) { + bodyLines.addAll(rawLines.sublist(endLineIndex + 1)); + } + + return _UpgradeContentHeaderExtraction( + bodyLines: bodyLines, + header: headerLines.isEmpty ? null : headerLines.join('\n'), + hasExplicitHeaderBlock: true, + ); +} diff --git a/test/app_upgrade_simple_dialog_test.dart b/test/app_upgrade_simple_dialog_test.dart new file mode 100644 index 0000000..d0a30f6 --- /dev/null +++ b/test/app_upgrade_simple_dialog_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yx_app_upgrade_flutter/app_upgrade_simple.dart'; +import 'package:yx_app_upgrade_flutter/models/upgrade_info.dart'; + +void main() { + group('showPreparedUpgrade', () { + testWidgets('renders header block and body content without crashing', + (WidgetTester tester) async { + var updateLaterCalled = false; + + await tester.pumpWidget( + _DialogTestHost( + info: _buildUpgradeInfo( + updateContent: ''' +/{ +本次 **V2.2.0** 版本聚焦[使用体验]优化。 +__支持嵌套__,例如 **[特别注意]** 与 `version 2.0` +/} +1. **第一条说明** +普通补充说明 +''', + ), + onUpdateLater: () { + updateLaterCalled = true; + }, + ), + ); + + await tester.tap(find.text('open-upgrade-dialog')); + await tester.pumpAndSettle(); + + expect(find.text('发现新版本'), findsOneWidget); + expect(find.text('更新内容'), findsOneWidget); + expect(_richTextPlainTexts(tester), + contains(contains('本次 V2.2.0 版本聚焦使用体验优化。'))); + expect(_richTextPlainTexts(tester), + contains(contains('支持嵌套,例如 特别注意 与 version 2.0'))); + expect(_richTextPlainTexts(tester), contains(contains('1. 第一条说明'))); + expect(_richTextPlainTexts(tester), contains(contains('普通补充说明'))); + + await tester.tap(find.text('稍后更新')); + await tester.pumpAndSettle(); + + expect(updateLaterCalled, isTrue); + expect(find.text('发现新版本'), findsNothing); + }); + + testWidgets('skips dialog when hasUpdate is false', + (WidgetTester tester) async { + await tester.pumpWidget( + _DialogTestHost( + info: _buildUpgradeInfo( + hasUpdate: false, + updateContent: '当前已经是最新版本', + ), + ), + ); + + await tester.tap(find.text('open-upgrade-dialog')); + await tester.pumpAndSettle(); + + expect(find.text('发现新版本'), findsNothing); + expect(find.text('更新内容'), findsNothing); + }); + + testWidgets('handles explicit but empty header block in dialog', + (WidgetTester tester) async { + await tester.pumpWidget( + _DialogTestHost( + info: _buildUpgradeInfo( + updateContent: ''' +/{ /} +- 第一条说明 +第二条说明 +''', + ), + ), + ); + + await tester.tap(find.text('open-upgrade-dialog')); + await tester.pumpAndSettle(); + + expect(find.text('发现新版本'), findsOneWidget); + expect(_richTextPlainTexts(tester), contains(contains('- 第一条说明'))); + expect(_richTextPlainTexts(tester), contains(contains('第二条说明'))); + }); + + testWidgets('renders the final growth-center line as update content', + (WidgetTester tester) async { + await tester.pumpWidget( + _DialogTestHost( + info: _buildUpgradeInfo( + updateContent: ''' +/{本次 **V2.2.0** 版本围绕[使用体验]、[账号安全]、[家校联动]、[学习管理]等方面进行多项优化与功能新增,具体更新内容如下:/} +工作录入新增**草稿箱功能**,录入内容自动暂存,录入途中临时退出、意外关闭页面,都不会丢失填写数据,有效避免重复录入,提升使用效率; +优化**多账号登录机制**,若同一手机号绑定多个账号,登录时需手动选择本次要登录的对应账号,区分账号使用,保障使用精准性; +全新上线**家校互动板块**,新增家长会、社群运营、线上家访特色功能,搭建高效家校沟通渠道,助力家校协同共育; +**成长中心**全面调整优化:成长中心入口迁移至工作台页面,升级全局搜索能力,章节内容改为结构化排版展示;优化视频播放逻辑与播放限制规则,同时新增学习过程监管功能,强化学习管控。 +''', + ), + ), + ); + + await tester.tap(find.text('open-upgrade-dialog')); + await tester.pumpAndSettle(); + + expect(find.text('发现新版本'), findsOneWidget); + expect( + _richTextPlainTexts(tester), + contains( + contains( + '成长中心全面调整优化:成长中心入口迁移至工作台页面,升级全局搜索能力,章节内容改为结构化排版展示;优化视频播放逻辑与播放限制规则,同时新增学习过程监管功能,强化学习管控。', + ), + ), + ); + }); + + testWidgets('renders supported rich text markers correctly on first lines', + (WidgetTester tester) async { + await tester.pumpWidget( + _DialogTestHost( + info: _buildUpgradeInfo( + updateContent: ''' +/{**头部粗体** /} +**粗体首行** +__斜体第二行__ +`代码第三行` +[高亮第四行] +**[组合重点]** +''', + ), + ), + ); + + await tester.tap(find.text('open-upgrade-dialog')); + await tester.pumpAndSettle(); + + final plainTexts = _richTextPlainTexts(tester).toList(); + + expect(find.text('发现新版本'), findsOneWidget); + expect( + plainTexts, + contains(contains('头部粗体')), + ); + expect( + plainTexts, + contains(contains('粗体首行')), + ); + expect( + plainTexts, + contains(contains('斜体第二行')), + ); + expect( + plainTexts, + contains(contains('代码第三行')), + ); + expect( + plainTexts, + contains(contains('高亮第四行')), + ); + expect( + plainTexts, + contains(contains('组合重点')), + ); + }); + }); +} + +class _DialogTestHost extends StatelessWidget { + const _DialogTestHost({ + required this.info, + this.onUpdateLater, + }); + + final UpgradeInfo info; + final VoidCallback? onUpdateLater; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: ElevatedButton( + onPressed: () { + AppUpgradeSimple.instance.showPreparedUpgrade( + context: context, + info: info, + onUpdateLater: onUpdateLater, + config: const UpgradeConfig( + showNoUpdateToast: false, + autoInstall: false, + enableDebugLog: false, + ), + ); + }, + child: const Text('open-upgrade-dialog'), + ), + ); + }, + ), + ), + ); + } +} + +UpgradeInfo _buildUpgradeInfo({ + bool hasUpdate = true, + String updateContent = '默认更新说明', +}) { + return UpgradeInfo( + hasUpdate: hasUpdate, + isForceUpdate: false, + versionBuildNumber: 200, + versionName: '2.0.0', + updateContent: updateContent, + currentBuildNumber: 100, + currentVersionName: '1.0.0', + ); +} + +Iterable _richTextPlainTexts(WidgetTester tester) { + return tester + .widgetList(find.byType(RichText)) + .map((widget) => widget.text.toPlainText()); +} diff --git a/test/app_upgrade_simple_parse_test.dart b/test/app_upgrade_simple_parse_test.dart new file mode 100644 index 0000000..216ac96 --- /dev/null +++ b/test/app_upgrade_simple_parse_test.dart @@ -0,0 +1,284 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yx_app_upgrade_flutter/app_upgrade_simple.dart'; + +void main() { + group('parseUpdateContent', () { + test('extracts header block and preserves nested markers', () { + const content = ''' +/{ +本次 **V2.2.0** 版本聚焦[使用体验]优化。 +__支持嵌套__,例如 **[特别注意]** 与 `version 2.0` +/} +1. 第一条说明 +2. 第二条说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect( + parsed.header, + '本次 **V2.2.0** 版本聚焦[使用体验]优化。\n__支持嵌套__,例如 **[特别注意]** 与 `version 2.0`', + ); + expect(parsed.bodyItems, hasLength(2)); + expect(parsed.bodyItems.first.text, '1. 第一条说明'); + expect(parsed.bodyItems.first.hasLeadingMarker, isTrue); + expect(parsed.bodyItems.last.text, '2. 第二条说明'); + }); + + test('supports inline header block', () { + const content = ''' +/{ 更新内容如下: /} +- 第一条说明 +普通说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect(parsed.header, '更新内容如下:'); + expect(parsed.bodyItems, hasLength(2)); + expect(parsed.bodyItems.first.hasLeadingMarker, isTrue); + expect(parsed.bodyItems.last.hasLeadingMarker, isFalse); + }); + + test('supports explicit but empty header block', () { + const content = ''' +/{ /} +正文说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect(parsed.header, isNull); + expect(parsed.bodyItems, hasLength(1)); + expect(parsed.bodyItems.first.text, '正文说明'); + }); + + test('keeps original content when header block is missing', () { + const content = ''' +**重要内容** +第二行说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isFalse); + expect(parsed.header, isNull); + expect(parsed.bodyItems, hasLength(2)); + expect(parsed.bodyItems.first.text, '**重要内容**'); + expect(parsed.bodyItems.last.text, '第二行说明'); + }); + + test('preserves blank lines inside header block', () { + const content = ''' +/{ +第一段说明 + +第二段说明 +/} +正文说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect(parsed.header, '第一段说明\n\n第二段说明'); + expect(parsed.bodyItems, hasLength(1)); + expect(parsed.bodyItems.first.text, '正文说明'); + }); + + test('falls back to original content when header block is not closed', () { + const content = ''' +/{ +第一段说明 +正文说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isFalse); + expect(parsed.header, isNull); + expect(parsed.bodyItems, hasLength(3)); + expect(parsed.bodyItems[0].text, '/{'); + expect(parsed.bodyItems[1].text, '第一段说明'); + expect(parsed.bodyItems[2].text, '正文说明'); + }); + + test('supports trailing body text on the same line as header end marker', + () { + const content = ''' +/{ 头部说明 /} 1. 第一条说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect(parsed.header, '头部说明'); + expect(parsed.bodyItems, hasLength(1)); + expect(parsed.bodyItems.first.text, '1. 第一条说明'); + expect(parsed.bodyItems.first.hasLeadingMarker, isTrue); + }); + + test('normalizes windows line endings', () { + const content = '/{\r\n头部说明\r\n/}\r\n- 第一条\r\n普通说明\r\n'; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect(parsed.header, '头部说明'); + expect(parsed.bodyItems, hasLength(2)); + expect(parsed.bodyItems.first.text, '- 第一条'); + expect(parsed.bodyItems.last.text, '普通说明'); + }); + + test('detects multiple supported leading marker styles', () { + const content = ''' +1. 数字序号 +一、中文序号 +- 无序列表 +• 特殊符号列表 +普通段落 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isFalse); + expect(parsed.bodyItems, hasLength(5)); + expect(parsed.bodyItems[0].hasLeadingMarker, isTrue); + expect(parsed.bodyItems[1].hasLeadingMarker, isTrue); + expect(parsed.bodyItems[2].hasLeadingMarker, isTrue); + expect(parsed.bodyItems[3].hasLeadingMarker, isTrue); + expect(parsed.bodyItems[4].hasLeadingMarker, isFalse); + }); + + test('does not treat later header markers inside body as header block', () { + const content = ''' +普通说明 +/{ 这不是头部 /} +第二行正文 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isFalse); + expect(parsed.header, isNull); + expect(parsed.bodyItems, hasLength(3)); + expect(parsed.bodyItems[1].text, '/{ 这不是头部 /}'); + }); + + test('returns empty result for blank content', () { + final parsed = AppUpgradeSimple.parseUpdateContent(' \n \r\n '); + + expect(parsed.hasExplicitHeaderBlock, isFalse); + expect(parsed.header, isNull); + expect(parsed.bodyItems, isEmpty); + }); + + test('keeps the final growth-center line as a separate body item', () { + const content = ''' +/{本次 **V2.2.0** 版本围绕[使用体验]、[账号安全]、[家校联动]、[学习管理]等方面进行多项优化与功能新增,具体更新内容如下:/} +工作录入新增**草稿箱功能**,录入内容自动暂存,录入途中临时退出、意外关闭页面,都不会丢失填写数据,有效避免重复录入,提升使用效率; +优化**多账号登录机制**,若同一手机号绑定多个账号,登录时需手动选择本次要登录的对应账号,区分账号使用,保障使用精准性; +全新上线**家校互动板块**,新增家长会、社群运营、线上家访特色功能,搭建高效家校沟通渠道,助力家校协同共育; +**成长中心**全面调整优化:成长中心入口迁移至工作台页面,升级全局搜索能力,章节内容改为结构化排版展示;优化视频播放逻辑与播放限制规则,同时新增学习过程监管功能,强化学习管控。 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect( + parsed.header, + '本次 **V2.2.0** 版本围绕[使用体验]、[账号安全]、[家校联动]、[学习管理]等方面进行多项优化与功能新增,具体更新内容如下:', + ); + expect(parsed.bodyItems, hasLength(4)); + expect(parsed.bodyItems[0].text, startsWith('工作录入新增**草稿箱功能**')); + expect(parsed.bodyItems[1].text, startsWith('优化**多账号登录机制**')); + expect(parsed.bodyItems[2].text, startsWith('全新上线**家校互动板块**')); + expect( + parsed.bodyItems[3].text, + '**成长中心**全面调整优化:成长中心入口迁移至工作台页面,升级全局搜索能力,章节内容改为结构化排版展示;优化视频播放逻辑与播放限制规则,同时新增学习过程监管功能,强化学习管控。', + ); + }); + + test('does not treat bold marker at line start as list marker', () { + const content = ''' +**成长中心**全面调整优化:成长中心入口迁移至工作台页面,升级全局搜索能力。 +* 合法无序列表项 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.bodyItems, hasLength(2)); + expect(parsed.bodyItems.first.text, + '**成长中心**全面调整优化:成长中心入口迁移至工作台页面,升级全局搜索能力。'); + expect(parsed.bodyItems.first.hasLeadingMarker, isFalse); + expect(parsed.bodyItems.last.text, '* 合法无序列表项'); + expect(parsed.bodyItems.last.hasLeadingMarker, isTrue); + }); + + test( + 'supports all rich text markers when they appear on the first body line', + () { + const content = ''' +**粗体首行** +__斜体次行__ +`代码第三行` +[高亮第四行] +**[组合重点]** +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isFalse); + expect(parsed.bodyItems, hasLength(5)); + expect(parsed.bodyItems[0].text, '**粗体首行**'); + expect(parsed.bodyItems[1].text, '__斜体次行__'); + expect(parsed.bodyItems[2].text, '`代码第三行`'); + expect(parsed.bodyItems[3].text, '[高亮第四行]'); + expect(parsed.bodyItems[4].text, '**[组合重点]**'); + expect( + parsed.bodyItems.every((item) => item.hasLeadingMarker == false), + isTrue, + ); + }); + + test('supports rich text markers on the first line inside header block', + () { + const content = ''' +/{ +**头部粗体** +__[头部高亮斜体]__ +`头部代码` +/} +正文说明 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.hasExplicitHeaderBlock, isTrue); + expect( + parsed.header, + '**头部粗体**\n__[头部高亮斜体]__\n`头部代码`', + ); + expect(parsed.bodyItems, hasLength(1)); + expect(parsed.bodyItems.first.text, '正文说明'); + }); + + test('does not treat version-like prefix as ordered marker', () { + const content = ''' +1.0版本修复说明 +2. 第一条真正的序号内容 +'''; + + final parsed = AppUpgradeSimple.parseUpdateContent(content); + + expect(parsed.bodyItems, hasLength(2)); + expect(parsed.bodyItems.first.text, '1.0版本修复说明'); + expect(parsed.bodyItems.first.hasLeadingMarker, isFalse); + expect(parsed.bodyItems.last.text, '2. 第一条真正的序号内容'); + expect(parsed.bodyItems.last.hasLeadingMarker, isTrue); + }); + }); +}