import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/update/update_config.dart'; import 'package:miaoji_zhang/shared/update/update_downloader.dart'; import 'package:miaoji_zhang/shared/update/update_installer.dart'; import 'package:miaoji_zhang/shared/update/update_models.dart'; import 'package:miaoji_zhang/shared/version.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; Future showUpdatePrompt( BuildContext context, { required AppRelease release, required bool forced, required Future Function() onIgnore, }) { return showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, isDismissible: false, enableDrag: false, backgroundColor: Colors.transparent, builder: (_) => UpdatePromptSheet(release: release, forced: forced, onIgnore: onIgnore), ); } class UpdatePromptSheet extends StatefulWidget { final AppRelease release; final bool forced; final Future Function() onIgnore; const UpdatePromptSheet({ super.key, required this.release, required this.forced, required this.onIgnore, }); @override State createState() => _UpdatePromptSheetState(); } class _UpdatePromptSheetState extends State { final _downloader = UpdateDownloader(); UpdateDownloadProgress? _progress; bool _working = false; String? _status; String? _error; String? _downloadedPath; @override void dispose() { _downloader.cancel(); super.dispose(); } Future _ignore() async { if (_working) return; await widget.onIgnore(); if (mounted) Navigator.pop(context); } Future _update() async { if (_working) return; final uri = Uri.tryParse(widget.release.downloadUrl); if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { setState(() => _error = '更新地址无效,必须使用 HTTPS'); return; } if (!UpdateConfig.canInstallInApp) { setState(() { _working = true; _error = null; _status = '正在打开更新页面…'; }); try { final opened = await launchUrl( uri, mode: LaunchMode.externalApplication, ); if (!opened) throw StateError('无法打开更新页面'); if (!widget.forced && mounted) Navigator.pop(context); if (mounted && widget.forced) { setState(() => _status = '更新页面已打开,完成更新后重新打开记之'); } } catch (_) { if (mounted) setState(() => _error = '无法打开更新页面,请稍后重试'); } finally { if (mounted) setState(() => _working = false); } return; } setState(() { _working = true; _error = null; _status = '正在下载更新包…'; _progress = null; }); String? path; try { final cachedPath = _downloadedPath; if (cachedPath != null && await File(cachedPath).exists()) { path = cachedPath; } else { _downloadedPath = null; path = await _downloader.downloadAndVerify( widget.release, onProgress: (progress) { if (!mounted) return; setState(() { _progress = progress; _status = '正在下载更新包…'; }); }, ); _downloadedPath = path; } if (!mounted) return; setState(() { _status = '校验完成,正在打开安装界面…'; _progress = null; }); final status = await UpdateInstaller.install( path: path, expectedBuild: widget.release.buildNumber, ); if (!mounted) return; setState(() { _status = switch (status) { ApkInstallStatus.launched => '安装界面已打开,完成后重新启动记之', ApkInstallStatus.permissionRequested => '请允许安装未知应用,返回后将继续安装', ApkInstallStatus.unsupported => '当前设备无法调起安装,请重新下载', }; if (status == ApkInstallStatus.unsupported) { _error = _status; } }); if (status == ApkInstallStatus.launched) { await _downloader.scheduleCleanup(path); } else if (status == ApkInstallStatus.unsupported) { await _downloader.deleteFile(path); _downloadedPath = null; } } on UpdateDownloadException catch (error) { if (mounted) setState(() => _error = error.message); } catch (_) { if (mounted) setState(() => _error = '更新失败,请重新尝试'); } finally { if (mounted) setState(() => _working = false); } } void _cancelDownload() { _downloader.cancel(); setState(() => _status = '正在取消下载…'); } @override Widget build(BuildContext context) { final release = widget.release; return PopScope( canPop: false, child: SafeArea( child: Container( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.84, ), padding: const EdgeInsets.fromLTRB(20, 0, 20, 18), decoration: BoxDecoration( color: context.jz.card, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ JzSheetHeader( title: widget.forced ? '必须更新后继续使用' : '发现新版本', subtitle: 'v${release.versionName} (${release.buildNumber}) · 当前 ${AppVersion.display}', ), if (widget.forced) Container( width: double.infinity, margin: const EdgeInsets.only(top: 12), padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), decoration: BoxDecoration( color: context.jz.warningBackground, borderRadius: BorderRadius.circular(12), border: Border.all( color: AppTheme.orange.withValues(alpha: 0.35), ), ), child: Text( '此版本已停止支持,更新完成前暂时不能进入 App。', style: TextStyle( fontSize: 12, color: context.jz.text2, height: 1.45, ), ), ), const SizedBox(height: 14), Flexible( child: Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: context.jz.background, borderRadius: BorderRadius.circular(14), border: Border.all(color: context.jz.line), ), child: SingleChildScrollView( child: MarkdownBody( data: release.releaseNotes.isEmpty ? '本次更新包含体验优化与问题修复。' : release.releaseNotes, selectable: true, styleSheet: MarkdownStyleSheet( p: TextStyle( fontSize: 13, color: context.jz.text2, height: 1.65, ), h1: TextStyle( fontSize: 17, fontWeight: FontWeight.w800, color: context.jz.text, ), h2: TextStyle( fontSize: 15, fontWeight: FontWeight.w800, color: context.jz.text, ), listBullet: TextStyle( fontSize: 13, color: AppTheme.primaryDeep, ), code: TextStyle( fontSize: 12, color: context.jz.text, backgroundColor: context.jz.card, ), blockquoteDecoration: BoxDecoration( color: context.jz.card, borderRadius: BorderRadius.circular(8), border: const Border( left: BorderSide(color: AppTheme.ai, width: 3), ), ), ), ), ), ), ), if (_progress != null || _status != null || _error != null) ...[ const SizedBox(height: 14), if (_progress != null) _UpdateProgressBar(progress: _progress!), if (_progress != null) const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: Text( _error ?? _status ?? '', style: TextStyle( fontSize: 11.5, color: _error == null ? context.jz.text2 : AppTheme.red, ), ), ), ], const SizedBox(height: 18), if (_working && _downloader.isDownloading && !widget.forced) SizedBox( width: double.infinity, child: JzActionButton( label: '取消下载', secondary: true, onPressed: _cancelDownload, ), ) else Row( children: [ if (!widget.forced) ...[ Expanded( child: JzActionButton( label: '忽略此版本', secondary: true, onPressed: _working ? null : _ignore, ), ), const SizedBox(width: 10), ], Expanded( child: JzActionButton( label: UpdateConfig.canInstallInApp ? (_downloadedPath == null ? '下载并安装' : '继续安装') : '立即更新', loading: _working, onPressed: _working ? null : _update, ), ), ], ), ], ), ), ), ); } } class _UpdateProgressBar extends StatelessWidget { final UpdateDownloadProgress progress; const _UpdateProgressBar({required this.progress}); @override Widget build(BuildContext context) { final fraction = progress.fraction ?? 0; return Column( children: [ ClipRRect( borderRadius: BorderRadius.circular(4), child: SizedBox( height: 7, child: Stack( fit: StackFit.expand, children: [ ColoredBox(color: context.jz.line), FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: fraction, child: const ColoredBox(color: AppTheme.primary), ), ], ), ), ), const SizedBox(height: 6), Row( children: [ Text( progress.total > 0 ? '${(fraction * 100).toStringAsFixed(0)}%' : _formatBytes(progress.received), style: TextStyle(fontSize: 10.5, color: context.jz.text3), ), const Spacer(), Text( '${_formatBytes(progress.bytesPerSecond.round())}/s', style: TextStyle(fontSize: 10.5, color: context.jz.text3), ), ], ), ], ); } } String _formatBytes(int bytes) { if (bytes >= 1024 * 1024) { return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; } if (bytes >= 1024) { return '${(bytes / 1024).toStringAsFixed(1)} KB'; } return '$bytes B'; }