import 'dart:io' show File; import 'package:app_settings/app_settings.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../providers/app_version_provider.dart'; /// 本进程内缓存已下载的安装包路径;弹窗销毁后仍可避免「从设置返回再点安装」重复下包。 class _UpdateApkPathCache { _UpdateApkPathCache._(); static String? _key; static String? _path; static String _composeKey(VersionCheckResult r) { final url = r.version.downloadUrl.trim(); final ver = r.version.newVersion.trim(); return '$url|$ver'; } static String? peekPath(VersionCheckResult r) { final want = _composeKey(r); if (_path != null && _key == want) { return _path; } return null; } static void remember(VersionCheckResult r, String absolutePath) { _key = _composeKey(r); _path = absolutePath; } static void forget() { _key = null; _path = null; } } /// 版本更新弹窗。 /// /// Android:支持应用内下载 APK 并拉起系统安装界面,并提供「浏览器打开」备用入口。 /// iOS:仅系统浏览器跳转下载页(外链),无应用内 APK 流程。 class UpdateDialog extends StatefulWidget { const UpdateDialog({super.key, required this.result}); final VersionCheckResult result; /// 显示版本更新弹窗。 static void show(BuildContext context, VersionCheckResult result) { showDialog( context: context, barrierDismissible: !result.isForce, builder: (_) => UpdateDialog(result: result), ); } @override State createState() => _UpdateDialogState(); } class _UpdateDialogState extends State { final CancelToken _cancelToken = CancelToken(); bool _downloading = false; double? _downloadProgress; VersionCheckResult get result => widget.result; bool get _isAndroid => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; @override void dispose() { _cancelToken.cancel(); super.dispose(); } Uri? _parseDownloadUri() { final url = result.version.downloadUrl.trim(); if (url.isEmpty) { return null; } final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme) { return null; } final s = uri.scheme.toLowerCase(); if (s != 'http' && s != 'https') { return null; } if (uri.host.isEmpty) { return null; } return uri; } Future _launchBrowser(AppLocalizations l10n) async { final uri = _parseDownloadUri(); if (uri == null) { return; } try { final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!ok && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.cannotOpenLink)), ); } } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.cannotOpenLink)), ); } } } Future _downloadAndInstallApk(AppLocalizations l10n) async { final uri = _parseDownloadUri(); if (uri == null) { return; } try { final dir = await getTemporaryDirectory(); final defaultPath = '${dir.path}/coinvision_upgrade.apk'; var cachedPath = _UpdateApkPathCache.peekPath(result); if (cachedPath != null && !_cachedApkStillOnDisk(cachedPath)) { _UpdateApkPathCache.forget(); cachedPath = null; } final savePath = cachedPath ?? defaultPath; final needDownload = cachedPath == null; if (needDownload) { final dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(minutes: 30), ), ); setState(() { _downloading = true; _downloadProgress = 0; }); await dio.download( uri.toString(), savePath, cancelToken: _cancelToken, onReceiveProgress: (received, total) { if (!mounted) { return; } if (total <= 0) { setState(() { _downloadProgress = null; }); } else { setState(() { _downloadProgress = received / total; }); } }, ); _UpdateApkPathCache.remember(result, savePath); if (!mounted) { return; } setState(() { _downloading = false; _downloadProgress = null; }); } if (!mounted) { return; } final permitted = await _ensureInstallPackagesPermission(l10n); if (!permitted || !mounted) { return; } final openRes = await OpenFilex.open( savePath, type: 'application/vnd.android.package-archive', ); if (!mounted) { return; } if (openRes.type != ResultType.done) { if (openRes.type == ResultType.fileNotFound) { _UpdateApkPathCache.forget(); } final msg = openRes.type == ResultType.permissionDenied ? l10n.installPermissionRequired : l10n.upgradeDownloadOrInstallFailed; ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar( content: Text(msg), action: SnackBarAction( label: l10n.goToSettings, onPressed: () { AppSettings.openAppSettings( type: AppSettingsType.manageUnknownAppSources, asAnotherTask: true, ); }, ), ), ); } } on DioException catch (_) { if (mounted) { setState(() { _downloading = false; _downloadProgress = null; }); _UpdateApkPathCache.forget(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.upgradeDownloadOrInstallFailed)), ); } } catch (_) { if (mounted) { setState(() { _downloading = false; _downloadProgress = null; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.upgradeDownloadOrInstallFailed)), ); } } } /// 临时目录 APK 可能被系统清理;路径在缓存中但文件已删除时不应跳过下载。 bool _cachedApkStillOnDisk(String absolutePath) { try { return File(absolutePath).existsSync(); } catch (_) { return false; } } Future _ensureInstallPackagesPermission(AppLocalizations l10n) async { var status = await Permission.requestInstallPackages.status; if (status.isGranted) { return true; } status = await Permission.requestInstallPackages.request(); if (status.isGranted) { return true; } // MIUI / 部分国产 ROM:系统不会弹窗,需在「允许安装未知应用」中为本应用单独放行。 if (!mounted) { return false; } ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar( content: Text(l10n.installPermissionRequired), duration: const Duration(seconds: 8), action: SnackBarAction( label: l10n.goToSettings, onPressed: () { AppSettings.openAppSettings( type: AppSettingsType.manageUnknownAppSources, asAnotherTask: true, ); }, ), ), ); return false; } Widget _buildActionArea( BuildContext context, ColorScheme cs, AppLocalizations l10n, ) { if (!_isAndroid) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FilledButton( onPressed: () => _launchBrowser(l10n), style: FilledButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 14), ), child: Text( l10n.updateNow, style: const TextStyle(fontWeight: FontWeight.w600), ), ), if (!result.isForce) ...[ const SizedBox(height: 8), TextButton( onPressed: () => Navigator.of(context).pop(), child: Text( l10n.remindLater, style: TextStyle(color: cs.onSurface.withAlpha(153)), ), ), ], ], ); } final primaryStyle = FilledButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 14), disabledBackgroundColor: AppColors.brand.withAlpha(120), disabledForegroundColor: Colors.black.withAlpha(120), ); final secondaryOutline = OutlinedButton.styleFrom( foregroundColor: cs.onSurface.withAlpha(230), side: BorderSide(color: cs.outline.withAlpha(180)), padding: const EdgeInsets.symmetric(vertical: 12), ); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ FilledButton( onPressed: _downloading ? null : () => _downloadAndInstallApk(l10n), style: primaryStyle, child: Text( l10n.updateInAppInstall, style: const TextStyle(fontWeight: FontWeight.w600), ), ), const SizedBox(height: 10), if (result.isForce) OutlinedButton( onPressed: _downloading ? null : () => _launchBrowser(l10n), style: secondaryOutline, child: Text(l10n.updateOpenInBrowser), ) else // 勿用 stretch:外层 Column 为 stretch 时,子 Row 在交叉轴上收到 maxHeight=∞, // stretch 会生成 tightFor(height: ∞) 触发 BoxConstraints forces an infinite height。 Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: TextButton( onPressed: _downloading ? null : () => Navigator.of(context).pop(), style: TextButton.styleFrom( foregroundColor: cs.onSurface.withAlpha(153), padding: const EdgeInsets.symmetric(vertical: 12), ), child: Text( l10n.remindLater, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: _downloading ? null : () => _launchBrowser(l10n), style: secondaryOutline, child: Text( l10n.updateOpenInBrowser, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ], ), ], ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final version = result.version; // [Dialog] 默认仅 minWidth≈280,maxWidth 为无限;Column + stretch 时子组件(如按钮 min 宽为 ∞) // 会破坏约束断言。限制 maxWidth/maxHeight,由 Column(mainAxis.min) 按内容高度收缩,避免全屏留白与崩溃。 final mq = MediaQuery.sizeOf(context); final maxDialogW = mq.width <= 560 ? mq.width - 48.0 : 560.0; final maxDialogH = mq.height * 0.85; return PopScope( canPop: !result.isForce, child: Dialog( backgroundColor: cs.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), clipBehavior: Clip.antiAlias, constraints: BoxConstraints( minWidth: 280, maxWidth: maxDialogW.clamp(280.0, 560.0), maxHeight: maxDialogH, ), child: Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon(Icons.system_update, color: AppColors.brand, size: 24), const SizedBox(width: 8), Expanded( child: Text( l10n.newVersionFound, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 16), Text( '${l10n.latestVersion}:v${version.newVersion}', style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Text( '${l10n.currentVersion}:v${result.currentVersion}', style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13, ), ), const SizedBox(height: 16), if (version.updateDescription.isNotEmpty) ...[ Text( l10n.updateContent, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 8), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: Text( version.updateDescription, style: TextStyle( color: cs.onSurface.withAlpha(200), fontSize: 13, height: 1.6, ), ), ), ), ], if (result.isForce) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: AppColors.error.withAlpha(20), borderRadius: BorderRadius.circular(6), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.warning_amber_rounded, color: AppColors.error, size: 16, ), const SizedBox(width: 4), Expanded( child: Text( l10n.forceUpdateTip, style: TextStyle(color: AppColors.error, fontSize: 12), ), ), ], ), ), ], if (_downloading) ...[ const SizedBox(height: 14), Text( l10n.downloadingUpdate, style: TextStyle(color: cs.onSurface.withAlpha(200), fontSize: 13), ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( minHeight: 6, value: (_downloadProgress != null && _downloadProgress! >= 0 && _downloadProgress! <= 1) ? _downloadProgress : null, backgroundColor: cs.surfaceContainerHighest, color: AppColors.brand, ), ), ], const SizedBox(height: 8), _buildActionArea(context, cs, l10n), ], ), ), ), ); } }