feat: add app upgrade system with self-hosted APK update support
- Add core/updater module: version checker, download manager (resumable + SHA-256), APK installer, app market detector, self-hosted updater with progress dialogs - Add Android native MethodChannels for APK installation and market detection - Add FileProvider config and REQUEST_INSTALL_PACKAGES permission - Wire UpdateService singleton into main.dart initialization - Add auto-check on home entry with cooldown + app resume detection - Add manual "检查更新" button and dynamic version display in settings - Fix chat page: bottom overflow, bash spinner persistence, collapsible results - Merge standing orders into tasks page as second tab Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3278696f4c
commit
f5d9b1f04f
|
|
@ -2,6 +2,7 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<application
|
<application
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:label="iAgent"
|
android:label="iAgent"
|
||||||
|
|
@ -29,6 +30,16 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- FileProvider for APK installation (Android 7.0+) -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ public final class GeneratedPluginRegistrant {
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin flutter_tts, com.eyedeadevelopment.fluttertts.FlutterTtsPlugin", e);
|
Log.e(TAG, "Error registering plugin flutter_tts, com.eyedeadevelopment.fluttertts.FlutterTtsPlugin", e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,88 @@
|
||||||
package com.iagent.it0_app
|
package com.iagent.it0_app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
private val INSTALLER_CHANNEL = "com.iagent.it0_app/apk_installer"
|
||||||
|
private val MARKET_CHANNEL = "com.iagent.it0_app/app_market"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
// APK 安装器通道
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"installApk" -> {
|
||||||
|
val apkPath = call.argument<String>("apkPath")
|
||||||
|
if (apkPath != null) {
|
||||||
|
try {
|
||||||
|
installApk(apkPath)
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("INSTALL_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_PATH", "APK path is null", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用市场检测通道
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MARKET_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"getInstallerPackageName" -> {
|
||||||
|
try {
|
||||||
|
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageManager.getInstallerPackageName(packageName)
|
||||||
|
}
|
||||||
|
result.success(installer)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installApk(apkPath: String) {
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
throw Exception("APK file not found: $apkPath")
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
val apkUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"${applicationContext.packageName}.fileprovider",
|
||||||
|
apkFile
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Uri.fromFile(apkFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||||
|
startActivity(intent)
|
||||||
|
|
||||||
|
// 关闭当前应用,让系统完成安装
|
||||||
|
finishAffinity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- 应用内部文件目录 -->
|
||||||
|
<files-path name="internal_files" path="." />
|
||||||
|
<!-- Flutter Documents 目录 -->
|
||||||
|
<files-path name="app_flutter" path="app_flutter/" />
|
||||||
|
<!-- 应用缓存目录 -->
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<!-- 应用外部文件目录 -->
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
</paths>
|
||||||
|
|
@ -48,6 +48,9 @@ class ApiEndpoints {
|
||||||
static const String adminTheme = '$adminSettings/theme';
|
static const String adminTheme = '$adminSettings/theme';
|
||||||
static const String adminNotifications = '$adminSettings/notifications';
|
static const String adminNotifications = '$adminSettings/notifications';
|
||||||
|
|
||||||
|
// App Update
|
||||||
|
static const String appVersionCheck = '/api/app/version/check';
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket
|
||||||
static const String wsTerminal = '/ws/terminal';
|
static const String wsTerminal = '/ws/terminal';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../updater/update_service.dart';
|
||||||
import '../widgets/offline_banner.dart';
|
import '../widgets/offline_banner.dart';
|
||||||
import '../../features/auth/data/providers/auth_provider.dart';
|
import '../../features/auth/data/providers/auth_provider.dart';
|
||||||
import '../../features/auth/presentation/pages/login_page.dart';
|
import '../../features/auth/presentation/pages/login_page.dart';
|
||||||
|
|
@ -72,11 +74,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
class ScaffoldWithNav extends ConsumerWidget {
|
class ScaffoldWithNav extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const ScaffoldWithNav({super.key, required this.child});
|
const ScaffoldWithNav({super.key, required this.child});
|
||||||
|
|
||||||
static const _routes = [
|
static const routes = [
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/chat',
|
'/chat',
|
||||||
'/tasks',
|
'/tasks',
|
||||||
|
|
@ -84,16 +86,69 @@ class ScaffoldWithNav extends ConsumerWidget {
|
||||||
'/settings',
|
'/settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ScaffoldWithNav> createState() => _ScaffoldWithNavState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
DateTime? _lastUpdateCheck;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// Check for update after a short delay on first entry
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkForUpdateIfNeeded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_checkForUpdateIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkForUpdateIfNeeded() async {
|
||||||
|
final updateService = UpdateService();
|
||||||
|
if (!updateService.isInitialized) return;
|
||||||
|
if (updateService.isShowingUpdateDialog) return;
|
||||||
|
|
||||||
|
// Cooldown: 90-300 seconds between checks
|
||||||
|
if (_lastUpdateCheck != null) {
|
||||||
|
final cooldown = 90 + Random().nextInt(210);
|
||||||
|
final elapsed =
|
||||||
|
DateTime.now().difference(_lastUpdateCheck!).inSeconds;
|
||||||
|
if (elapsed < cooldown) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUpdateCheck = DateTime.now();
|
||||||
|
|
||||||
|
// Delay 3 seconds to let the UI settle
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await updateService.checkForUpdate(context);
|
||||||
|
}
|
||||||
|
|
||||||
int _selectedIndex(BuildContext context) {
|
int _selectedIndex(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).uri.path;
|
final location = GoRouterState.of(context).uri.path;
|
||||||
for (int i = 0; i < _routes.length; i++) {
|
for (int i = 0; i < ScaffoldWithNav.routes.length; i++) {
|
||||||
if (location.startsWith(_routes[i])) return i;
|
if (location.startsWith(ScaffoldWithNav.routes[i])) return i;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||||
final currentIndex = _selectedIndex(context);
|
final currentIndex = _selectedIndex(context);
|
||||||
|
|
||||||
|
|
@ -101,7 +156,7 @@ class ScaffoldWithNav extends ConsumerWidget {
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
const OfflineBanner(),
|
const OfflineBanner(),
|
||||||
Expanded(child: child),
|
Expanded(child: widget.child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
|
|
@ -139,7 +194,7 @@ class ScaffoldWithNav extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
if (index != currentIndex) {
|
if (index != currentIndex) {
|
||||||
GoRouter.of(context).go(_routes[index]);
|
GoRouter.of(context).go(ScaffoldWithNav.routes[index]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
/// APK 安装器
|
||||||
|
/// 负责调用原生代码安装 APK 文件
|
||||||
|
class ApkInstaller {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.iagent.it0_app/apk_installer');
|
||||||
|
|
||||||
|
/// 安装 APK
|
||||||
|
static Future<bool> installApk(File apkFile) async {
|
||||||
|
try {
|
||||||
|
if (!await apkFile.exists()) {
|
||||||
|
debugPrint('APK file not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 8.0+ 需要请求安装权限
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final hasPermission = await _requestInstallPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
debugPrint('Install permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Installing APK: ${apkFile.path}');
|
||||||
|
final result = await _channel.invokeMethod('installApk', {
|
||||||
|
'apkPath': apkFile.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('Installation triggered: $result');
|
||||||
|
return result == true;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('Install failed (PlatformException): ${e.message}');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Install failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求安装权限(Android 8.0+)
|
||||||
|
static Future<bool> _requestInstallPermission() async {
|
||||||
|
if (await Permission.requestInstallPackages.isGranted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = await Permission.requestInstallPackages.request();
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有安装权限
|
||||||
|
static Future<bool> hasInstallPermission() async {
|
||||||
|
return await Permission.requestInstallPackages.isGranted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// 应用市场检测器
|
||||||
|
/// 检测应用安装来源,决定升级策略
|
||||||
|
class AppMarketDetector {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.iagent.it0_app/app_market');
|
||||||
|
|
||||||
|
/// 常见应用市场包名
|
||||||
|
static const List<String> _marketPackages = [
|
||||||
|
'com.android.vending', // Google Play
|
||||||
|
'com.huawei.appmarket', // 华为应用市场
|
||||||
|
'com.xiaomi.market', // 小米应用商店
|
||||||
|
'com.oppo.market', // OPPO 软件商店
|
||||||
|
'com.bbk.appstore', // vivo 应用商店
|
||||||
|
'com.tencent.android.qqdownloader', // 应用宝
|
||||||
|
'com.qihoo.appstore', // 360 手机助手
|
||||||
|
'com.baidu.appsearch', // 百度手机助手
|
||||||
|
'com.wandoujia.phoenix2', // 豌豆荚
|
||||||
|
'com.dragon.android.pandaspace', // 91 助手
|
||||||
|
'com.sec.android.app.samsungapps', // 三星应用商店
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 获取安装来源
|
||||||
|
static Future<String?> getInstallerPackageName() async {
|
||||||
|
if (!Platform.isAndroid) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final installer =
|
||||||
|
await _channel.invokeMethod<String>('getInstallerPackageName');
|
||||||
|
return installer;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('Get installer failed (PlatformException): ${e.message}');
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Get installer failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否来自应用市场
|
||||||
|
static Future<bool> isFromAppMarket() async {
|
||||||
|
final installer = await getInstallerPackageName();
|
||||||
|
|
||||||
|
if (installer == null || installer.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _marketPackages.contains(installer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取安装来源名称(用于显示)
|
||||||
|
static Future<String> getInstallerName() async {
|
||||||
|
final installer = await getInstallerPackageName();
|
||||||
|
|
||||||
|
if (installer == null || installer.isEmpty) {
|
||||||
|
return '直接安装';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (installer) {
|
||||||
|
case 'com.android.vending':
|
||||||
|
return 'Google Play';
|
||||||
|
case 'com.huawei.appmarket':
|
||||||
|
return '华为应用市场';
|
||||||
|
case 'com.xiaomi.market':
|
||||||
|
return '小米应用商店';
|
||||||
|
case 'com.oppo.market':
|
||||||
|
return 'OPPO 软件商店';
|
||||||
|
case 'com.bbk.appstore':
|
||||||
|
return 'vivo 应用商店';
|
||||||
|
case 'com.tencent.android.qqdownloader':
|
||||||
|
return '应用宝';
|
||||||
|
case 'com.qihoo.appstore':
|
||||||
|
return '360 手机助手';
|
||||||
|
case 'com.baidu.appsearch':
|
||||||
|
return '百度手机助手';
|
||||||
|
case 'com.wandoujia.phoenix2':
|
||||||
|
return '豌豆荚';
|
||||||
|
case 'com.sec.android.app.samsungapps':
|
||||||
|
return '三星应用商店';
|
||||||
|
default:
|
||||||
|
return installer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开应用市场详情页
|
||||||
|
static Future<bool> openAppMarketDetail(String packageName) async {
|
||||||
|
final marketUri = Uri.parse('market://details?id=$packageName');
|
||||||
|
|
||||||
|
if (await canLaunchUrl(marketUri)) {
|
||||||
|
await launchUrl(marketUri, mode: LaunchMode.externalApplication);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
final webUri = Uri.parse(
|
||||||
|
'https://play.google.com/store/apps/details?id=$packageName');
|
||||||
|
if (await canLaunchUrl(webUri)) {
|
||||||
|
await launchUrl(webUri, mode: LaunchMode.externalApplication);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
import '../version_checker.dart';
|
||||||
|
import '../download_manager.dart';
|
||||||
|
import '../apk_installer.dart';
|
||||||
|
import '../app_market_detector.dart';
|
||||||
|
import '../models/version_info.dart';
|
||||||
|
|
||||||
|
/// 自建服务器更新器
|
||||||
|
/// 负责从自建服务器下载 APK 并安装
|
||||||
|
class SelfHostedUpdater {
|
||||||
|
final VersionChecker versionChecker;
|
||||||
|
final DownloadManager downloadManager;
|
||||||
|
|
||||||
|
SelfHostedUpdater({
|
||||||
|
required String apiBaseUrl,
|
||||||
|
}) : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl),
|
||||||
|
downloadManager = DownloadManager();
|
||||||
|
|
||||||
|
/// 检查并提示更新
|
||||||
|
Future<void> checkAndPromptUpdate(BuildContext context) async {
|
||||||
|
debugPrint('[SelfHostedUpdater] 开始检查更新...');
|
||||||
|
final versionInfo = await versionChecker.checkForUpdate();
|
||||||
|
|
||||||
|
if (versionInfo == null) {
|
||||||
|
debugPrint('[SelfHostedUpdater] 已是最新版本,无需更新');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[SelfHostedUpdater] 发现新版本: ${versionInfo.version}');
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// 检测安装来源
|
||||||
|
final isFromMarket = await AppMarketDetector.isFromAppMarket();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (isFromMarket) {
|
||||||
|
_showMarketUpdateDialog(context, versionInfo);
|
||||||
|
} else {
|
||||||
|
_showSelfHostedUpdateDialog(context, versionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默检查更新(不显示对话框)
|
||||||
|
Future<VersionInfo?> silentCheckUpdate() async {
|
||||||
|
return await versionChecker.checkForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用市场更新提示
|
||||||
|
void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
versionInfo.forceUpdate ? '发现重要更新' : '发现新版本',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'最新版本: ${versionInfo.version}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (versionInfo.updateLog != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'更新内容:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
versionInfo.updateLog!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!versionInfo.forceUpdate)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('稍后'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final packageInfo =
|
||||||
|
await versionChecker.getCurrentVersion();
|
||||||
|
await AppMarketDetector.openAppMarketDetail(
|
||||||
|
packageInfo.packageName);
|
||||||
|
},
|
||||||
|
child: const Text('前往应用市场'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自建更新对话框
|
||||||
|
void _showSelfHostedUpdateDialog(
|
||||||
|
BuildContext context, VersionInfo versionInfo) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
versionInfo.forceUpdate ? '发现重要更新' : '发现新版本',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'最新版本: ${versionInfo.version}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'文件大小: ${versionInfo.fileSizeFriendly}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (versionInfo.updateLog != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'更新内容:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
versionInfo.updateLog!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!versionInfo.forceUpdate)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('暂时不更新'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_startUpdate(context, versionInfo);
|
||||||
|
},
|
||||||
|
child: const Text('立即更新'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始下载并安装
|
||||||
|
Future<void> _startUpdate(
|
||||||
|
BuildContext context, VersionInfo versionInfo) async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => _DownloadProgressDialog(
|
||||||
|
versionInfo: versionInfo,
|
||||||
|
downloadManager: downloadManager,
|
||||||
|
forceUpdate: versionInfo.forceUpdate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理下载的 APK
|
||||||
|
Future<void> cleanup() async {
|
||||||
|
await downloadManager.cleanupDownloadedApk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载进度对话框
|
||||||
|
class _DownloadProgressDialog extends StatefulWidget {
|
||||||
|
final VersionInfo versionInfo;
|
||||||
|
final DownloadManager downloadManager;
|
||||||
|
final bool forceUpdate;
|
||||||
|
|
||||||
|
const _DownloadProgressDialog({
|
||||||
|
required this.versionInfo,
|
||||||
|
required this.downloadManager,
|
||||||
|
required this.forceUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DownloadProgressDialog> createState() =>
|
||||||
|
_DownloadProgressDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadProgressDialogState extends State<_DownloadProgressDialog> {
|
||||||
|
double _progress = 0.0;
|
||||||
|
String _statusText = '准备下载...';
|
||||||
|
bool _isDownloading = true;
|
||||||
|
bool _hasError = false;
|
||||||
|
bool _downloadCompleted = false;
|
||||||
|
File? _downloadedApkFile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startDownload() async {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '正在下载...';
|
||||||
|
_isDownloading = true;
|
||||||
|
_hasError = false;
|
||||||
|
_downloadCompleted = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
final apkFile = await widget.downloadManager.downloadApk(
|
||||||
|
url: widget.versionInfo.downloadUrl,
|
||||||
|
sha256Expected: widget.versionInfo.sha256,
|
||||||
|
onProgress: (received, total) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_progress = received / total;
|
||||||
|
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
_statusText = '正在下载... $receivedMB MB / $totalMB MB';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (apkFile == null) {
|
||||||
|
setState(() {
|
||||||
|
_statusText =
|
||||||
|
widget.downloadManager.status == DownloadStatus.cancelled
|
||||||
|
? '下载已取消'
|
||||||
|
: '下载失败,请稍后重试';
|
||||||
|
_isDownloading = false;
|
||||||
|
_hasError = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloadedApkFile = apkFile;
|
||||||
|
|
||||||
|
if (widget.forceUpdate) {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '下载完成,准备安装...';
|
||||||
|
});
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
await _installApk();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '下载完成';
|
||||||
|
_isDownloading = false;
|
||||||
|
_downloadCompleted = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _installApk() async {
|
||||||
|
if (_downloadedApkFile == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_statusText = '正在安装...';
|
||||||
|
_isDownloading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final installed = await ApkInstaller.installApk(_downloadedApkFile!);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '安装失败,请手动安装';
|
||||||
|
_isDownloading = false;
|
||||||
|
_hasError = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelDownload() {
|
||||||
|
widget.downloadManager.cancelDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retryDownload() {
|
||||||
|
widget.downloadManager.reset();
|
||||||
|
_startDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
String title;
|
||||||
|
if (_downloadCompleted && !_hasError) {
|
||||||
|
title = '下载完成';
|
||||||
|
} else if (_hasError) {
|
||||||
|
title = '更新失败';
|
||||||
|
} else {
|
||||||
|
title = '正在更新';
|
||||||
|
}
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isDownloading && !widget.forceUpdate,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: _progress,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
_hasError ? AppColors.error : AppColors.info,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: _hasError
|
||||||
|
? AppColors.error
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isDownloading) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(_progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.info,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_isDownloading)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _cancelDownload,
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
if (!_isDownloading && _hasError) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('关闭'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _retryDownload,
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_downloadCompleted && !_hasError) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('稍后安装'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _installApk,
|
||||||
|
child: const Text('立即安装'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 下载状态
|
||||||
|
enum DownloadStatus {
|
||||||
|
idle,
|
||||||
|
downloading,
|
||||||
|
verifying,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载进度回调
|
||||||
|
typedef DownloadProgressCallback = void Function(int received, int total);
|
||||||
|
|
||||||
|
/// 下载管理器
|
||||||
|
/// 负责下载 APK 文件并验证完整性,支持断点续传
|
||||||
|
class DownloadManager {
|
||||||
|
final Dio _dio = Dio();
|
||||||
|
CancelToken? _cancelToken;
|
||||||
|
DownloadStatus _status = DownloadStatus.idle;
|
||||||
|
|
||||||
|
/// 当前下载状态
|
||||||
|
DownloadStatus get status => _status;
|
||||||
|
|
||||||
|
/// 下载 APK 文件(支持断点续传)
|
||||||
|
Future<File?> downloadApk({
|
||||||
|
required String url,
|
||||||
|
required String sha256Expected,
|
||||||
|
DownloadProgressCallback? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// 强制 HTTPS
|
||||||
|
if (!url.startsWith('https://')) {
|
||||||
|
debugPrint('Download URL must use HTTPS');
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = DownloadStatus.downloading;
|
||||||
|
_cancelToken = CancelToken();
|
||||||
|
|
||||||
|
final dir = await getExternalStorageDirectory() ??
|
||||||
|
await getApplicationSupportDirectory();
|
||||||
|
final savePath = '${dir.path}/app_update.apk';
|
||||||
|
final tempPath = '${dir.path}/app_update.apk.tmp';
|
||||||
|
final file = File(savePath);
|
||||||
|
final tempFile = File(tempPath);
|
||||||
|
|
||||||
|
// 检查是否有未完成的下载(断点续传)
|
||||||
|
int downloadedBytes = 0;
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
downloadedBytes = await tempFile.length();
|
||||||
|
debugPrint('Found partial download: $downloadedBytes bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果完整文件已存在则删除
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Downloading APK to: $savePath (resume from: $downloadedBytes bytes)');
|
||||||
|
|
||||||
|
// 使用流式下载以支持断点续传
|
||||||
|
final response = await _dio.get<ResponseBody>(
|
||||||
|
url,
|
||||||
|
cancelToken: _cancelToken,
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
receiveTimeout: const Duration(minutes: 10),
|
||||||
|
sendTimeout: const Duration(minutes: 10),
|
||||||
|
headers: downloadedBytes > 0
|
||||||
|
? {'Range': 'bytes=$downloadedBytes-'}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取总文件大小
|
||||||
|
int totalBytes = 0;
|
||||||
|
final contentLength = response.headers.value('content-length');
|
||||||
|
final contentRange = response.headers.value('content-range');
|
||||||
|
|
||||||
|
if (contentRange != null) {
|
||||||
|
final match =
|
||||||
|
RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange);
|
||||||
|
if (match != null) {
|
||||||
|
totalBytes = int.parse(match.group(1)!);
|
||||||
|
}
|
||||||
|
} else if (contentLength != null) {
|
||||||
|
totalBytes = int.parse(contentLength) + downloadedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Total file size: $totalBytes bytes');
|
||||||
|
|
||||||
|
// 打开文件进行追加写入
|
||||||
|
final sink = tempFile.openWrite(mode: FileMode.append);
|
||||||
|
int receivedBytes = downloadedBytes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await for (final chunk in response.data!.stream) {
|
||||||
|
sink.add(chunk);
|
||||||
|
receivedBytes += chunk.length;
|
||||||
|
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
onProgress?.call(receivedBytes, totalBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sink.flush();
|
||||||
|
} finally {
|
||||||
|
await sink.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重命名临时文件为最终文件
|
||||||
|
await tempFile.rename(savePath);
|
||||||
|
|
||||||
|
debugPrint('Download completed');
|
||||||
|
_status = DownloadStatus.verifying;
|
||||||
|
|
||||||
|
// 校验 SHA-256
|
||||||
|
final isValid = await _verifySha256(file, sha256Expected);
|
||||||
|
if (!isValid) {
|
||||||
|
debugPrint('SHA-256 verification failed');
|
||||||
|
await file.delete();
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('SHA-256 verified');
|
||||||
|
_status = DownloadStatus.completed;
|
||||||
|
return file;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.type == DioExceptionType.cancel) {
|
||||||
|
debugPrint('Download cancelled');
|
||||||
|
_status = DownloadStatus.cancelled;
|
||||||
|
} else {
|
||||||
|
debugPrint('Download failed: $e');
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Download failed: $e');
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消下载
|
||||||
|
void cancelDownload() {
|
||||||
|
_cancelToken?.cancel('User cancelled download');
|
||||||
|
_status = DownloadStatus.cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置状态
|
||||||
|
void reset() {
|
||||||
|
_cancelToken = null;
|
||||||
|
_status = DownloadStatus.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验文件 SHA-256
|
||||||
|
Future<bool> _verifySha256(File file, String expectedSha256) async {
|
||||||
|
try {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final digest = sha256.convert(bytes);
|
||||||
|
final actualSha256 = digest.toString();
|
||||||
|
|
||||||
|
debugPrint('Expected SHA-256: $expectedSha256');
|
||||||
|
debugPrint('Actual SHA-256: $actualSha256');
|
||||||
|
|
||||||
|
return actualSha256.toLowerCase() == expectedSha256.toLowerCase();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('SHA-256 verification error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除已下载的 APK 文件和临时文件
|
||||||
|
Future<void> cleanupDownloadedApk() async {
|
||||||
|
try {
|
||||||
|
final dir = await getExternalStorageDirectory() ??
|
||||||
|
await getApplicationSupportDirectory();
|
||||||
|
final file = File('${dir.path}/app_update.apk');
|
||||||
|
final tempFile = File('${dir.path}/app_update.apk.tmp');
|
||||||
|
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
debugPrint('Cleaned up downloaded APK');
|
||||||
|
}
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
debugPrint('Cleaned up temporary APK file');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Cleanup failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/// 更新渠道类型
|
||||||
|
enum UpdateChannel {
|
||||||
|
/// Google Play 应用内更新
|
||||||
|
googlePlay,
|
||||||
|
|
||||||
|
/// 自建服务器 APK 升级
|
||||||
|
selfHosted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新配置
|
||||||
|
class UpdateConfig {
|
||||||
|
/// 更新渠道
|
||||||
|
final UpdateChannel channel;
|
||||||
|
|
||||||
|
/// API 基础地址 (selfHosted 模式必需)
|
||||||
|
final String? apiBaseUrl;
|
||||||
|
|
||||||
|
/// 是否启用更新检测
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// 检查更新间隔(秒)
|
||||||
|
final int checkIntervalSeconds;
|
||||||
|
|
||||||
|
const UpdateConfig({
|
||||||
|
required this.channel,
|
||||||
|
this.apiBaseUrl,
|
||||||
|
this.enabled = true,
|
||||||
|
this.checkIntervalSeconds = 86400, // 默认24小时
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 默认配置 - 自建服务器模式
|
||||||
|
static UpdateConfig selfHosted({
|
||||||
|
required String apiBaseUrl,
|
||||||
|
bool enabled = true,
|
||||||
|
int checkIntervalSeconds = 86400,
|
||||||
|
}) {
|
||||||
|
return UpdateConfig(
|
||||||
|
channel: UpdateChannel.selfHosted,
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
|
enabled: enabled,
|
||||||
|
checkIntervalSeconds: checkIntervalSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认配置 - Google Play 模式
|
||||||
|
static UpdateConfig googlePlay({
|
||||||
|
bool enabled = true,
|
||||||
|
int checkIntervalSeconds = 86400,
|
||||||
|
}) {
|
||||||
|
return UpdateConfig(
|
||||||
|
channel: UpdateChannel.googlePlay,
|
||||||
|
enabled: enabled,
|
||||||
|
checkIntervalSeconds: checkIntervalSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/// 版本信息模型
|
||||||
|
class VersionInfo {
|
||||||
|
/// 版本号: "1.2.0"
|
||||||
|
final String version;
|
||||||
|
|
||||||
|
/// 版本代码: 120
|
||||||
|
final int versionCode;
|
||||||
|
|
||||||
|
/// APK 下载地址
|
||||||
|
final String downloadUrl;
|
||||||
|
|
||||||
|
/// 文件大小(字节)
|
||||||
|
final int fileSize;
|
||||||
|
|
||||||
|
/// 友好显示: "25.6 MB"
|
||||||
|
final String fileSizeFriendly;
|
||||||
|
|
||||||
|
/// SHA-256 校验
|
||||||
|
final String sha256;
|
||||||
|
|
||||||
|
/// 是否强制更新
|
||||||
|
final bool forceUpdate;
|
||||||
|
|
||||||
|
/// 更新日志
|
||||||
|
final String? updateLog;
|
||||||
|
|
||||||
|
/// 发布时间
|
||||||
|
final DateTime releaseDate;
|
||||||
|
|
||||||
|
const VersionInfo({
|
||||||
|
required this.version,
|
||||||
|
required this.versionCode,
|
||||||
|
required this.downloadUrl,
|
||||||
|
required this.fileSize,
|
||||||
|
required this.fileSizeFriendly,
|
||||||
|
required this.sha256,
|
||||||
|
this.forceUpdate = false,
|
||||||
|
this.updateLog,
|
||||||
|
required this.releaseDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VersionInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return VersionInfo(
|
||||||
|
version: json['version'] as String,
|
||||||
|
versionCode: json['versionCode'] as int,
|
||||||
|
downloadUrl: json['downloadUrl'] as String,
|
||||||
|
fileSize: json['fileSize'] as int,
|
||||||
|
fileSizeFriendly: json['fileSizeFriendly'] as String,
|
||||||
|
sha256: json['sha256'] as String,
|
||||||
|
forceUpdate: json['forceUpdate'] as bool? ?? false,
|
||||||
|
updateLog: json['updateLog'] as String?,
|
||||||
|
releaseDate: DateTime.parse(json['releaseDate'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'channels/self_hosted_updater.dart';
|
||||||
|
import 'models/update_config.dart';
|
||||||
|
import 'models/version_info.dart';
|
||||||
|
|
||||||
|
/// 统一升级服务
|
||||||
|
/// 根据配置自动选择合适的升级渠道
|
||||||
|
class UpdateService {
|
||||||
|
static UpdateService? _instance;
|
||||||
|
UpdateService._();
|
||||||
|
|
||||||
|
factory UpdateService() {
|
||||||
|
_instance ??= UpdateService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
late UpdateConfig _config;
|
||||||
|
SelfHostedUpdater? _selfHostedUpdater;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _isShowingUpdateDialog = false;
|
||||||
|
|
||||||
|
/// 是否已初始化
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
|
||||||
|
/// 是否正在显示升级弹窗
|
||||||
|
bool get isShowingUpdateDialog => _isShowingUpdateDialog;
|
||||||
|
|
||||||
|
/// 当前配置
|
||||||
|
UpdateConfig get config => _config;
|
||||||
|
|
||||||
|
/// 初始化
|
||||||
|
void initialize(UpdateConfig config) {
|
||||||
|
_config = config;
|
||||||
|
|
||||||
|
if (config.channel == UpdateChannel.selfHosted) {
|
||||||
|
if (config.apiBaseUrl == null) {
|
||||||
|
throw ArgumentError('apiBaseUrl is required for self-hosted channel');
|
||||||
|
}
|
||||||
|
_selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('UpdateService initialized with channel: ${_config.channel}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查更新(自动显示对话框)
|
||||||
|
Future<void> checkForUpdate(BuildContext context) async {
|
||||||
|
if (!_isInitialized || !_config.enabled) return;
|
||||||
|
|
||||||
|
_isShowingUpdateDialog = true;
|
||||||
|
try {
|
||||||
|
switch (_config.channel) {
|
||||||
|
case UpdateChannel.googlePlay:
|
||||||
|
// Google Play 暂不支持,留作扩展
|
||||||
|
break;
|
||||||
|
case UpdateChannel.selfHosted:
|
||||||
|
await _selfHostedUpdater!.checkAndPromptUpdate(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isShowingUpdateDialog = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默检查更新(不显示对话框)
|
||||||
|
Future<VersionInfo?> silentCheck() async {
|
||||||
|
if (!_isInitialized || !_config.enabled) return null;
|
||||||
|
|
||||||
|
if (_config.channel == UpdateChannel.selfHosted) {
|
||||||
|
return await _selfHostedUpdater?.silentCheckUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动检查更新(带加载提示)
|
||||||
|
Future<void> manualCheckUpdate(BuildContext context) async {
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
// 显示加载中
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// 检查是否有更新
|
||||||
|
final versionInfo = await silentCheck();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (versionInfo == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('当前已是最新版本'),
|
||||||
|
backgroundColor: Color(0xFF22C55E),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await checkForUpdate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理缓存的更新文件
|
||||||
|
Future<void> cleanup() async {
|
||||||
|
await _selfHostedUpdater?.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'models/version_info.dart';
|
||||||
|
|
||||||
|
/// 版本检测器
|
||||||
|
/// 负责从服务器获取最新版本信息并与当前版本比较
|
||||||
|
class VersionChecker {
|
||||||
|
final String apiBaseUrl;
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
VersionChecker({required this.apiBaseUrl})
|
||||||
|
: _dio = Dio(BaseOptions(
|
||||||
|
baseUrl: apiBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
/// 获取当前版本信息
|
||||||
|
Future<PackageInfo> getCurrentVersion() async {
|
||||||
|
return await PackageInfo.fromPlatform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从服务器获取最新版本信息
|
||||||
|
Future<VersionInfo?> fetchLatestVersion() async {
|
||||||
|
try {
|
||||||
|
final currentInfo = await getCurrentVersion();
|
||||||
|
debugPrint('[VersionChecker] 当前版本: ${currentInfo.version}, buildNumber: ${currentInfo.buildNumber}');
|
||||||
|
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/app/version/check',
|
||||||
|
queryParameters: {
|
||||||
|
'platform': 'android',
|
||||||
|
'current_version': currentInfo.version,
|
||||||
|
'current_version_code': currentInfo.buildNumber,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
final needUpdate = response.data['needUpdate'] as bool? ?? true;
|
||||||
|
if (!needUpdate) {
|
||||||
|
debugPrint('[VersionChecker] 服务器返回无需更新');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
debugPrint('[VersionChecker] 服务器返回需要更新');
|
||||||
|
return VersionInfo.fromJson(response.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VersionChecker] 获取版本失败: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有新版本
|
||||||
|
Future<VersionInfo?> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
final currentInfo = await getCurrentVersion();
|
||||||
|
final latestInfo = await fetchLatestVersion();
|
||||||
|
|
||||||
|
if (latestInfo == null) return null;
|
||||||
|
|
||||||
|
final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0;
|
||||||
|
if (latestInfo.versionCode > currentCode) {
|
||||||
|
return latestInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Check update failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
isLast: isLast,
|
isLast: isLast,
|
||||||
icon: isExecuting ? null : Icons.check_circle_outline,
|
icon: isExecuting ? null : Icons.check_circle_outline,
|
||||||
content: tool != null && tool.input.isNotEmpty
|
content: tool != null && tool.input.isNotEmpty
|
||||||
? CodeBlock(text: tool.input)
|
? CodeBlock(text: tool.input, maxLines: 1)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -121,7 +121,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
isLast: isLast,
|
isLast: isLast,
|
||||||
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
|
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
|
||||||
content: tool?.output != null && tool!.output!.isNotEmpty
|
content: tool?.output != null && tool!.output!.isNotEmpty
|
||||||
? CodeBlock(
|
? _CollapsibleCodeBlock(
|
||||||
text: tool.output!,
|
text: tool.output!,
|
||||||
textColor: isError ? AppColors.error : null,
|
textColor: isError ? AppColors.error : null,
|
||||||
)
|
)
|
||||||
|
|
@ -215,7 +215,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Error banner
|
// Error banner
|
||||||
if (chatState.error != null)
|
if (chatState.error != null)
|
||||||
|
|
@ -282,6 +284,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
_buildInputArea(chatState),
|
_buildInputArea(chatState),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,7 +330,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -359,7 +361,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,6 +376,68 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
// Standing order content (embedded in timeline node)
|
// Standing order content (embedded in timeline node)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Collapsible code block for tool results – collapsed by default, tap to expand
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _CollapsibleCodeBlock extends StatefulWidget {
|
||||||
|
final String text;
|
||||||
|
final Color? textColor;
|
||||||
|
|
||||||
|
const _CollapsibleCodeBlock({
|
||||||
|
required this.text,
|
||||||
|
this.textColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CollapsibleCodeBlock> createState() => _CollapsibleCodeBlockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final lineCount = '\n'.allMatches(widget.text).length + 1;
|
||||||
|
// Short results (≤3 lines): always show fully
|
||||||
|
if (lineCount <= 3) {
|
||||||
|
return CodeBlock(text: widget.text, textColor: widget.textColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AnimatedCrossFade(
|
||||||
|
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
|
||||||
|
secondChild: CodeBlock(
|
||||||
|
text: widget.text,
|
||||||
|
textColor: widget.textColor,
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
crossFadeState: _expanded
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
_expanded ? '收起' : '展开 ($lineCount 行)',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.info,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Collapsible thinking content – expanded while streaming, collapsed when done
|
// Collapsible thinking content – expanded while streaming, collapsed when done
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,22 @@ class ChatNotifier extends StateNotifier<ChatState> {
|
||||||
if (summary.isNotEmpty && !hasAssistantText) {
|
if (summary.isNotEmpty && !hasAssistantText) {
|
||||||
_appendOrUpdateAssistantMessage(summary, MessageType.text);
|
_appendOrUpdateAssistantMessage(summary, MessageType.text);
|
||||||
}
|
}
|
||||||
state = state.copyWith(agentStatus: AgentStatus.idle);
|
// Mark any remaining executing tools as completed
|
||||||
|
final finalMessages = state.messages.map((m) {
|
||||||
|
if (m.type == MessageType.toolUse &&
|
||||||
|
m.toolExecution?.status == ToolStatus.executing) {
|
||||||
|
return m.copyWith(
|
||||||
|
toolExecution: m.toolExecution!.copyWith(
|
||||||
|
status: ToolStatus.completed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
state = state.copyWith(
|
||||||
|
messages: finalMessages,
|
||||||
|
agentStatus: AgentStatus.idle,
|
||||||
|
);
|
||||||
|
|
||||||
case ErrorEvent(:final message):
|
case ErrorEvent(:final message):
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,13 @@ class CodeBlock extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final style = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: textColor ?? AppColors.textSecondary,
|
||||||
|
height: 1.4,
|
||||||
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
|
|
@ -205,15 +212,16 @@ class CodeBlock extends StatelessWidget {
|
||||||
color: AppColors.background,
|
color: AppColors.background,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: SelectableText(
|
child: maxLines != null
|
||||||
text,
|
? Text(
|
||||||
style: TextStyle(
|
text.replaceAll('\n', ' '),
|
||||||
fontFamily: 'monospace',
|
style: style,
|
||||||
fontSize: 12,
|
|
||||||
color: textColor ?? AppColors.textSecondary,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: SelectableText(
|
||||||
|
text,
|
||||||
|
style: style,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../../core/updater/update_service.dart';
|
||||||
import '../../../auth/data/providers/auth_provider.dart';
|
import '../../../auth/data/providers/auth_provider.dart';
|
||||||
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
||||||
import '../providers/settings_providers.dart';
|
import '../providers/settings_providers.dart';
|
||||||
|
|
@ -14,12 +16,22 @@ class SettingsPage extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
|
String _appVersion = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
ref.read(accountProfileProvider.notifier).loadProfile();
|
ref.read(accountProfileProvider.notifier).loadProfile();
|
||||||
});
|
});
|
||||||
|
_loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVersion() async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _appVersion = 'v${info.version}+${info.buildNumber}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -120,9 +132,16 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
iconBg: const Color(0xFF64748B),
|
iconBg: const Color(0xFF64748B),
|
||||||
title: '版本',
|
title: '版本',
|
||||||
trailing: Text('v1.0.0',
|
trailing: Text(
|
||||||
|
_appVersion.isNotEmpty ? _appVersion : 'v1.0.0',
|
||||||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||||||
),
|
),
|
||||||
|
_SettingsRow(
|
||||||
|
icon: Icons.system_update_outlined,
|
||||||
|
iconBg: const Color(0xFF22C55E),
|
||||||
|
title: '检查更新',
|
||||||
|
onTap: () => UpdateService().manualCheckUpdate(context),
|
||||||
|
),
|
||||||
if (settings.selectedTenantName != null)
|
if (settings.selectedTenantName != null)
|
||||||
_SettingsRow(
|
_SettingsRow(
|
||||||
icon: Icons.business_outlined,
|
icon: Icons.business_outlined,
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class StandingOrdersPage extends ConsumerWidget {
|
||||||
itemCount: orders.length,
|
itemCount: orders.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final order = orders[index];
|
final order = orders[index];
|
||||||
return _StandingOrderCard(order: order);
|
return StandingOrderCard(order: order);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -78,16 +78,16 @@ class StandingOrdersPage extends ConsumerWidget {
|
||||||
// Standing order card widget
|
// Standing order card widget
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _StandingOrderCard extends ConsumerStatefulWidget {
|
class StandingOrderCard extends ConsumerStatefulWidget {
|
||||||
final Map<String, dynamic> order;
|
final Map<String, dynamic> order;
|
||||||
const _StandingOrderCard({required this.order});
|
const StandingOrderCard({required this.order});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_StandingOrderCard> createState() =>
|
ConsumerState<StandingOrderCard> createState() =>
|
||||||
_StandingOrderCardState();
|
StandingOrderCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
class StandingOrderCardState extends ConsumerState<StandingOrderCard> {
|
||||||
bool _toggling = false;
|
bool _toggling = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -166,7 +166,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
||||||
// Trigger type chip
|
// Trigger type chip
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_TriggerChip(triggerType: triggerType),
|
TriggerChip(triggerType: triggerType),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Icon(Icons.access_time,
|
Icon(Icons.access_time,
|
||||||
size: 14, color: AppColors.textMuted),
|
size: 14, color: AppColors.textMuted),
|
||||||
|
|
@ -210,7 +210,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
||||||
),
|
),
|
||||||
children: executions
|
children: executions
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.map((exec) => _ExecutionHistoryItem(execution: exec))
|
.map((exec) => ExecutionHistoryItem(execution: exec))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -247,9 +247,9 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
||||||
// Trigger type chip
|
// Trigger type chip
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _TriggerChip extends StatelessWidget {
|
class TriggerChip extends StatelessWidget {
|
||||||
final String triggerType;
|
final String triggerType;
|
||||||
const _TriggerChip({required this.triggerType});
|
const TriggerChip({required this.triggerType});
|
||||||
|
|
||||||
Color get _color {
|
Color get _color {
|
||||||
switch (triggerType.toLowerCase()) {
|
switch (triggerType.toLowerCase()) {
|
||||||
|
|
@ -309,9 +309,9 @@ class _TriggerChip extends StatelessWidget {
|
||||||
// Execution history item
|
// Execution history item
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _ExecutionHistoryItem extends StatelessWidget {
|
class ExecutionHistoryItem extends StatelessWidget {
|
||||||
final Map<String, dynamic> execution;
|
final Map<String, dynamic> execution;
|
||||||
const _ExecutionHistoryItem({required this.execution});
|
const ExecutionHistoryItem({required this.execution});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ import '../../../../core/utils/date_formatter.dart';
|
||||||
import '../../../../core/widgets/empty_state.dart';
|
import '../../../../core/widgets/empty_state.dart';
|
||||||
import '../../../../core/widgets/error_view.dart';
|
import '../../../../core/widgets/error_view.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
|
import '../../../standing_orders/presentation/pages/standing_orders_page.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TODO 41 – Tasks page: list + create form
|
// Providers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Fetches the full list of ops tasks from the backend.
|
/// Fetches the full list of ops tasks from the backend.
|
||||||
|
|
@ -42,21 +43,89 @@ final _serversForPickerProvider =
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tasks page – ConsumerWidget
|
// Tasks page – Tabbed (Tasks + Standing Orders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TasksPage extends ConsumerWidget {
|
class TasksPage extends ConsumerStatefulWidget {
|
||||||
const TasksPage({super.key});
|
const TasksPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<TasksPage> createState() => _TasksPageState();
|
||||||
final tasksAsync = ref.watch(tasksProvider);
|
}
|
||||||
|
|
||||||
|
class _TasksPageState extends ConsumerState<TasksPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(() => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('运维任务'),
|
title: const Text('任务'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: '运维任务'),
|
||||||
|
Tab(text: '常驻指令'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_TasksListBody(ref: ref),
|
||||||
|
_StandingOrdersListBody(ref: ref),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// FAB only on the tasks tab
|
||||||
|
floatingActionButton: _tabController.index == 0
|
||||||
|
? FloatingActionButton(
|
||||||
|
onPressed: () => _showCreateTaskSheet(context, ref),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCreateTaskSheet(BuildContext context, WidgetRef ref) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (context) => _CreateTaskSheet(ref: ref),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tasks list body (tab 1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _TasksListBody extends StatelessWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _TasksListBody({required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tasksAsync = ref.watch(tasksProvider);
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
onRefresh: () async => ref.invalidate(tasksProvider),
|
onRefresh: () async => ref.invalidate(tasksProvider),
|
||||||
child: tasksAsync.when(
|
child: tasksAsync.when(
|
||||||
data: (tasks) {
|
data: (tasks) {
|
||||||
|
|
@ -82,25 +151,48 @@ class TasksPage extends ConsumerWidget {
|
||||||
onRetry: () => ref.invalidate(tasksProvider),
|
onRetry: () => ref.invalidate(tasksProvider),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: () => _showCreateTaskSheet(context, ref),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Create task bottom sheet --------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// Standing orders list body (tab 2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void _showCreateTaskSheet(BuildContext context, WidgetRef ref) {
|
class _StandingOrdersListBody extends StatelessWidget {
|
||||||
showModalBottomSheet(
|
final WidgetRef ref;
|
||||||
context: context,
|
const _StandingOrdersListBody({required this.ref});
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: AppColors.surface,
|
@override
|
||||||
shape: const RoundedRectangleBorder(
|
Widget build(BuildContext context) {
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
final ordersAsync = ref.watch(standingOrdersProvider);
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(standingOrdersProvider),
|
||||||
|
child: ordersAsync.when(
|
||||||
|
data: (orders) {
|
||||||
|
if (orders.isEmpty) {
|
||||||
|
return const EmptyState(
|
||||||
|
icon: Icons.rule_outlined,
|
||||||
|
title: '暂无常驻指令',
|
||||||
|
subtitle: '通过 iAgent 对话创建常驻指令',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: orders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final order = orders[index];
|
||||||
|
return StandingOrderCard(order: order);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
error: e,
|
||||||
|
onRetry: () => ref.invalidate(standingOrdersProvider),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
builder: (context) => _CreateTaskSheet(ref: ref),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/config/app_config.dart';
|
||||||
import 'core/services/error_logger.dart';
|
import 'core/services/error_logger.dart';
|
||||||
|
import 'core/updater/update_service.dart';
|
||||||
|
import 'core/updater/models/update_config.dart';
|
||||||
import 'features/notifications/presentation/providers/notification_providers.dart';
|
import 'features/notifications/presentation/providers/notification_providers.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -44,6 +47,16 @@ void main() {
|
||||||
const initSettings = InitializationSettings(android: androidInit);
|
const initSettings = InitializationSettings(android: androidInit);
|
||||||
await localNotifications.initialize(initSettings);
|
await localNotifications.initialize(initSettings);
|
||||||
|
|
||||||
|
// Initialize app update service
|
||||||
|
final config = AppConfig.production();
|
||||||
|
UpdateService().initialize(
|
||||||
|
UpdateConfig.selfHosted(
|
||||||
|
apiBaseUrl: config.apiBaseUrl,
|
||||||
|
enabled: true,
|
||||||
|
checkIntervalSeconds: 86400, // 24小时
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
@ -696,6 +696,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ dependencies:
|
||||||
permission_handler: ^11.3.0
|
permission_handler: ^11.3.0
|
||||||
audio_session: ^0.2.2
|
audio_session: ^0.2.2
|
||||||
|
|
||||||
|
# App Update
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
crypto: ^3.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue