feat: add app upgrade system with self-hosted APK update support

- Add core/updater module: version checker, download manager (resumable + SHA-256),
  APK installer, app market detector, self-hosted updater with progress dialogs
- Add Android native MethodChannels for APK installation and market detection
- Add FileProvider config and REQUEST_INSTALL_PACKAGES permission
- Wire UpdateService singleton into main.dart initialization
- Add auto-check on home entry with cooldown + app resume detection
- Add manual "检查更新" button and dynamic version display in settings
- Fix chat page: bottom overflow, bash spinner persistence, collapsible results
- Merge standing orders into tasks page as second tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-26 22:35:01 -08:00
parent 3278696f4c
commit f5d9b1f04f
23 changed files with 1599 additions and 107 deletions

View File

@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:usesCleartextTraffic="true"
android:label="iAgent"
@ -29,6 +30,16 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- FileProvider for APK installation (Android 7.0+) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -40,6 +40,11 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_tts, com.eyedeadevelopment.fluttertts.FlutterTtsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {

View File

@ -1,5 +1,88 @@
package com.iagent.it0_app
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val INSTALLER_CHANNEL = "com.iagent.it0_app/apk_installer"
private val MARKET_CHANNEL = "com.iagent.it0_app/app_market"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// APK 安装器通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"installApk" -> {
val apkPath = call.argument<String>("apkPath")
if (apkPath != null) {
try {
installApk(apkPath)
result.success(true)
} catch (e: Exception) {
result.error("INSTALL_FAILED", e.message, null)
}
} else {
result.error("INVALID_PATH", "APK path is null", null)
}
}
else -> result.notImplemented()
}
}
// 应用市场检测通道
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MARKET_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getInstallerPackageName" -> {
try {
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
packageManager.getInstallSourceInfo(packageName).installingPackageName
} else {
@Suppress("DEPRECATION")
packageManager.getInstallerPackageName(packageName)
}
result.success(installer)
} catch (e: Exception) {
result.success(null)
}
}
else -> result.notImplemented()
}
}
}
private fun installApk(apkPath: String) {
val apkFile = File(apkPath)
if (!apkFile.exists()) {
throw Exception("APK file not found: $apkPath")
}
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val apkUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
FileProvider.getUriForFile(
this,
"${applicationContext.packageName}.fileprovider",
apkFile
)
} else {
Uri.fromFile(apkFile)
}
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
startActivity(intent)
// 关闭当前应用,让系统完成安装
finishAffinity()
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- 应用内部文件目录 -->
<files-path name="internal_files" path="." />
<!-- Flutter Documents 目录 -->
<files-path name="app_flutter" path="app_flutter/" />
<!-- 应用缓存目录 -->
<cache-path name="cache" path="." />
<!-- 应用外部文件目录 -->
<external-files-path name="external_files" path="." />
</paths>

View File

@ -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';
}

View File

@ -1,6 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../updater/update_service.dart';
import '../widgets/offline_banner.dart';
import '../../features/auth/data/providers/auth_provider.dart';
import '../../features/auth/presentation/pages/login_page.dart';
@ -72,11 +74,11 @@ final routerProvider = Provider<GoRouter>((ref) {
);
});
class ScaffoldWithNav extends ConsumerWidget {
class ScaffoldWithNav extends ConsumerStatefulWidget {
final Widget child;
const ScaffoldWithNav({super.key, required this.child});
static const _routes = [
static const routes = [
'/dashboard',
'/chat',
'/tasks',
@ -84,16 +86,69 @@ class ScaffoldWithNav extends ConsumerWidget {
'/settings',
];
@override
ConsumerState<ScaffoldWithNav> createState() => _ScaffoldWithNavState();
}
class _ScaffoldWithNavState extends ConsumerState<ScaffoldWithNav>
with WidgetsBindingObserver {
DateTime? _lastUpdateCheck;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Check for update after a short delay on first entry
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdateIfNeeded();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkForUpdateIfNeeded();
}
}
Future<void> _checkForUpdateIfNeeded() async {
final updateService = UpdateService();
if (!updateService.isInitialized) return;
if (updateService.isShowingUpdateDialog) return;
// Cooldown: 90-300 seconds between checks
if (_lastUpdateCheck != null) {
final cooldown = 90 + Random().nextInt(210);
final elapsed =
DateTime.now().difference(_lastUpdateCheck!).inSeconds;
if (elapsed < cooldown) return;
}
_lastUpdateCheck = DateTime.now();
// Delay 3 seconds to let the UI settle
await Future.delayed(const Duration(seconds: 3));
if (!mounted) return;
await updateService.checkForUpdate(context);
}
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
for (int i = 0; i < _routes.length; i++) {
if (location.startsWith(_routes[i])) return i;
for (int i = 0; i < ScaffoldWithNav.routes.length; i++) {
if (location.startsWith(ScaffoldWithNav.routes[i])) return i;
}
return 0;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final unreadCount = ref.watch(unreadNotificationCountProvider);
final currentIndex = _selectedIndex(context);
@ -101,7 +156,7 @@ class ScaffoldWithNav extends ConsumerWidget {
body: Column(
children: [
const OfflineBanner(),
Expanded(child: child),
Expanded(child: widget.child),
],
),
bottomNavigationBar: NavigationBar(
@ -139,7 +194,7 @@ class ScaffoldWithNav extends ConsumerWidget {
],
onDestinationSelected: (index) {
if (index != currentIndex) {
GoRouter.of(context).go(_routes[index]);
GoRouter.of(context).go(ScaffoldWithNav.routes[index]);
}
},
),

View File

@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
/// APK
/// APK
class ApkInstaller {
static const MethodChannel _channel =
MethodChannel('com.iagent.it0_app/apk_installer');
/// APK
static Future<bool> installApk(File apkFile) async {
try {
if (!await apkFile.exists()) {
debugPrint('APK file not found');
return false;
}
// Android 8.0+
if (Platform.isAndroid) {
final hasPermission = await _requestInstallPermission();
if (!hasPermission) {
debugPrint('Install permission denied');
return false;
}
}
debugPrint('Installing APK: ${apkFile.path}');
final result = await _channel.invokeMethod('installApk', {
'apkPath': apkFile.path,
});
debugPrint('Installation triggered: $result');
return result == true;
} on PlatformException catch (e) {
debugPrint('Install failed (PlatformException): ${e.message}');
return false;
} catch (e) {
debugPrint('Install failed: $e');
return false;
}
}
/// Android 8.0+
static Future<bool> _requestInstallPermission() async {
if (await Permission.requestInstallPackages.isGranted) {
return true;
}
final status = await Permission.requestInstallPackages.request();
return status.isGranted;
}
///
static Future<bool> hasInstallPermission() async {
return await Permission.requestInstallPackages.isGranted;
}
}

View File

@ -0,0 +1,106 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:url_launcher/url_launcher.dart';
///
///
class AppMarketDetector {
static const MethodChannel _channel =
MethodChannel('com.iagent.it0_app/app_market');
///
static const List<String> _marketPackages = [
'com.android.vending', // Google Play
'com.huawei.appmarket', //
'com.xiaomi.market', //
'com.oppo.market', // OPPO
'com.bbk.appstore', // vivo
'com.tencent.android.qqdownloader', //
'com.qihoo.appstore', // 360
'com.baidu.appsearch', //
'com.wandoujia.phoenix2', //
'com.dragon.android.pandaspace', // 91
'com.sec.android.app.samsungapps', //
];
///
static Future<String?> getInstallerPackageName() async {
if (!Platform.isAndroid) return null;
try {
final installer =
await _channel.invokeMethod<String>('getInstallerPackageName');
return installer;
} on PlatformException catch (e) {
debugPrint('Get installer failed (PlatformException): ${e.message}');
return null;
} catch (e) {
debugPrint('Get installer failed: $e');
return null;
}
}
///
static Future<bool> isFromAppMarket() async {
final installer = await getInstallerPackageName();
if (installer == null || installer.isEmpty) {
return false;
}
return _marketPackages.contains(installer);
}
///
static Future<String> getInstallerName() async {
final installer = await getInstallerPackageName();
if (installer == null || installer.isEmpty) {
return '直接安装';
}
switch (installer) {
case 'com.android.vending':
return 'Google Play';
case 'com.huawei.appmarket':
return '华为应用市场';
case 'com.xiaomi.market':
return '小米应用商店';
case 'com.oppo.market':
return 'OPPO 软件商店';
case 'com.bbk.appstore':
return 'vivo 应用商店';
case 'com.tencent.android.qqdownloader':
return '应用宝';
case 'com.qihoo.appstore':
return '360 手机助手';
case 'com.baidu.appsearch':
return '百度手机助手';
case 'com.wandoujia.phoenix2':
return '豌豆荚';
case 'com.sec.android.app.samsungapps':
return '三星应用商店';
default:
return installer;
}
}
///
static Future<bool> openAppMarketDetail(String packageName) async {
final marketUri = Uri.parse('market://details?id=$packageName');
if (await canLaunchUrl(marketUri)) {
await launchUrl(marketUri, mode: LaunchMode.externalApplication);
return true;
} else {
final webUri = Uri.parse(
'https://play.google.com/store/apps/details?id=$packageName');
if (await canLaunchUrl(webUri)) {
await launchUrl(webUri, mode: LaunchMode.externalApplication);
return true;
}
return false;
}
}
}

View File

@ -0,0 +1,428 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../theme/app_colors.dart';
import '../version_checker.dart';
import '../download_manager.dart';
import '../apk_installer.dart';
import '../app_market_detector.dart';
import '../models/version_info.dart';
///
/// APK
class SelfHostedUpdater {
final VersionChecker versionChecker;
final DownloadManager downloadManager;
SelfHostedUpdater({
required String apiBaseUrl,
}) : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl),
downloadManager = DownloadManager();
///
Future<void> checkAndPromptUpdate(BuildContext context) async {
debugPrint('[SelfHostedUpdater] 开始检查更新...');
final versionInfo = await versionChecker.checkForUpdate();
if (versionInfo == null) {
debugPrint('[SelfHostedUpdater] 已是最新版本,无需更新');
return;
}
debugPrint('[SelfHostedUpdater] 发现新版本: ${versionInfo.version}');
if (!context.mounted) return;
//
final isFromMarket = await AppMarketDetector.isFromAppMarket();
if (!context.mounted) return;
if (isFromMarket) {
_showMarketUpdateDialog(context, versionInfo);
} else {
_showSelfHostedUpdateDialog(context, versionInfo);
}
}
///
Future<VersionInfo?> silentCheckUpdate() async {
return await versionChecker.checkForUpdate();
}
///
void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => PopScope(
canPop: false,
child: AlertDialog(
title: Text(
versionInfo.forceUpdate ? '发现重要更新' : '发现新版本',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新版本: ${versionInfo.version}',
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
if (versionInfo.updateLog != null) ...[
const SizedBox(height: 16),
const Text(
'更新内容:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
versionInfo.updateLog!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
const SizedBox(height: 16),
const Text(
'检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。',
style: TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
actions: [
if (!versionInfo.forceUpdate)
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('稍后'),
),
FilledButton(
onPressed: () async {
Navigator.pop(context);
final packageInfo =
await versionChecker.getCurrentVersion();
await AppMarketDetector.openAppMarketDetail(
packageInfo.packageName);
},
child: const Text('前往应用市场'),
),
],
),
),
);
}
///
void _showSelfHostedUpdateDialog(
BuildContext context, VersionInfo versionInfo) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => PopScope(
canPop: false,
child: AlertDialog(
title: Text(
versionInfo.forceUpdate ? '发现重要更新' : '发现新版本',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新版本: ${versionInfo.version}',
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'文件大小: ${versionInfo.fileSizeFriendly}',
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
if (versionInfo.updateLog != null) ...[
const SizedBox(height: 16),
const Text(
'更新内容:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
versionInfo.updateLog!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
],
),
),
actions: [
if (!versionInfo.forceUpdate)
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('暂时不更新'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
_startUpdate(context, versionInfo);
},
child: const Text('立即更新'),
),
],
),
),
);
}
///
Future<void> _startUpdate(
BuildContext context, VersionInfo versionInfo) async {
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => _DownloadProgressDialog(
versionInfo: versionInfo,
downloadManager: downloadManager,
forceUpdate: versionInfo.forceUpdate,
),
);
}
/// APK
Future<void> cleanup() async {
await downloadManager.cleanupDownloadedApk();
}
}
///
class _DownloadProgressDialog extends StatefulWidget {
final VersionInfo versionInfo;
final DownloadManager downloadManager;
final bool forceUpdate;
const _DownloadProgressDialog({
required this.versionInfo,
required this.downloadManager,
required this.forceUpdate,
});
@override
State<_DownloadProgressDialog> createState() =>
_DownloadProgressDialogState();
}
class _DownloadProgressDialogState extends State<_DownloadProgressDialog> {
double _progress = 0.0;
String _statusText = '准备下载...';
bool _isDownloading = true;
bool _hasError = false;
bool _downloadCompleted = false;
File? _downloadedApkFile;
@override
void initState() {
super.initState();
_startDownload();
}
Future<void> _startDownload() async {
setState(() {
_statusText = '正在下载...';
_isDownloading = true;
_hasError = false;
_downloadCompleted = false;
});
final apkFile = await widget.downloadManager.downloadApk(
url: widget.versionInfo.downloadUrl,
sha256Expected: widget.versionInfo.sha256,
onProgress: (received, total) {
if (mounted) {
setState(() {
_progress = received / total;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
_statusText = '正在下载... $receivedMB MB / $totalMB MB';
});
}
},
);
if (!mounted) return;
if (apkFile == null) {
setState(() {
_statusText =
widget.downloadManager.status == DownloadStatus.cancelled
? '下载已取消'
: '下载失败,请稍后重试';
_isDownloading = false;
_hasError = true;
});
return;
}
_downloadedApkFile = apkFile;
if (widget.forceUpdate) {
setState(() {
_statusText = '下载完成,准备安装...';
});
await Future.delayed(const Duration(milliseconds: 500));
await _installApk();
} else {
setState(() {
_statusText = '下载完成';
_isDownloading = false;
_downloadCompleted = true;
});
}
}
Future<void> _installApk() async {
if (_downloadedApkFile == null) return;
setState(() {
_statusText = '正在安装...';
_isDownloading = true;
});
final installed = await ApkInstaller.installApk(_downloadedApkFile!);
if (!mounted) return;
if (installed) {
Navigator.pop(context);
} else {
setState(() {
_statusText = '安装失败,请手动安装';
_isDownloading = false;
_hasError = true;
});
}
}
void _cancelDownload() {
widget.downloadManager.cancelDownload();
}
void _retryDownload() {
widget.downloadManager.reset();
_startDownload();
}
@override
Widget build(BuildContext context) {
String title;
if (_downloadCompleted && !_hasError) {
title = '下载完成';
} else if (_hasError) {
title = '更新失败';
} else {
title = '正在更新';
}
return PopScope(
canPop: !_isDownloading && !widget.forceUpdate,
child: AlertDialog(
title: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(
value: _progress,
valueColor: AlwaysStoppedAnimation<Color>(
_hasError ? AppColors.error : AppColors.info,
),
),
const SizedBox(height: 16),
Text(
_statusText,
style: TextStyle(
fontSize: 14,
color: _hasError
? AppColors.error
: AppColors.textSecondary,
),
),
if (_isDownloading) ...[
const SizedBox(height: 8),
Text(
'${(_progress * 100).toStringAsFixed(0)}%',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.info,
),
),
],
],
),
actions: [
if (_isDownloading)
TextButton(
onPressed: _cancelDownload,
child: const Text('取消'),
),
if (!_isDownloading && _hasError) ...[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
FilledButton(
onPressed: _retryDownload,
child: const Text('重试'),
),
],
if (_downloadCompleted && !_hasError) ...[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('稍后安装'),
),
FilledButton(
onPressed: _installApk,
child: const Text('立即安装'),
),
],
],
),
);
}
}

View File

@ -0,0 +1,200 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
///
enum DownloadStatus {
idle,
downloading,
verifying,
completed,
failed,
cancelled,
}
///
typedef DownloadProgressCallback = void Function(int received, int total);
///
/// APK
class DownloadManager {
final Dio _dio = Dio();
CancelToken? _cancelToken;
DownloadStatus _status = DownloadStatus.idle;
///
DownloadStatus get status => _status;
/// APK
Future<File?> downloadApk({
required String url,
required String sha256Expected,
DownloadProgressCallback? onProgress,
}) async {
try {
// HTTPS
if (!url.startsWith('https://')) {
debugPrint('Download URL must use HTTPS');
_status = DownloadStatus.failed;
return null;
}
_status = DownloadStatus.downloading;
_cancelToken = CancelToken();
final dir = await getExternalStorageDirectory() ??
await getApplicationSupportDirectory();
final savePath = '${dir.path}/app_update.apk';
final tempPath = '${dir.path}/app_update.apk.tmp';
final file = File(savePath);
final tempFile = File(tempPath);
//
int downloadedBytes = 0;
if (await tempFile.exists()) {
downloadedBytes = await tempFile.length();
debugPrint('Found partial download: $downloadedBytes bytes');
}
//
if (await file.exists()) {
await file.delete();
}
debugPrint('Downloading APK to: $savePath (resume from: $downloadedBytes bytes)');
// 使
final response = await _dio.get<ResponseBody>(
url,
cancelToken: _cancelToken,
options: Options(
responseType: ResponseType.stream,
receiveTimeout: const Duration(minutes: 10),
sendTimeout: const Duration(minutes: 10),
headers: downloadedBytes > 0
? {'Range': 'bytes=$downloadedBytes-'}
: null,
),
);
//
int totalBytes = 0;
final contentLength = response.headers.value('content-length');
final contentRange = response.headers.value('content-range');
if (contentRange != null) {
final match =
RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange);
if (match != null) {
totalBytes = int.parse(match.group(1)!);
}
} else if (contentLength != null) {
totalBytes = int.parse(contentLength) + downloadedBytes;
}
debugPrint('Total file size: $totalBytes bytes');
//
final sink = tempFile.openWrite(mode: FileMode.append);
int receivedBytes = downloadedBytes;
try {
await for (final chunk in response.data!.stream) {
sink.add(chunk);
receivedBytes += chunk.length;
if (totalBytes > 0) {
onProgress?.call(receivedBytes, totalBytes);
}
}
await sink.flush();
} finally {
await sink.close();
}
//
await tempFile.rename(savePath);
debugPrint('Download completed');
_status = DownloadStatus.verifying;
// SHA-256
final isValid = await _verifySha256(file, sha256Expected);
if (!isValid) {
debugPrint('SHA-256 verification failed');
await file.delete();
_status = DownloadStatus.failed;
return null;
}
debugPrint('SHA-256 verified');
_status = DownloadStatus.completed;
return file;
} on DioException catch (e) {
if (e.type == DioExceptionType.cancel) {
debugPrint('Download cancelled');
_status = DownloadStatus.cancelled;
} else {
debugPrint('Download failed: $e');
_status = DownloadStatus.failed;
}
return null;
} catch (e) {
debugPrint('Download failed: $e');
_status = DownloadStatus.failed;
return null;
}
}
///
void cancelDownload() {
_cancelToken?.cancel('User cancelled download');
_status = DownloadStatus.cancelled;
}
///
void reset() {
_cancelToken = null;
_status = DownloadStatus.idle;
}
/// SHA-256
Future<bool> _verifySha256(File file, String expectedSha256) async {
try {
final bytes = await file.readAsBytes();
final digest = sha256.convert(bytes);
final actualSha256 = digest.toString();
debugPrint('Expected SHA-256: $expectedSha256');
debugPrint('Actual SHA-256: $actualSha256');
return actualSha256.toLowerCase() == expectedSha256.toLowerCase();
} catch (e) {
debugPrint('SHA-256 verification error: $e');
return false;
}
}
/// APK
Future<void> cleanupDownloadedApk() async {
try {
final dir = await getExternalStorageDirectory() ??
await getApplicationSupportDirectory();
final file = File('${dir.path}/app_update.apk');
final tempFile = File('${dir.path}/app_update.apk.tmp');
if (await file.exists()) {
await file.delete();
debugPrint('Cleaned up downloaded APK');
}
if (await tempFile.exists()) {
await tempFile.delete();
debugPrint('Cleaned up temporary APK file');
}
} catch (e) {
debugPrint('Cleanup failed: $e');
}
}
}

View File

@ -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,
);
}
}

View File

@ -0,0 +1,55 @@
///
class VersionInfo {
/// : "1.2.0"
final String version;
/// : 120
final int versionCode;
/// APK
final String downloadUrl;
/// ()
final int fileSize;
/// : "25.6 MB"
final String fileSizeFriendly;
/// SHA-256
final String sha256;
///
final bool forceUpdate;
///
final String? updateLog;
///
final DateTime releaseDate;
const VersionInfo({
required this.version,
required this.versionCode,
required this.downloadUrl,
required this.fileSize,
required this.fileSizeFriendly,
required this.sha256,
this.forceUpdate = false,
this.updateLog,
required this.releaseDate,
});
factory VersionInfo.fromJson(Map<String, dynamic> json) {
return VersionInfo(
version: json['version'] as String,
versionCode: json['versionCode'] as int,
downloadUrl: json['downloadUrl'] as String,
fileSize: json['fileSize'] as int,
fileSizeFriendly: json['fileSizeFriendly'] as String,
sha256: json['sha256'] as String,
forceUpdate: json['forceUpdate'] as bool? ?? false,
updateLog: json['updateLog'] as String?,
releaseDate: DateTime.parse(json['releaseDate'] as String),
);
}
}

View File

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'channels/self_hosted_updater.dart';
import 'models/update_config.dart';
import 'models/version_info.dart';
///
///
class UpdateService {
static UpdateService? _instance;
UpdateService._();
factory UpdateService() {
_instance ??= UpdateService._();
return _instance!;
}
late UpdateConfig _config;
SelfHostedUpdater? _selfHostedUpdater;
bool _isInitialized = false;
bool _isShowingUpdateDialog = false;
///
bool get isInitialized => _isInitialized;
///
bool get isShowingUpdateDialog => _isShowingUpdateDialog;
///
UpdateConfig get config => _config;
///
void initialize(UpdateConfig config) {
_config = config;
if (config.channel == UpdateChannel.selfHosted) {
if (config.apiBaseUrl == null) {
throw ArgumentError('apiBaseUrl is required for self-hosted channel');
}
_selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!);
}
_isInitialized = true;
debugPrint('UpdateService initialized with channel: ${_config.channel}');
}
///
Future<void> checkForUpdate(BuildContext context) async {
if (!_isInitialized || !_config.enabled) return;
_isShowingUpdateDialog = true;
try {
switch (_config.channel) {
case UpdateChannel.googlePlay:
// Google Play
break;
case UpdateChannel.selfHosted:
await _selfHostedUpdater!.checkAndPromptUpdate(context);
break;
}
} finally {
_isShowingUpdateDialog = false;
}
}
///
Future<VersionInfo?> silentCheck() async {
if (!_isInitialized || !_config.enabled) return null;
if (_config.channel == UpdateChannel.selfHosted) {
return await _selfHostedUpdater?.silentCheckUpdate();
}
return null;
}
///
Future<void> manualCheckUpdate(BuildContext context) async {
if (!_isInitialized) return;
//
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
await Future.delayed(const Duration(seconds: 1));
if (!context.mounted) return;
Navigator.pop(context);
//
final versionInfo = await silentCheck();
if (!context.mounted) return;
if (versionInfo == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('当前已是最新版本'),
backgroundColor: Color(0xFF22C55E),
duration: Duration(seconds: 2),
),
);
} else {
await checkForUpdate(context);
}
}
///
Future<void> cleanup() async {
await _selfHostedUpdater?.cleanup();
}
}

