diff --git a/it0_app/android/app/src/main/AndroidManifest.xml b/it0_app/android/app/src/main/AndroidManifest.xml
index 60f9981..b97d576 100644
--- a/it0_app/android/app/src/main/AndroidManifest.xml
+++ b/it0_app/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
+
+
+
+
+ when (call.method) {
+ "installApk" -> {
+ val apkPath = call.argument("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()
+ }
+}
diff --git a/it0_app/android/app/src/main/res/xml/file_paths.xml b/it0_app/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..5b5ec12
--- /dev/null
+++ b/it0_app/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/it0_app/lib/core/config/api_endpoints.dart b/it0_app/lib/core/config/api_endpoints.dart
index d439f22..66cbd99 100644
--- a/it0_app/lib/core/config/api_endpoints.dart
+++ b/it0_app/lib/core/config/api_endpoints.dart
@@ -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';
}
diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart
index 3ef0423..398cb64 100644
--- a/it0_app/lib/core/router/app_router.dart
+++ b/it0_app/lib/core/router/app_router.dart
@@ -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((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 createState() => _ScaffoldWithNavState();
+}
+
+class _ScaffoldWithNavState extends ConsumerState
+ 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 _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]);
}
},
),
diff --git a/it0_app/lib/core/updater/apk_installer.dart b/it0_app/lib/core/updater/apk_installer.dart
new file mode 100644
index 0000000..f32e4c7
--- /dev/null
+++ b/it0_app/lib/core/updater/apk_installer.dart
@@ -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 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 _requestInstallPermission() async {
+ if (await Permission.requestInstallPackages.isGranted) {
+ return true;
+ }
+
+ final status = await Permission.requestInstallPackages.request();
+ return status.isGranted;
+ }
+
+ /// 检查是否有安装权限
+ static Future hasInstallPermission() async {
+ return await Permission.requestInstallPackages.isGranted;
+ }
+}
diff --git a/it0_app/lib/core/updater/app_market_detector.dart b/it0_app/lib/core/updater/app_market_detector.dart
new file mode 100644
index 0000000..66e0ef0
--- /dev/null
+++ b/it0_app/lib/core/updater/app_market_detector.dart
@@ -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 _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 getInstallerPackageName() async {
+ if (!Platform.isAndroid) return null;
+
+ try {
+ final installer =
+ await _channel.invokeMethod('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 isFromAppMarket() async {
+ final installer = await getInstallerPackageName();
+
+ if (installer == null || installer.isEmpty) {
+ return false;
+ }
+
+ return _marketPackages.contains(installer);
+ }
+
+ /// 获取安装来源名称(用于显示)
+ static Future 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 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;
+ }
+ }
+}
diff --git a/it0_app/lib/core/updater/channels/self_hosted_updater.dart b/it0_app/lib/core/updater/channels/self_hosted_updater.dart
new file mode 100644
index 0000000..794f2d9
--- /dev/null
+++ b/it0_app/lib/core/updater/channels/self_hosted_updater.dart
@@ -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 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 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 _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 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 _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 _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(
+ _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('立即安装'),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/it0_app/lib/core/updater/download_manager.dart b/it0_app/lib/core/updater/download_manager.dart
new file mode 100644
index 0000000..4882353
--- /dev/null
+++ b/it0_app/lib/core/updater/download_manager.dart
@@ -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 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(
+ 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 _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 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');
+ }
+ }
+}
diff --git a/it0_app/lib/core/updater/models/update_config.dart b/it0_app/lib/core/updater/models/update_config.dart
new file mode 100644
index 0000000..6c5f39b
--- /dev/null
+++ b/it0_app/lib/core/updater/models/update_config.dart
@@ -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,
+ );
+ }
+}
diff --git a/it0_app/lib/core/updater/models/version_info.dart b/it0_app/lib/core/updater/models/version_info.dart
new file mode 100644
index 0000000..8138ab4
--- /dev/null
+++ b/it0_app/lib/core/updater/models/version_info.dart
@@ -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 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),
+ );
+ }
+}
diff --git a/it0_app/lib/core/updater/update_service.dart b/it0_app/lib/core/updater/update_service.dart
new file mode 100644
index 0000000..7a52b9e
--- /dev/null
+++ b/it0_app/lib/core/updater/update_service.dart
@@ -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 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 silentCheck() async {
+ if (!_isInitialized || !_config.enabled) return null;
+
+ if (_config.channel == UpdateChannel.selfHosted) {
+ return await _selfHostedUpdater?.silentCheckUpdate();
+ }
+
+ return null;
+ }
+
+ /// 手动检查更新(带加载提示)
+ Future 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 cleanup() async {
+ await _selfHostedUpdater?.cleanup();
+ }
+}
diff --git a/it0_app/lib/core/updater/version_checker.dart b/it0_app/lib/core/updater/version_checker.dart
new file mode 100644
index 0000000..bbba1c8
--- /dev/null
+++ b/it0_app/lib/core/updater/version_checker.dart
@@ -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 getCurrentVersion() async {
+ return await PackageInfo.fromPlatform();
+ }
+
+ /// 从服务器获取最新版本信息
+ Future 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 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;
+ }
+ }
+}
diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart
index 6728808..384ea5e 100644
--- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart
+++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart
@@ -107,7 +107,7 @@ class _ChatPageState extends ConsumerState {
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 {
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 {
),
],
),
- body: Column(
+ body: SafeArea(
+ top: false,
+ child: Column(
children: [
// Error banner
if (chatState.error != null)
@@ -282,6 +284,7 @@ class _ChatPageState extends ConsumerState {
_buildInputArea(chatState),
],
),
+ ),
);
}
@@ -327,38 +330,36 @@ class _ChatPageState extends ConsumerState {
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 {
// 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
// ---------------------------------------------------------------------------
diff --git a/it0_app/lib/features/chat/presentation/providers/chat_providers.dart b/it0_app/lib/features/chat/presentation/providers/chat_providers.dart
index efa357c..ca8a8c0 100644
--- a/it0_app/lib/features/chat/presentation/providers/chat_providers.dart
+++ b/it0_app/lib/features/chat/presentation/providers/chat_providers.dart
@@ -260,7 +260,22 @@ class ChatNotifier extends StateNotifier {
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(
diff --git a/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart b/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart
index c376ab9..c93452f 100644
--- a/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart
+++ b/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart
@@ -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,
+ ),
);
}
}
diff --git a/it0_app/lib/features/settings/presentation/pages/settings_page.dart b/it0_app/lib/features/settings/presentation/pages/settings_page.dart
index 099d97e..ea655e4 100644
--- a/it0_app/lib/features/settings/presentation/pages/settings_page.dart
+++ b/it0_app/lib/features/settings/presentation/pages/settings_page.dart
@@ -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 {
+ String _appVersion = '';
+
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(accountProfileProvider.notifier).loadProfile();
});
+ _loadVersion();
+ }
+
+ Future _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 {
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,
diff --git a/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart b/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart
index a65afc1..ed7d1a6 100644
--- a/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart
+++ b/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart
@@ -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 order;
- const _StandingOrderCard({required this.order});
+ const StandingOrderCard({required this.order});
@override
- ConsumerState<_StandingOrderCard> createState() =>
- _StandingOrderCardState();
+ ConsumerState createState() =>
+ StandingOrderCardState();
}
-class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
+class StandingOrderCardState extends ConsumerState {
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