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>() - .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 execution; - const _ExecutionHistoryItem({required this.execution}); + const ExecutionHistoryItem({required this.execution}); @override Widget build(BuildContext context) { diff --git a/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart b/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart index 83e4d6e..5fea33d 100644 --- a/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart +++ b/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart @@ -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 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 + 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 // --------------------------------------------------------------------------- diff --git a/it0_app/lib/main.dart b/it0_app/lib/main.dart index d3d5198..af732af 100644 --- a/it0_app/lib/main.dart +++ b/it0_app/lib/main.dart @@ -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: [ diff --git a/it0_app/pubspec.lock b/it0_app/pubspec.lock index 04857cb..f893719 100644 --- a/it0_app/pubspec.lock +++ b/it0_app/pubspec.lock @@ -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: diff --git a/it0_app/pubspec.yaml b/it0_app/pubspec.yaml index 6de71da..da33668 100644 --- a/it0_app/pubspec.yaml +++ b/it0_app/pubspec.yaml @@ -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