View File

@ -0,0 +1,74 @@
import 'package:package_info_plus/package_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'models/version_info.dart';
///
///
class VersionChecker {
final String apiBaseUrl;
final Dio _dio;
VersionChecker({required this.apiBaseUrl})
: _dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
///
Future<PackageInfo> getCurrentVersion() async {
return await PackageInfo.fromPlatform();
}
///
Future<VersionInfo?> fetchLatestVersion() async {
try {
final currentInfo = await getCurrentVersion();
debugPrint('[VersionChecker] 当前版本: ${currentInfo.version}, buildNumber: ${currentInfo.buildNumber}');
final response = await _dio.get(
'/api/app/version/check',
queryParameters: {
'platform': 'android',
'current_version': currentInfo.version,
'current_version_code': currentInfo.buildNumber,
},
);
if (response.statusCode == 200 && response.data != null) {
final needUpdate = response.data['needUpdate'] as bool? ?? true;
if (!needUpdate) {
debugPrint('[VersionChecker] 服务器返回无需更新');
return null;
}
debugPrint('[VersionChecker] 服务器返回需要更新');
return VersionInfo.fromJson(response.data);
}
return null;
} catch (e) {
debugPrint('[VersionChecker] 获取版本失败: $e');
return null;
}
}
///
Future<VersionInfo?> checkForUpdate() async {
try {
final currentInfo = await getCurrentVersion();
final latestInfo = await fetchLatestVersion();
if (latestInfo == null) return null;
final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0;
if (latestInfo.versionCode > currentCode) {
return latestInfo;
}
return null;
} catch (e) {
debugPrint('Check update failed: $e');
return null;
}
}
}

