update_dialog.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. import 'dart:io' show File;
  2. import 'package:app_settings/app_settings.dart';
  3. import 'package:dio/dio.dart';
  4. import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
  5. import 'package:flutter/material.dart';
  6. import 'package:open_filex/open_filex.dart';
  7. import 'package:path_provider/path_provider.dart';
  8. import 'package:permission_handler/permission_handler.dart';
  9. import 'package:url_launcher/url_launcher.dart';
  10. import '../../../core/l10n/app_localizations.dart';
  11. import '../../../core/theme/app_colors.dart';
  12. import '../../../providers/app_version_provider.dart';
  13. /// 本进程内缓存已下载的安装包路径;弹窗销毁后仍可避免「从设置返回再点安装」重复下包。
  14. class _UpdateApkPathCache {
  15. _UpdateApkPathCache._();
  16. static String? _key;
  17. static String? _path;
  18. static String _composeKey(VersionCheckResult r) {
  19. final url = r.version.downloadUrl.trim();
  20. final ver = r.version.newVersion.trim();
  21. return '$url|$ver';
  22. }
  23. static String? peekPath(VersionCheckResult r) {
  24. final want = _composeKey(r);
  25. if (_path != null && _key == want) {
  26. return _path;
  27. }
  28. return null;
  29. }
  30. static void remember(VersionCheckResult r, String absolutePath) {
  31. _key = _composeKey(r);
  32. _path = absolutePath;
  33. }
  34. static void forget() {
  35. _key = null;
  36. _path = null;
  37. }
  38. }
  39. /// 版本更新弹窗。
  40. ///
  41. /// Android:支持应用内下载 APK 并拉起系统安装界面,并提供「浏览器打开」备用入口。
  42. /// iOS:仅系统浏览器跳转下载页(外链),无应用内 APK 流程。
  43. class UpdateDialog extends StatefulWidget {
  44. const UpdateDialog({super.key, required this.result});
  45. final VersionCheckResult result;
  46. /// 显示版本更新弹窗。
  47. static void show(BuildContext context, VersionCheckResult result) {
  48. showDialog<void>(
  49. context: context,
  50. barrierDismissible: !result.isForce,
  51. builder: (_) => UpdateDialog(result: result),
  52. );
  53. }
  54. @override
  55. State<UpdateDialog> createState() => _UpdateDialogState();
  56. }
  57. class _UpdateDialogState extends State<UpdateDialog> {
  58. final CancelToken _cancelToken = CancelToken();
  59. bool _downloading = false;
  60. double? _downloadProgress;
  61. VersionCheckResult get result => widget.result;
  62. bool get _isAndroid =>
  63. !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
  64. @override
  65. void dispose() {
  66. _cancelToken.cancel();
  67. super.dispose();
  68. }
  69. Uri? _parseDownloadUri() {
  70. final url = result.version.downloadUrl.trim();
  71. if (url.isEmpty) {
  72. return null;
  73. }
  74. final uri = Uri.tryParse(url);
  75. if (uri == null || !uri.hasScheme) {
  76. return null;
  77. }
  78. final s = uri.scheme.toLowerCase();
  79. if (s != 'http' && s != 'https') {
  80. return null;
  81. }
  82. if (uri.host.isEmpty) {
  83. return null;
  84. }
  85. return uri;
  86. }
  87. Future<void> _launchBrowser(AppLocalizations l10n) async {
  88. final uri = _parseDownloadUri();
  89. if (uri == null) {
  90. return;
  91. }
  92. try {
  93. final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
  94. if (!ok && mounted) {
  95. ScaffoldMessenger.of(context).showSnackBar(
  96. SnackBar(content: Text(l10n.cannotOpenLink)),
  97. );
  98. }
  99. } catch (_) {
  100. if (mounted) {
  101. ScaffoldMessenger.of(context).showSnackBar(
  102. SnackBar(content: Text(l10n.cannotOpenLink)),
  103. );
  104. }
  105. }
  106. }
  107. Future<void> _downloadAndInstallApk(AppLocalizations l10n) async {
  108. final uri = _parseDownloadUri();
  109. if (uri == null) {
  110. return;
  111. }
  112. try {
  113. final dir = await getTemporaryDirectory();
  114. final defaultPath = '${dir.path}/coinvision_upgrade.apk';
  115. var cachedPath = _UpdateApkPathCache.peekPath(result);
  116. if (cachedPath != null && !_cachedApkStillOnDisk(cachedPath)) {
  117. _UpdateApkPathCache.forget();
  118. cachedPath = null;
  119. }
  120. final savePath = cachedPath ?? defaultPath;
  121. final needDownload = cachedPath == null;
  122. if (needDownload) {
  123. final dio = Dio(
  124. BaseOptions(
  125. connectTimeout: const Duration(seconds: 30),
  126. receiveTimeout: const Duration(minutes: 30),
  127. ),
  128. );
  129. setState(() {
  130. _downloading = true;
  131. _downloadProgress = 0;
  132. });
  133. await dio.download(
  134. uri.toString(),
  135. savePath,
  136. cancelToken: _cancelToken,
  137. onReceiveProgress: (received, total) {
  138. if (!mounted) {
  139. return;
  140. }
  141. if (total <= 0) {
  142. setState(() {
  143. _downloadProgress = null;
  144. });
  145. } else {
  146. setState(() {
  147. _downloadProgress = received / total;
  148. });
  149. }
  150. },
  151. );
  152. _UpdateApkPathCache.remember(result, savePath);
  153. if (!mounted) {
  154. return;
  155. }
  156. setState(() {
  157. _downloading = false;
  158. _downloadProgress = null;
  159. });
  160. }
  161. if (!mounted) {
  162. return;
  163. }
  164. final permitted = await _ensureInstallPackagesPermission(l10n);
  165. if (!permitted || !mounted) {
  166. return;
  167. }
  168. final openRes = await OpenFilex.open(
  169. savePath,
  170. type: 'application/vnd.android.package-archive',
  171. );
  172. if (!mounted) {
  173. return;
  174. }
  175. if (openRes.type != ResultType.done) {
  176. if (openRes.type == ResultType.fileNotFound) {
  177. _UpdateApkPathCache.forget();
  178. }
  179. final msg = openRes.type == ResultType.permissionDenied
  180. ? l10n.installPermissionRequired
  181. : l10n.upgradeDownloadOrInstallFailed;
  182. ScaffoldMessenger.maybeOf(context)?.showSnackBar(
  183. SnackBar(
  184. content: Text(msg),
  185. action: SnackBarAction(
  186. label: l10n.goToSettings,
  187. onPressed: () {
  188. AppSettings.openAppSettings(
  189. type: AppSettingsType.manageUnknownAppSources,
  190. asAnotherTask: true,
  191. );
  192. },
  193. ),
  194. ),
  195. );
  196. }
  197. } on DioException catch (_) {
  198. if (mounted) {
  199. setState(() {
  200. _downloading = false;
  201. _downloadProgress = null;
  202. });
  203. _UpdateApkPathCache.forget();
  204. ScaffoldMessenger.of(context).showSnackBar(
  205. SnackBar(content: Text(l10n.upgradeDownloadOrInstallFailed)),
  206. );
  207. }
  208. } catch (_) {
  209. if (mounted) {
  210. setState(() {
  211. _downloading = false;
  212. _downloadProgress = null;
  213. });
  214. ScaffoldMessenger.of(context).showSnackBar(
  215. SnackBar(content: Text(l10n.upgradeDownloadOrInstallFailed)),
  216. );
  217. }
  218. }
  219. }
  220. /// 临时目录 APK 可能被系统清理;路径在缓存中但文件已删除时不应跳过下载。
  221. bool _cachedApkStillOnDisk(String absolutePath) {
  222. try {
  223. return File(absolutePath).existsSync();
  224. } catch (_) {
  225. return false;
  226. }
  227. }
  228. Future<bool> _ensureInstallPackagesPermission(AppLocalizations l10n) async {
  229. var status = await Permission.requestInstallPackages.status;
  230. if (status.isGranted) {
  231. return true;
  232. }
  233. status = await Permission.requestInstallPackages.request();
  234. if (status.isGranted) {
  235. return true;
  236. }
  237. // MIUI / 部分国产 ROM:系统不会弹窗,需在「允许安装未知应用」中为本应用单独放行。
  238. if (!mounted) {
  239. return false;
  240. }
  241. ScaffoldMessenger.maybeOf(context)?.showSnackBar(
  242. SnackBar(
  243. content: Text(l10n.installPermissionRequired),
  244. duration: const Duration(seconds: 8),
  245. action: SnackBarAction(
  246. label: l10n.goToSettings,
  247. onPressed: () {
  248. AppSettings.openAppSettings(
  249. type: AppSettingsType.manageUnknownAppSources,
  250. asAnotherTask: true,
  251. );
  252. },
  253. ),
  254. ),
  255. );
  256. return false;
  257. }
  258. Widget _buildActionArea(
  259. BuildContext context,
  260. ColorScheme cs,
  261. AppLocalizations l10n,
  262. ) {
  263. if (!_isAndroid) {
  264. return Column(
  265. mainAxisSize: MainAxisSize.min,
  266. crossAxisAlignment: CrossAxisAlignment.stretch,
  267. children: [
  268. FilledButton(
  269. onPressed: () => _launchBrowser(l10n),
  270. style: FilledButton.styleFrom(
  271. backgroundColor: AppColors.brand,
  272. foregroundColor: Colors.black,
  273. padding: const EdgeInsets.symmetric(vertical: 14),
  274. ),
  275. child: Text(
  276. l10n.updateNow,
  277. style: const TextStyle(fontWeight: FontWeight.w600),
  278. ),
  279. ),
  280. if (!result.isForce) ...[
  281. const SizedBox(height: 8),
  282. TextButton(
  283. onPressed: () => Navigator.of(context).pop(),
  284. child: Text(
  285. l10n.remindLater,
  286. style: TextStyle(color: cs.onSurface.withAlpha(153)),
  287. ),
  288. ),
  289. ],
  290. ],
  291. );
  292. }
  293. final primaryStyle = FilledButton.styleFrom(
  294. backgroundColor: AppColors.brand,
  295. foregroundColor: Colors.black,
  296. padding: const EdgeInsets.symmetric(vertical: 14),
  297. disabledBackgroundColor: AppColors.brand.withAlpha(120),
  298. disabledForegroundColor: Colors.black.withAlpha(120),
  299. );
  300. final secondaryOutline = OutlinedButton.styleFrom(
  301. foregroundColor: cs.onSurface.withAlpha(230),
  302. side: BorderSide(color: cs.outline.withAlpha(180)),
  303. padding: const EdgeInsets.symmetric(vertical: 12),
  304. );
  305. return Column(
  306. mainAxisSize: MainAxisSize.min,
  307. crossAxisAlignment: CrossAxisAlignment.stretch,
  308. children: [
  309. FilledButton(
  310. onPressed:
  311. _downloading ? null : () => _downloadAndInstallApk(l10n),
  312. style: primaryStyle,
  313. child: Text(
  314. l10n.updateInAppInstall,
  315. style: const TextStyle(fontWeight: FontWeight.w600),
  316. ),
  317. ),
  318. const SizedBox(height: 10),
  319. if (result.isForce)
  320. OutlinedButton(
  321. onPressed: _downloading ? null : () => _launchBrowser(l10n),
  322. style: secondaryOutline,
  323. child: Text(l10n.updateOpenInBrowser),
  324. )
  325. else
  326. // 勿用 stretch:外层 Column 为 stretch 时,子 Row 在交叉轴上收到 maxHeight=∞,
  327. // stretch 会生成 tightFor(height: ∞) 触发 BoxConstraints forces an infinite height。
  328. Row(
  329. crossAxisAlignment: CrossAxisAlignment.center,
  330. children: [
  331. Expanded(
  332. child: TextButton(
  333. onPressed: _downloading ? null : () => Navigator.of(context).pop(),
  334. style: TextButton.styleFrom(
  335. foregroundColor: cs.onSurface.withAlpha(153),
  336. padding: const EdgeInsets.symmetric(vertical: 12),
  337. ),
  338. child: Text(
  339. l10n.remindLater,
  340. maxLines: 1,
  341. overflow: TextOverflow.ellipsis,
  342. ),
  343. ),
  344. ),
  345. const SizedBox(width: 8),
  346. Expanded(
  347. child: OutlinedButton(
  348. onPressed: _downloading ? null : () => _launchBrowser(l10n),
  349. style: secondaryOutline,
  350. child: Text(
  351. l10n.updateOpenInBrowser,
  352. maxLines: 1,
  353. overflow: TextOverflow.ellipsis,
  354. ),
  355. ),
  356. ),
  357. ],
  358. ),
  359. ],
  360. );
  361. }
  362. @override
  363. Widget build(BuildContext context) {
  364. final cs = Theme.of(context).colorScheme;
  365. final l10n = AppLocalizations.of(context)!;
  366. final version = result.version;
  367. // [Dialog] 默认仅 minWidth≈280,maxWidth 为无限;Column + stretch 时子组件(如按钮 min 宽为 ∞)
  368. // 会破坏约束断言。限制 maxWidth/maxHeight,由 Column(mainAxis.min) 按内容高度收缩,避免全屏留白与崩溃。
  369. final mq = MediaQuery.sizeOf(context);
  370. final maxDialogW =
  371. mq.width <= 560 ? mq.width - 48.0 : 560.0;
  372. final maxDialogH = mq.height * 0.85;
  373. return PopScope(
  374. canPop: !result.isForce,
  375. child: Dialog(
  376. backgroundColor: cs.surface,
  377. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
  378. clipBehavior: Clip.antiAlias,
  379. constraints: BoxConstraints(
  380. minWidth: 280,
  381. maxWidth: maxDialogW.clamp(280.0, 560.0),
  382. maxHeight: maxDialogH,
  383. ),
  384. child: Padding(
  385. padding: const EdgeInsets.fromLTRB(24, 20, 24, 20),
  386. child: Column(
  387. mainAxisSize: MainAxisSize.min,
  388. crossAxisAlignment: CrossAxisAlignment.stretch,
  389. children: [
  390. Row(
  391. children: [
  392. Icon(Icons.system_update, color: AppColors.brand, size: 24),
  393. const SizedBox(width: 8),
  394. Expanded(
  395. child: Text(
  396. l10n.newVersionFound,
  397. style: TextStyle(
  398. color: cs.onSurface,
  399. fontSize: 16,
  400. fontWeight: FontWeight.w600,
  401. ),
  402. ),
  403. ),
  404. ],
  405. ),
  406. const SizedBox(height: 16),
  407. Text(
  408. '${l10n.latestVersion}:v${version.newVersion}',
  409. style: TextStyle(
  410. color: cs.onSurface,
  411. fontSize: 14,
  412. fontWeight: FontWeight.w500,
  413. ),
  414. ),
  415. const SizedBox(height: 4),
  416. Text(
  417. '${l10n.currentVersion}:v${result.currentVersion}',
  418. style: TextStyle(
  419. color: cs.onSurface.withAlpha(153),
  420. fontSize: 13,
  421. ),
  422. ),
  423. const SizedBox(height: 16),
  424. if (version.updateDescription.isNotEmpty) ...[
  425. Text(
  426. l10n.updateContent,
  427. style: TextStyle(
  428. color: cs.onSurface,
  429. fontSize: 14,
  430. fontWeight: FontWeight.w500,
  431. ),
  432. ),
  433. const SizedBox(height: 8),
  434. ConstrainedBox(
  435. constraints: const BoxConstraints(maxHeight: 200),
  436. child: SingleChildScrollView(
  437. physics: const ClampingScrollPhysics(),
  438. child: Text(
  439. version.updateDescription,
  440. style: TextStyle(
  441. color: cs.onSurface.withAlpha(200),
  442. fontSize: 13,
  443. height: 1.6,
  444. ),
  445. ),
  446. ),
  447. ),
  448. ],
  449. if (result.isForce) ...[
  450. const SizedBox(height: 12),
  451. Container(
  452. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
  453. decoration: BoxDecoration(
  454. color: AppColors.error.withAlpha(20),
  455. borderRadius: BorderRadius.circular(6),
  456. ),
  457. child: Row(
  458. crossAxisAlignment: CrossAxisAlignment.start,
  459. children: [
  460. Icon(
  461. Icons.warning_amber_rounded,
  462. color: AppColors.error,
  463. size: 16,
  464. ),
  465. const SizedBox(width: 4),
  466. Expanded(
  467. child: Text(
  468. l10n.forceUpdateTip,
  469. style: TextStyle(color: AppColors.error, fontSize: 12),
  470. ),
  471. ),
  472. ],
  473. ),
  474. ),
  475. ],
  476. if (_downloading) ...[
  477. const SizedBox(height: 14),
  478. Text(
  479. l10n.downloadingUpdate,
  480. style: TextStyle(color: cs.onSurface.withAlpha(200), fontSize: 13),
  481. ),
  482. const SizedBox(height: 8),
  483. ClipRRect(
  484. borderRadius: BorderRadius.circular(4),
  485. child: LinearProgressIndicator(
  486. minHeight: 6,
  487. value: (_downloadProgress != null &&
  488. _downloadProgress! >= 0 &&
  489. _downloadProgress! <= 1)
  490. ? _downloadProgress
  491. : null,
  492. backgroundColor: cs.surfaceContainerHighest,
  493. color: AppColors.brand,
  494. ),
  495. ),
  496. ],
  497. const SizedBox(height: 8),
  498. _buildActionArea(context, cs, l10n),
  499. ],
  500. ),
  501. ),
  502. ),
  503. );
  504. }
  505. }