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.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="iAgent"
|
||||
|
|
@ -29,6 +30,16 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ public final class GeneratedPluginRegistrant {
|
|||
} catch (Exception 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 {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,88 @@
|
|||
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.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 adminNotifications = '$adminSettings/notifications';
|
||||
|
||||
// App Update
|
||||
static const String appVersionCheck = '/api/app/version/check';
|
||||
|
||||
// WebSocket
|
||||
static const String wsTerminal = '/ws/terminal';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../updater/update_service.dart';
|
||||
import '../widgets/offline_banner.dart';
|
||||
import '../../features/auth/data/providers/auth_provider.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;
|
||||
const ScaffoldWithNav({super.key, required this.child});
|
||||
|
||||
static const _routes = [
|
||||
static const routes = [
|
||||
'/dashboard',
|
||||
'/chat',
|
||||
'/tasks',
|
||||
|
|
@ -84,16 +86,69 @@ class ScaffoldWithNav extends ConsumerWidget {
|
|||
'/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) {
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
for (int i = 0; i < _routes.length; i++) {
|
||||
if (location.startsWith(_routes[i])) return i;
|
||||
for (int i = 0; i < ScaffoldWithNav.routes.length; i++) {
|
||||
if (location.startsWith(ScaffoldWithNav.routes[i])) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||
final currentIndex = _selectedIndex(context);
|
||||
|
||||
|
|
@ -101,7 +156,7 @@ class ScaffoldWithNav extends ConsumerWidget {
|
|||
body: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
Expanded(child: child),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
|
|
@ -139,7 +194,7 @@ class ScaffoldWithNav extends ConsumerWidget {
|
|||
],
|
||||
onDestinationSelected: (index) {
|
||||
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,
|
||||
icon: isExecuting ? null : Icons.check_circle_outline,
|
||||
content: tool != null && tool.input.isNotEmpty
|
||||
? CodeBlock(text: tool.input)
|
||||
? CodeBlock(text: tool.input, maxLines: 1)
|
||||
: null,
|
||||
);
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
isLast: isLast,
|
||||
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
|
||||
content: tool?.output != null && tool!.output!.isNotEmpty
|
||||
? CodeBlock(
|
||||
? _CollapsibleCodeBlock(
|
||||
text: tool.output!,
|
||||
textColor: isError ? AppColors.error : null,
|
||||
)
|
||||
|
|
@ -215,7 +215,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// Error banner
|
||||
if (chatState.error != null)
|
||||
|
|
@ -282,6 +284,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
_buildInputArea(chatState),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -327,38 +330,36 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
color: AppColors.surface,
|
||||
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入指令...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入指令...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _send(),
|
||||
enabled: !isDisabled,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _send(),
|
||||
enabled: !isDisabled,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (chatState.isStreaming)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
|
||||
tooltip: '停止',
|
||||
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: isDisabled ? null : _send,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (chatState.isStreaming)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
|
||||
tooltip: '停止',
|
||||
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: isDisabled ? null : _send,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -375,6 +376,68 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -260,7 +260,22 @@ class ChatNotifier extends StateNotifier<ChatState> {
|
|||
if (summary.isNotEmpty && !hasAssistantText) {
|
||||
_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):
|
||||
state = state.copyWith(
|
||||
|
|
|
|||
|
|
@ -198,6 +198,13 @@ class CodeBlock extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: textColor ?? AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
|
|
@ -205,16 +212,17 @@ class CodeBlock extends StatelessWidget {
|
|||
color: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: textColor ?? AppColors.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: maxLines,
|
||||
),
|
||||
child: maxLines != null
|
||||
? Text(
|
||||
text.replaceAll('\n', ' '),
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: SelectableText(
|
||||
text,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/updater/update_service.dart';
|
||||
import '../../../auth/data/providers/auth_provider.dart';
|
||||
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
||||
import '../providers/settings_providers.dart';
|
||||
|
|
@ -14,12 +16,22 @@ class SettingsPage extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
String _appVersion = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() {
|
||||
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
|
||||
|
|
@ -120,9 +132,16 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||
icon: Icons.info_outline,
|
||||
iconBg: const Color(0xFF64748B),
|
||||
title: '版本',
|
||||
trailing: Text('v1.0.0',
|
||||
trailing: Text(
|
||||
_appVersion.isNotEmpty ? _appVersion : 'v1.0.0',
|
||||
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)
|
||||
_SettingsRow(
|
||||
icon: Icons.business_outlined,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class StandingOrdersPage extends ConsumerWidget {
|
|||
itemCount: orders.length,
|
||||
itemBuilder: (context, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _StandingOrderCard extends ConsumerStatefulWidget {
|
||||
class StandingOrderCard extends ConsumerStatefulWidget {
|
||||
final Map<String, dynamic> order;
|
||||
const _StandingOrderCard({required this.order});
|
||||
const StandingOrderCard({required this.order});
|
||||
|
||||
@override
|
||||
ConsumerState<_StandingOrderCard> createState() =>
|
||||
_StandingOrderCardState();
|
||||
ConsumerState<StandingOrderCard> createState() =>
|
||||
StandingOrderCardState();
|
||||
}
|
||||
|
||||
class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
||||
class StandingOrderCardState extends ConsumerState<StandingOrderCard> {
|
||||
bool _toggling = false;
|
||||
|
||||
@override
|
||||
|
|
@ -166,7 +166,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
|||
// Trigger type chip
|
||||
Row(
|
||||
children: [
|
||||
_TriggerChip(triggerType: triggerType),
|
||||
TriggerChip(triggerType: triggerType),
|
||||
const Spacer(),
|
||||
Icon(Icons.access_time,
|
||||
size: 14, color: AppColors.textMuted),
|
||||
|
|
@ -210,7 +210,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
|||
),
|
||||
children: executions
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((exec) => _ExecutionHistoryItem(execution: exec))
|
||||
.map((exec) => ExecutionHistoryItem(execution: exec))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
|
@ -247,9 +247,9 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
|
|||
// Trigger type chip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _TriggerChip extends StatelessWidget {
|
||||
class TriggerChip extends StatelessWidget {
|
||||
final String triggerType;
|
||||
const _TriggerChip({required this.triggerType});
|
||||
const TriggerChip({required this.triggerType});
|
||||
|
||||
Color get _color {
|
||||
switch (triggerType.toLowerCase()) {
|
||||
|
|
@ -309,9 +309,9 @@ class _TriggerChip extends StatelessWidget {
|
|||
// Execution history item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ExecutionHistoryItem extends StatelessWidget {
|
||||
class ExecutionHistoryItem extends StatelessWidget {
|
||||
final Map<String, dynamic> execution;
|
||||
const _ExecutionHistoryItem({required this.execution});
|
||||
const ExecutionHistoryItem({required this.execution});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import '../../../../core/utils/date_formatter.dart';
|
|||
import '../../../../core/widgets/empty_state.dart';
|
||||
import '../../../../core/widgets/error_view.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.
|
||||
|
|
@ -42,55 +43,62 @@ final _serversForPickerProvider =
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks page – ConsumerWidget
|
||||
// Tasks page – Tabbed (Tasks + Standing Orders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class TasksPage extends ConsumerWidget {
|
||||
class TasksPage extends ConsumerStatefulWidget {
|
||||
const TasksPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
ConsumerState<TasksPage> createState() => _TasksPageState();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('运维任务'),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(tasksProvider),
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.assignment_outlined,
|
||||
title: '暂无任务',
|
||||
subtitle: '点击 + 创建新任务',
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
return _TaskCard(task: task);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => ErrorView(
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(tasksProvider),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showCreateTaskSheet(context, ref),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
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(() {}));
|
||||
}
|
||||
|
||||
// ---- Create task bottom sheet --------------------------------------------
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('任务'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: '运维任务'),
|
||||
Tab(text: '常驻指令'),
|
||||
],
|
||||
),
|
||||
),
|
||||
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(
|
||||
|
|
@ -105,6 +113,90 @@ class TasksPage extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.assignment_outlined,
|
||||
title: '暂无任务',
|
||||
subtitle: '点击 + 创建新任务',
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
return _TaskCard(task: task);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => ErrorView(
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(tasksProvider),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standing orders list body (tab 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _StandingOrdersListBody extends StatelessWidget {
|
||||
final WidgetRef ref;
|
||||
const _StandingOrdersListBody({required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task card widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'app.dart';
|
||||
import 'core/config/app_config.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';
|
||||
|
||||
void main() {
|
||||
|
|
@ -44,6 +47,16 @@ void main() {
|
|||
const initSettings = InitializationSettings(android: androidInit);
|
||||
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(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
@ -696,6 +696,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ dependencies:
|
|||
permission_handler: ^11.3.0
|
||||
audio_session: ^0.2.2
|
||||
|
||||
# App Update
|
||||
package_info_plus: ^8.0.0
|
||||
crypto: ^3.0.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
Loading…
Reference in New Issue