View File

@ -107,7 +107,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
isLast: isLast,
icon: isExecuting ? null : Icons.check_circle_outline,
content: tool != null && tool.input.isNotEmpty
? CodeBlock(text: tool.input)
? CodeBlock(text: tool.input, maxLines: 1)
: null,
);
@ -121,7 +121,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
isLast: isLast,
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
content: tool?.output != null && tool!.output!.isNotEmpty
? CodeBlock(
? _CollapsibleCodeBlock(
text: tool.output!,
textColor: isError ? AppColors.error : null,
)
@ -215,7 +215,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
],
),
body: Column(
body: SafeArea(
top: false,
child: Column(
children: [
// Error banner
if (chatState.error != null)
@ -282,6 +284,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_buildInputArea(chatState),
],
),
),
);
}
@ -327,38 +330,36 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: '输入指令...',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: '输入指令...',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
enabled: !isDisabled,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
enabled: !isDisabled,
),
const SizedBox(width: 8),
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
)
else
IconButton(
icon: const Icon(Icons.send),
onPressed: isDisabled ? null : _send,
),
],
),
),
const SizedBox(width: 8),
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
)
else
IconButton(
icon: const Icon(Icons.send),
onPressed: isDisabled ? null : _send,
),
],
),
);
}
@ -375,6 +376,68 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Standing order content (embedded in timeline node)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Collapsible code block for tool results collapsed by default, tap to expand
// ---------------------------------------------------------------------------
class _CollapsibleCodeBlock extends StatefulWidget {
final String text;
final Color? textColor;
const _CollapsibleCodeBlock({
required this.text,
this.textColor,
});
@override
State<_CollapsibleCodeBlock> createState() => _CollapsibleCodeBlockState();
}
class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final lineCount = '\n'.allMatches(widget.text).length + 1;
// Short results (3 lines): always show fully
if (lineCount <= 3) {
return CodeBlock(text: widget.text, textColor: widget.textColor);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedCrossFade(
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
secondChild: CodeBlock(
text: widget.text,
textColor: widget.textColor,
maxLines: 3,
),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_expanded ? '收起' : '展开 ($lineCount 行)',
style: TextStyle(
color: AppColors.info,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Collapsible thinking content expanded while streaming, collapsed when done
// ---------------------------------------------------------------------------

View File

@ -260,7 +260,22 @@ class ChatNotifier extends StateNotifier<ChatState> {
if (summary.isNotEmpty && !hasAssistantText) {
_appendOrUpdateAssistantMessage(summary, MessageType.text);
}
state = state.copyWith(agentStatus: AgentStatus.idle);
// Mark any remaining executing tools as completed
final finalMessages = state.messages.map((m) {
if (m.type == MessageType.toolUse &&
m.toolExecution?.status == ToolStatus.executing) {
return m.copyWith(
toolExecution: m.toolExecution!.copyWith(
status: ToolStatus.completed,
),
);
}
return m;
}).toList();
state = state.copyWith(
messages: finalMessages,
agentStatus: AgentStatus.idle,
);
case ErrorEvent(:final message):
state = state.copyWith(

View File

@ -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,
),
);
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/updater/update_service.dart';
import '../../../auth/data/providers/auth_provider.dart';
import '../../../agent_call/presentation/pages/voice_test_page.dart';
import '../providers/settings_providers.dart';
@ -14,12 +16,22 @@ class SettingsPage extends ConsumerStatefulWidget {
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
String _appVersion = '';
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(accountProfileProvider.notifier).loadProfile();
});
_loadVersion();
}
Future<void> _loadVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() => _appVersion = 'v${info.version}+${info.buildNumber}');
}
}
@override
@ -120,9 +132,16 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
icon: Icons.info_outline,
iconBg: const Color(0xFF64748B),
title: '版本',
trailing: Text('v1.0.0',
trailing: Text(
_appVersion.isNotEmpty ? _appVersion : 'v1.0.0',
style: TextStyle(color: subtitleColor, fontSize: 14)),
),
_SettingsRow(
icon: Icons.system_update_outlined,
iconBg: const Color(0xFF22C55E),
title: '检查更新',
onTap: () => UpdateService().manualCheckUpdate(context),
),
if (settings.selectedTenantName != null)
_SettingsRow(
icon: Icons.business_outlined,

View File

@ -59,7 +59,7 @@ class StandingOrdersPage extends ConsumerWidget {
itemCount: orders.length,
itemBuilder: (context, index) {
final order = orders[index];
return _StandingOrderCard(order: order);
return StandingOrderCard(order: order);
},
);
},
@ -78,16 +78,16 @@ class StandingOrdersPage extends ConsumerWidget {
// Standing order card widget
// ---------------------------------------------------------------------------
class _StandingOrderCard extends ConsumerStatefulWidget {
class StandingOrderCard extends ConsumerStatefulWidget {
final Map<String, dynamic> order;
const _StandingOrderCard({required this.order});
const StandingOrderCard({required this.order});
@override
ConsumerState<_StandingOrderCard> createState() =>
_StandingOrderCardState();
ConsumerState<StandingOrderCard> createState() =>
StandingOrderCardState();
}
class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
class StandingOrderCardState extends ConsumerState<StandingOrderCard> {
bool _toggling = false;
@override
@ -166,7 +166,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
// Trigger type chip
Row(
children: [
_TriggerChip(triggerType: triggerType),
TriggerChip(triggerType: triggerType),
const Spacer(),
Icon(Icons.access_time,
size: 14, color: AppColors.textMuted),
@ -210,7 +210,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
),
children: executions
.cast<Map<String, dynamic>>()
.map((exec) => _ExecutionHistoryItem(execution: exec))
.map((exec) => ExecutionHistoryItem(execution: exec))
.toList(),
),
),
@ -247,9 +247,9 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
// Trigger type chip
// ---------------------------------------------------------------------------
class _TriggerChip extends StatelessWidget {
class TriggerChip extends StatelessWidget {
final String triggerType;
const _TriggerChip({required this.triggerType});
const TriggerChip({required this.triggerType});
Color get _color {
switch (triggerType.toLowerCase()) {
@ -309,9 +309,9 @@ class _TriggerChip extends StatelessWidget {
// Execution history item
// ---------------------------------------------------------------------------
class _ExecutionHistoryItem extends StatelessWidget {
class ExecutionHistoryItem extends StatelessWidget {
final Map<String, dynamic> execution;
const _ExecutionHistoryItem({required this.execution});
const ExecutionHistoryItem({required this.execution});
@override
Widget build(BuildContext context) {

View File

@ -8,9 +8,10 @@ import '../../../../core/utils/date_formatter.dart';
import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
import '../../../../core/widgets/status_badge.dart';
import '../../../standing_orders/presentation/pages/standing_orders_page.dart';
// ---------------------------------------------------------------------------
// TODO 41 Tasks page: list + create form
// Providers
// ---------------------------------------------------------------------------
/// Fetches the full list of ops tasks from the backend.
@ -42,55 +43,62 @@ final _serversForPickerProvider =
});
// ---------------------------------------------------------------------------
// Tasks page ConsumerWidget
// Tasks page Tabbed (Tasks + Standing Orders)
// ---------------------------------------------------------------------------
class TasksPage extends ConsumerWidget {
class TasksPage extends ConsumerStatefulWidget {
const TasksPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksProvider);
ConsumerState<TasksPage> createState() => _TasksPageState();
}
return Scaffold(
appBar: AppBar(
title: const Text('运维任务'),
),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(tasksProvider),
child: tasksAsync.when(
data: (tasks) {
if (tasks.isEmpty) {
return const EmptyState(
icon: Icons.assignment_outlined,
title: '暂无任务',
subtitle: '点击 + 创建新任务',
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return _TaskCard(task: task);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
error: e,
onRetry: () => ref.invalidate(tasksProvider),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateTaskSheet(context, ref),
child: const Icon(Icons.add),
),
);
class _TasksPageState extends ConsumerState<TasksPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() => setState(() {}));
}
// ---- Create task bottom sheet --------------------------------------------
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('任务'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '运维任务'),
Tab(text: '常驻指令'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_TasksListBody(ref: ref),
_StandingOrdersListBody(ref: ref),
],
),
// FAB only on the tasks tab
floatingActionButton: _tabController.index == 0
? FloatingActionButton(
onPressed: () => _showCreateTaskSheet(context, ref),
child: const Icon(Icons.add),
)
: null,
);
}
void _showCreateTaskSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
@ -105,6 +113,90 @@ class TasksPage extends ConsumerWidget {
}
}
// ---------------------------------------------------------------------------
// Tasks list body (tab 1)
// ---------------------------------------------------------------------------
class _TasksListBody extends StatelessWidget {
final WidgetRef ref;
const _TasksListBody({required this.ref});
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(tasksProvider),
child: tasksAsync.when(
data: (tasks) {
if (tasks.isEmpty) {
return const EmptyState(
icon: Icons.assignment_outlined,
title: '暂无任务',
subtitle: '点击 + 创建新任务',
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return _TaskCard(task: task);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
error: e,
onRetry: () => ref.invalidate(tasksProvider),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Standing orders list body (tab 2)
// ---------------------------------------------------------------------------
class _StandingOrdersListBody extends StatelessWidget {
final WidgetRef ref;
const _StandingOrdersListBody({required this.ref});
@override
Widget build(BuildContext context) {
final ordersAsync = ref.watch(standingOrdersProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(standingOrdersProvider),
child: ordersAsync.when(
data: (orders) {
if (orders.isEmpty) {
return const EmptyState(
icon: Icons.rule_outlined,
title: '暂无常驻指令',
subtitle: '通过 iAgent 对话创建常驻指令',
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: orders.length,
itemBuilder: (context, index) {
final order = orders[index];
return StandingOrderCard(order: order);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
error: e,
onRetry: () => ref.invalidate(standingOrdersProvider),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Task card widget
// ---------------------------------------------------------------------------

View File

@ -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: [

View File

@ -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:

View File

@ -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