| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- 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<void>(
- context: context,
- barrierDismissible: !result.isForce,
- builder: (_) => UpdateDialog(result: result),
- );
- }
- @override
- State<UpdateDialog> createState() => _UpdateDialogState();
- }
- class _UpdateDialogState extends State<UpdateDialog> {
- 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<void> _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<void> _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<bool> _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),
- ],
- ),
- ),
- ),
- );
- }
- }
|