1153 lines
39 KiB
Dart
1153 lines
39 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:app_upgrade_plugin/widgets/market_selection_dialog.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:fluttertoast/fluttertoast.dart';
|
||
|
||
import 'app_upgrade_plugin.dart';
|
||
import 'core/upgrade_utils.dart';
|
||
|
||
/// 简化版App升级管理器
|
||
/// 提供最简单的API,一行代码即可实现App升级功能
|
||
class AppUpgradeSimple {
|
||
static AppUpgradeSimple? _instance;
|
||
|
||
/// 获取单例实例
|
||
static AppUpgradeSimple get instance {
|
||
_instance ??= AppUpgradeSimple._();
|
||
return _instance!;
|
||
}
|
||
|
||
@visibleForTesting
|
||
static set instance(AppUpgradeSimple value) {
|
||
_instance = value;
|
||
}
|
||
|
||
@visibleForTesting
|
||
AppUpgradeSimple.private({AppUpgradePlugin? plugin}) : _plugin = plugin ?? AppUpgradePlugin();
|
||
|
||
AppUpgradeSimple._() : _plugin = AppUpgradePlugin();
|
||
|
||
final AppUpgradePlugin _plugin;
|
||
|
||
/// 一键检查更新(最简单的使用方式)
|
||
///
|
||
/// 示例:
|
||
/// ```dart
|
||
/// AppUpgradeSimple.instance.checkUpdate(
|
||
/// context: context,
|
||
/// url: 'https://api.example.com/check-update',
|
||
/// );
|
||
/// ```
|
||
///
|
||
/// [context] 需要是一个能够访问到 `Navigator` 和 `MaterialLocalizations` 的有效上下文。
|
||
/// 如果您不确定,建议在 `init` 方法中提供 `navigatorKey`,这样插件可以获取一个可靠的上下文。
|
||
Future<void> checkUpdate({
|
||
required BuildContext context,
|
||
required String url,
|
||
Map<String, dynamic>? params,
|
||
bool showNoUpdateToast = true,
|
||
bool autoDownload = false,
|
||
bool autoInstall = false,
|
||
VoidCallback? onComplete,
|
||
}) async {
|
||
try {
|
||
assert(_canShowMaterialDialog(context), '请在 MaterialApp 环境内调用');
|
||
|
||
// 检查更新
|
||
final info = await _plugin.checkUpdate(url, params: params);
|
||
|
||
if (info == null || !info.hasUpdate) {
|
||
if (showNoUpdateToast && context.mounted) _showToast('已是最新版本');
|
||
onComplete?.call();
|
||
return;
|
||
}
|
||
|
||
await _showUpgradeDialog(
|
||
context: context,
|
||
info: info,
|
||
autoDownload: autoDownload,
|
||
autoInstall: autoInstall,
|
||
onComplete: onComplete,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('检查更新失败: $e');
|
||
|
||
String errorMessage = '检查更新失败';
|
||
|
||
if (e.toString().contains('无网络连接')) {
|
||
errorMessage = '无网络连接,请检查网络设置';
|
||
} else if (e.toString().contains('Failed host lookup')) {
|
||
errorMessage = '无法连接到服务器,请检查网络或稍后重试';
|
||
} else if (e.toString().contains('Connection refused')) {
|
||
errorMessage = '服务器拒绝连接,请稍后重试';
|
||
} else if (e.toString().contains('timeout')) {
|
||
errorMessage = '连接超时,请检查网络';
|
||
} else {
|
||
errorMessage = '检查更新失败: ${e.toString().split(':').first}';
|
||
}
|
||
|
||
if (context.mounted) {
|
||
_showToast(errorMessage);
|
||
|
||
// 如果是网络问题,显示网络诊断建议
|
||
if (e.toString().contains('Failed host lookup') || e.toString().contains('无网络连接')) {
|
||
debugPrint('建议: 请检查网络连接或尝试使用网络诊断功能');
|
||
}
|
||
}
|
||
onComplete?.call();
|
||
}
|
||
}
|
||
|
||
/// 在无法显示对话框时的后备逻辑:显示Toast并尝试后台下载/安装
|
||
Future<void> _showToastAndDownloadInBackground({
|
||
required BuildContext context,
|
||
required UpgradeInfo info,
|
||
required bool autoDownload,
|
||
required bool autoInstall,
|
||
}) async {
|
||
if (info.isForceUpdate) {
|
||
_showToast('有重要更新,但无法显示对话框。请在 MaterialApp 环境内重试。');
|
||
} else {
|
||
_showToast('发现新版本: ${info.versionName}');
|
||
if (autoDownload && Platform.isAndroid && info.downloadUrl != null) {
|
||
final filePath = await _plugin.downloadApk(info.downloadUrl!, onProgress: (_) {});
|
||
if (filePath != null && autoInstall) {
|
||
await _installApkHeadless(context, filePath);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 静默检查更新(不显示无更新提示)
|
||
Future<UpgradeInfo?> checkUpdateSilent({
|
||
required String url,
|
||
Map<String, dynamic>? params,
|
||
}) async {
|
||
try {
|
||
return await _plugin.checkUpdate(url, params: params);
|
||
} catch (e) {
|
||
debugPrint('静默检查更新失败: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 显示升级对话框
|
||
Future<void> _showUpgradeDialog({
|
||
required BuildContext context,
|
||
required UpgradeInfo info,
|
||
required bool autoDownload,
|
||
required bool autoInstall,
|
||
VoidCallback? onComplete,
|
||
}) {
|
||
return showDialog(
|
||
context: context,
|
||
barrierDismissible: !info.isForceUpdate,
|
||
builder: (context) {
|
||
if (info.isForceUpdate) {
|
||
return _ForceUpgradeDialog(info: info);
|
||
} else {
|
||
return _SimpleUpgradeDialog(
|
||
info: info,
|
||
autoDownload: autoDownload,
|
||
autoInstall: autoInstall,
|
||
onComplete: onComplete,
|
||
showToast: (message) => _showToast(message),
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 显示备用升级对话框(当MaterialApp环境不可用时)
|
||
void _showFallbackUpgradeDialog({
|
||
required BuildContext context,
|
||
required UpgradeInfo info,
|
||
required bool autoDownload,
|
||
required bool autoInstall,
|
||
VoidCallback? onComplete,
|
||
}) {
|
||
showGeneralDialog(
|
||
context: context,
|
||
barrierDismissible: !info.isForceUpdate,
|
||
barrierLabel: 'Dismiss', // Provide a non-localized label
|
||
pageBuilder: (buildContext, animation, secondaryAnimation) {
|
||
// 使用一个包装器来提供基本的文本样式和方向,这对于独立于Material的对话框是必需的
|
||
return Directionality(
|
||
textDirection: TextDirection.ltr,
|
||
child: _FallbackUpgradeDialog(
|
||
info: info,
|
||
autoDownload: autoDownload,
|
||
autoInstall: autoInstall,
|
||
onComplete: onComplete,
|
||
showToast: (message) => _showToast(message),
|
||
),
|
||
);
|
||
},
|
||
).then((_) {
|
||
// The dialog has been dismissed.
|
||
onComplete?.call();
|
||
});
|
||
}
|
||
|
||
/// 显示Toast提示
|
||
void _showToast(String message) {
|
||
Fluttertoast.showToast(msg: message);
|
||
}
|
||
|
||
/// 检查是否存在可用的 Material 环境(仅用于对话框)
|
||
bool _canShowMaterialDialog(BuildContext context) {
|
||
if (!context.mounted) return false;
|
||
try {
|
||
// Localizations.of will throw if not found.
|
||
return Localizations.of<MaterialLocalizations>(context, MaterialLocalizations) != null;
|
||
} catch (_) {
|
||
// If it throws, it means we're not in a Material scope.
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 共享的安装逻辑 (无UI)
|
||
Future<void> _installApkHeadless(BuildContext context, String filePath) async {
|
||
if (!Platform.isAndroid) return;
|
||
final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context);
|
||
if (!hasPermission) {
|
||
_showToast('未授予安装权限,无法完成更新');
|
||
return;
|
||
}
|
||
final success = await _plugin.installApk(filePath);
|
||
if (!success) {
|
||
_showToast('安装失败,请手动安装');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 共享的升级对话框内容构建器
|
||
class _UpgradeDialogContent extends StatelessWidget {
|
||
final UpgradeInfo info;
|
||
final bool isDownloading;
|
||
final double downloadProgress;
|
||
final String statusText;
|
||
|
||
const _UpgradeDialogContent({
|
||
required this.info,
|
||
required this.isDownloading,
|
||
required this.downloadProgress,
|
||
required this.statusText,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final List<String> changeItems =
|
||
info.updateContent.split(RegExp(r'\r?\n')).map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'版本:${info.versionName}',
|
||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||
),
|
||
if (info.apkSize != null) ...[
|
||
const SizedBox(height: 4),
|
||
Text('大小:${formatBytes(info.apkSize!)}'),
|
||
],
|
||
const SizedBox(height: 12),
|
||
const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
constraints: const BoxConstraints(maxHeight: 220),
|
||
child: SingleChildScrollView(
|
||
child: changeItems.isEmpty
|
||
? Text(info.updateContent)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: changeItems
|
||
.map(
|
||
(line) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('• '),
|
||
Expanded(child: Text(line)),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
.toList(),
|
||
),
|
||
),
|
||
),
|
||
if (isDownloading) ...[
|
||
const SizedBox(height: 16),
|
||
ClipRRect(
|
||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||
child: LinearProgressIndicator(
|
||
value: downloadProgress.clamp(0.0, 1.0),
|
||
minHeight: 6,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(child: Text(statusText, overflow: TextOverflow.ellipsis)),
|
||
Text('${(downloadProgress * 100).toStringAsFixed(1)}%'),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 共享的升级操作逻辑
|
||
mixin _UpgradeDialogLogic<T extends StatefulWidget> on State<T> {
|
||
final _plugin = AppUpgradePlugin();
|
||
bool _isDownloading = false;
|
||
double _downloadProgress = 0;
|
||
String _statusText = '';
|
||
|
||
UpgradeInfo get info;
|
||
void Function(String) get showToast;
|
||
VoidCallback? get onComplete;
|
||
bool get autoDownload;
|
||
bool get autoInstall;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (autoDownload && Platform.isAndroid) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_startDownloadAndInstall();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _startDownloadAndInstall() async {
|
||
if (!Platform.isAndroid || info.downloadUrl == null) return;
|
||
if (!mounted) return;
|
||
|
||
final hasStorage = await PermissionHelper.checkAndRequestStoragePermission(context: context);
|
||
if (!hasStorage) {
|
||
showToast('缺少存储权限,无法下载');
|
||
return;
|
||
}
|
||
await PermissionHelper.checkAndRequestNotificationPermission(context: context);
|
||
|
||
setState(() {
|
||
_isDownloading = true;
|
||
_statusText = '下载中...';
|
||
_downloadProgress = 0;
|
||
});
|
||
|
||
final filePath = await _plugin.downloadApk(
|
||
info.downloadUrl!,
|
||
onProgress: (p) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_downloadProgress = p.progress;
|
||
_statusText = '下载中 ${p.percentage}%';
|
||
});
|
||
},
|
||
);
|
||
|
||
if (!mounted) return;
|
||
|
||
if (filePath == null) {
|
||
setState(() {
|
||
_isDownloading = false;
|
||
_statusText = '下载失败';
|
||
});
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_statusText = '下载完成';
|
||
_downloadProgress = 1.0;
|
||
});
|
||
|
||
if (autoInstall) {
|
||
await _installApk(filePath);
|
||
}
|
||
}
|
||
|
||
Future<void> _installApk(String filePath) async {
|
||
if (!mounted) return;
|
||
final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context);
|
||
if (!hasPermission) {
|
||
if (mounted) {
|
||
showToast('未授予安装权限,无法完成更新');
|
||
}
|
||
return;
|
||
}
|
||
|
||
final success = await _plugin.installApk(filePath);
|
||
if (!success && mounted) {
|
||
showToast('安装失败,请手动安装');
|
||
}
|
||
}
|
||
|
||
Future<void> _showInstallMethodChooser() async {
|
||
final bool hasMarkets = info.appMarkets != null && info.appMarkets!.isNotEmpty;
|
||
final bool hasDownload = info.downloadUrl != null;
|
||
|
||
if (!mounted) return;
|
||
|
||
// 始终提供“应用市场”入口;无具体列表则用通用 market:// 链接
|
||
final choice = await showModalBottomSheet<String>(
|
||
context: context,
|
||
isScrollControlled: false,
|
||
useRootNavigator: true,
|
||
// UI Beautification: Rounded corners
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Draggable handle
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade300,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
// Title and Close Button
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8.0),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
const Text('选择更新方式', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
Positioned(
|
||
right: -12,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.close),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Option 1: App Market
|
||
ListTile(
|
||
leading: const Icon(Icons.storefront_outlined),
|
||
title: const Text('前往应用市场更新'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onTap: () => Navigator.of(ctx).pop('market'),
|
||
),
|
||
|
||
// Option 2: Direct Download
|
||
if (hasDownload)
|
||
ListTile(
|
||
leading: const Icon(Icons.download_for_offline_outlined),
|
||
title: const Text('直接下载安装包'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onTap: () => Navigator.of(ctx).pop('download'),
|
||
),
|
||
|
||
const Divider(height: 24),
|
||
|
||
// Cancel Button
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
backgroundColor: Colors.white,
|
||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||
elevation: 2,
|
||
shadowColor: Colors.grey.withOpacity(0.5),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
side: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('取消', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
if (choice == 'market') {
|
||
if (hasMarkets) {
|
||
await MarketSelectionDialog.show(
|
||
context,
|
||
markets: info.appMarkets!,
|
||
onSelected: (market) async {
|
||
if (market.url != null && market.url!.isNotEmpty) {
|
||
_plugin.goToAppStore(market.url!);
|
||
} else {
|
||
final appInfo = await _plugin.getAppInfo();
|
||
final pkg = appInfo['packageName'] ?? '';
|
||
if (pkg.isNotEmpty) {
|
||
_plugin.goToAppStore('market://details?id=$pkg');
|
||
}
|
||
}
|
||
},
|
||
);
|
||
} else {
|
||
final appInfo = await _plugin.getAppInfo();
|
||
final pkg = appInfo['packageName'] ?? '';
|
||
if (pkg.isNotEmpty) {
|
||
_plugin.goToAppStore('market://details?id=$pkg');
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop();
|
||
onComplete?.call();
|
||
return;
|
||
}
|
||
|
||
if (choice == 'download' && hasDownload && !_isDownloading) {
|
||
await _startDownloadAndInstall();
|
||
}
|
||
}
|
||
|
||
void _handleAction() {
|
||
if (Platform.isAndroid) {
|
||
_handleAndroidAction();
|
||
} else if (Platform.isIOS) {
|
||
_handleIosAction();
|
||
} else {
|
||
showToast('Unsupported platform');
|
||
}
|
||
}
|
||
|
||
/// Handles the upgrade action for iOS.
|
||
void _handleIosAction() {
|
||
if (info.appStoreUrl != null) {
|
||
_plugin.goToAppStore(info.appStoreUrl!);
|
||
// Pop the upgrade dialog.
|
||
if (Navigator.canPop(context)) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
onComplete?.call();
|
||
} else {
|
||
showToast('App Store URL is not available.');
|
||
}
|
||
}
|
||
|
||
/// Handles the upgrade action for Android.
|
||
Future<void> _handleAndroidAction() async {
|
||
// On Android, we always assume a market option is available,
|
||
// because we can fall back to a generic market:// intent.
|
||
const bool hasMarketOption = true;
|
||
final bool hasDownloadOption = info.downloadUrl != null;
|
||
|
||
// This case is unlikely on Android, but kept for robustness.
|
||
if (!hasMarketOption && !hasDownloadOption) {
|
||
showToast('No update method available.');
|
||
return;
|
||
}
|
||
|
||
// If a download option is not available, going to the market is the only choice.
|
||
if (!hasDownloadOption) {
|
||
_handleMarketAction();
|
||
return;
|
||
}
|
||
|
||
// If a download option exists, always give the user a choice,
|
||
// as the market option is also implicitly available.
|
||
await _showDownloadChoiceSheet();
|
||
}
|
||
|
||
/// Opens the app store or shows a market selection dialog.
|
||
Future<void> _performMarketAction() async {
|
||
final hasMarkets = info.appMarkets?.isNotEmpty ?? false;
|
||
if (hasMarkets) {
|
||
await MarketSelectionDialog.show(
|
||
context,
|
||
markets: info.appMarkets!,
|
||
onSelected: (market) {
|
||
_plugin.goToAppStore(market.url ?? market.packageName ?? '');
|
||
},
|
||
);
|
||
} else {
|
||
// No specific markets, try a generic market link.
|
||
final appInfo = await _plugin.getAppInfo();
|
||
final pkg = appInfo['packageName'] ?? '';
|
||
if (pkg.isNotEmpty) {
|
||
_plugin.goToAppStore('market://details?id=$pkg');
|
||
// _plugin.goToAppStore('market://details?id=$pkg');
|
||
} else {
|
||
showToast('Could not determine app package name.');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pops the current dialog and then performs the market action.
|
||
Future<void> _handleMarketAction() async {
|
||
// Pop the upgrade dialog before proceeding.
|
||
if (Navigator.canPop(context)) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
if (!mounted) return;
|
||
await _performMarketAction();
|
||
onComplete?.call();
|
||
}
|
||
|
||
Future<void> _showDownloadChoiceSheet() async {
|
||
final bool hasDownload = info.downloadUrl != null;
|
||
|
||
if (!mounted) return;
|
||
|
||
final choice = await showModalBottomSheet<String>(
|
||
context: context,
|
||
isScrollControlled: false,
|
||
useRootNavigator: true,
|
||
// UI Beautification: Rounded corners
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Draggable handle
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade300,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
// Title and Close Button
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8.0),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
const Text('选择更新方式', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
Positioned(
|
||
right: -12,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.close),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Option 1: App Market
|
||
ListTile(
|
||
leading: const Icon(Icons.storefront_outlined),
|
||
title: const Text('前往应用市场更新'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onTap: () => Navigator.of(ctx).pop('market'),
|
||
),
|
||
|
||
// Option 2: Direct Download
|
||
if (hasDownload)
|
||
ListTile(
|
||
leading: const Icon(Icons.download_for_offline_outlined),
|
||
title: const Text('直接下载安装包'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onTap: () => Navigator.of(ctx).pop('download'),
|
||
),
|
||
|
||
const Divider(height: 24),
|
||
|
||
// Cancel Button
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
backgroundColor: Colors.white,
|
||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||
elevation: 2,
|
||
shadowColor: Colors.grey.withOpacity(0.5),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
side: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('取消', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
if (choice == 'market') {
|
||
await _performMarketAction();
|
||
onComplete?.call();
|
||
return;
|
||
}
|
||
|
||
if (choice == 'download' && hasDownload && !_isDownloading) {
|
||
await _startDownloadAndInstall();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 简化版升级对话框(非强制更新)
|
||
class _SimpleUpgradeDialog extends StatefulWidget {
|
||
final UpgradeInfo info;
|
||
final bool autoDownload;
|
||
final bool autoInstall;
|
||
final VoidCallback? onComplete;
|
||
final void Function(String) showToast;
|
||
|
||
const _SimpleUpgradeDialog({
|
||
required this.info,
|
||
required this.autoDownload,
|
||
required this.autoInstall,
|
||
this.onComplete,
|
||
required this.showToast,
|
||
});
|
||
|
||
@override
|
||
State<_SimpleUpgradeDialog> createState() => _SimpleUpgradeDialogState();
|
||
}
|
||
|
||
class _SimpleUpgradeDialogState extends State<_SimpleUpgradeDialog> with _UpgradeDialogLogic {
|
||
@override
|
||
UpgradeInfo get info => widget.info;
|
||
@override
|
||
void Function(String) get showToast => widget.showToast;
|
||
@override
|
||
VoidCallback? get onComplete => widget.onComplete;
|
||
@override
|
||
bool get autoDownload => widget.autoDownload;
|
||
@override
|
||
bool get autoInstall => widget.autoInstall;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return AlertDialog(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
contentPadding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||
title: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.system_update,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text('发现新版本'),
|
||
],
|
||
),
|
||
content: _UpgradeDialogContent(
|
||
info: widget.info,
|
||
isDownloading: _isDownloading,
|
||
downloadProgress: _downloadProgress,
|
||
statusText: _statusText,
|
||
),
|
||
actionsAlignment: MainAxisAlignment.end,
|
||
actions: _isDownloading
|
||
? []
|
||
: [
|
||
if (!widget.info.isForceUpdate)
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
widget.onComplete?.call();
|
||
},
|
||
child: const Text('稍后更新'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: _handleAction,
|
||
child: Text(Platform.isAndroid ? '立即更新' : '前往更新'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 强制更新对话框
|
||
class _ForceUpgradeDialog extends StatefulWidget {
|
||
final UpgradeInfo info;
|
||
|
||
const _ForceUpgradeDialog({required this.info});
|
||
|
||
@override
|
||
State<_ForceUpgradeDialog> createState() => _ForceUpgradeDialogState();
|
||
}
|
||
|
||
class _ForceUpgradeDialogState extends State<_ForceUpgradeDialog> {
|
||
final _plugin = AppUpgradePlugin();
|
||
bool _isDownloading = false;
|
||
double _downloadProgress = 0;
|
||
String _statusText = '';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 强制更新也需用户交互后再开始下载
|
||
}
|
||
|
||
Future<void> _startDownload() async {
|
||
// 下载前申请权限 (Just-in-Time)
|
||
if (Platform.isAndroid) {
|
||
final hasStorage = await PermissionHelper.checkAndRequestStoragePermission(context: context);
|
||
if (!hasStorage) {
|
||
setState(() {
|
||
_statusText = '缺少存储权限,无法下载';
|
||
});
|
||
return;
|
||
}
|
||
// 通知权限仅为辅助
|
||
await PermissionHelper.checkAndRequestNotificationPermission(context: context);
|
||
}
|
||
|
||
if (widget.info.downloadUrl == null) {
|
||
setState(() {
|
||
_statusText = '下载地址无效';
|
||
});
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_isDownloading = true;
|
||
_statusText = '准备下载...';
|
||
});
|
||
|
||
final filePath = await _plugin.downloadApk(
|
||
widget.info.downloadUrl!,
|
||
onProgress: (p) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_downloadProgress = p.progress;
|
||
_statusText = '下载中 ${p.percentage}%';
|
||
});
|
||
},
|
||
);
|
||
|
||
if (filePath != null) {
|
||
await _installApk(context, filePath);
|
||
}
|
||
}
|
||
|
||
// 进度已在 downloadApk 的回调中更新
|
||
|
||
Future<void> _installApk(BuildContext context, String filePath) async {
|
||
final hasPermission = await PermissionHelper.checkAndRequestInstallPermission(context: context);
|
||
if (!hasPermission) {
|
||
if (context.mounted) {
|
||
setState(() {
|
||
_statusText = '未授予安装权限,请手动授权后重试';
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
await _plugin.installApk(filePath);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return WillPopScope(
|
||
onWillPop: () async => false, // 强制更新,不允许返回
|
||
child: AlertDialog(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
contentPadding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||
title: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.system_update,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text('发现新版本 (强制)'),
|
||
],
|
||
),
|
||
content: _UpgradeDialogContent(
|
||
info: widget.info,
|
||
isDownloading: _isDownloading,
|
||
downloadProgress: _downloadProgress,
|
||
statusText: _statusText,
|
||
),
|
||
actionsAlignment: MainAxisAlignment.end,
|
||
actions: _isDownloading
|
||
? []
|
||
: [
|
||
ElevatedButton(
|
||
onPressed: () async {
|
||
// 强制更新下也先让用户选择安装方式
|
||
final hasMarkets = widget.info.appMarkets != null && widget.info.appMarkets!.isNotEmpty;
|
||
final hasDownload = widget.info.downloadUrl != null;
|
||
|
||
if (hasMarkets && hasDownload) {
|
||
final choice = await showModalBottomSheet<String>(
|
||
context: context,
|
||
isScrollControlled: false,
|
||
useRootNavigator: true,
|
||
// UI Beautification: Rounded corners
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Draggable handle
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade300,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
// Title and Close Button
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8.0),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
const Text('选择更新方式',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
Positioned(
|
||
right: -12,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.close),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Option 1: App Market
|
||
ListTile(
|
||
leading: const Icon(Icons.storefront_outlined),
|
||
title: const Text('前往应用市场更新'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onTap: () => Navigator.of(ctx).pop('market'),
|
||
),
|
||
|
||
// Option 2: Direct Download
|
||
if (hasDownload)
|
||
ListTile(
|
||
leading: const Icon(Icons.download_for_offline_outlined),
|
||
title: const Text('直接下载安装包'),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onTap: () => Navigator.of(ctx).pop('download'),
|
||
),
|
||
|
||
const Divider(height: 24),
|
||
|
||
// Cancel Button
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
backgroundColor: Colors.white,
|
||
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||
elevation: 2,
|
||
shadowColor: Colors.grey.withOpacity(0.5),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
side: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child:
|
||
const Text('取消', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
if (choice == 'market') {
|
||
Navigator.of(context).pop();
|
||
MarketSelectionDialog.show(
|
||
context,
|
||
markets: widget.info.appMarkets!,
|
||
onSelected: (market) {
|
||
_plugin.goToAppStore(market.url ?? market.packageName ?? '');
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
if (choice == 'download') {
|
||
await _startDownload();
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (hasMarkets) {
|
||
Navigator.of(context).pop();
|
||
MarketSelectionDialog.show(
|
||
context,
|
||
markets: widget.info.appMarkets!,
|
||
onSelected: (market) {
|
||
_plugin.goToAppStore(market.url ?? market.packageName ?? '');
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (hasDownload) {
|
||
await _startDownload();
|
||
return;
|
||
}
|
||
},
|
||
child: const Text('立即更新'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 当 [MaterialApp] 不可用时,提供一个基础的回退升级对话框
|
||
class _FallbackUpgradeDialog extends StatefulWidget {
|
||
final UpgradeInfo info;
|
||
final bool autoDownload;
|
||
final bool autoInstall;
|
||
final VoidCallback? onComplete;
|
||
final void Function(String) showToast;
|
||
|
||
const _FallbackUpgradeDialog({
|
||
required this.info,
|
||
required this.autoDownload,
|
||
required this.autoInstall,
|
||
this.onComplete,
|
||
required this.showToast,
|
||
});
|
||
|
||
@override
|
||
State<_FallbackUpgradeDialog> createState() => _FallbackUpgradeDialogState();
|
||
}
|
||
|
||
class _FallbackUpgradeDialogState extends State<_FallbackUpgradeDialog> with _UpgradeDialogLogic {
|
||
@override
|
||
UpgradeInfo get info => widget.info;
|
||
@override
|
||
void Function(String) get showToast => widget.showToast;
|
||
@override
|
||
VoidCallback? get onComplete => widget.onComplete;
|
||
@override
|
||
bool get autoDownload => widget.autoDownload;
|
||
@override
|
||
bool get autoInstall => widget.autoInstall;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final title = '发现新版本${widget.info.isForceUpdate ? " (强制)" : ""}';
|
||
|
||
final body = Center(
|
||
child: Material(
|
||
type: MaterialType.transparency,
|
||
child: Container(
|
||
margin: const EdgeInsets.all(24.0),
|
||
padding: const EdgeInsets.all(16.0),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16.0),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 16),
|
||
DefaultTextStyle(
|
||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||
child: _UpgradeDialogContent(
|
||
info: widget.info,
|
||
isDownloading: _isDownloading,
|
||
downloadProgress: _downloadProgress,
|
||
statusText: _statusText,
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
if (!widget.info.isForceUpdate && !_isDownloading) ...[
|
||
GestureDetector(
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
widget.onComplete?.call();
|
||
},
|
||
child: const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Text('稍后更新', style: TextStyle(color: Colors.blue)),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
GestureDetector(
|
||
onTap: _isDownloading ? null : _handleAction,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: _isDownloading ? Colors.grey : Colors.blue,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
_isDownloading
|
||
? '下载中...'
|
||
: Platform.isAndroid
|
||
? '立即更新'
|
||
: '前往更新',
|
||
style: const TextStyle(color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
return widget.info.isForceUpdate ? WillPopScope(onWillPop: () async => false, child: body) : body;
|
||
}
|
||
}
|