This commit is contained in:
@@ -27,4 +27,12 @@ class StorageKeys {
|
||||
|
||||
// 搜索历史
|
||||
static const String searchHistory = 'search_history';
|
||||
|
||||
// 同步相关
|
||||
static const String syncConfig = 'sync_config';
|
||||
static const String syncState = 'sync_state';
|
||||
static const String clientId = 'client_id';
|
||||
|
||||
// 日志级别
|
||||
static const String logLevel = 'app_log_level';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:io';
|
||||
|
||||
class SyncDefaults {
|
||||
SyncDefaults._();
|
||||
|
||||
/// 默认同步目录
|
||||
static String defaultLocalRoot() {
|
||||
if (Platform.isWindows) {
|
||||
return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4';
|
||||
} else if (Platform.isLinux) {
|
||||
final xdgDownload =
|
||||
Platform.environment['XDG_DOWNLOAD_DIR'] ?? ("${Platform.environment['HOME'] ?? ''}/Downloads");
|
||||
return '$xdgDownload/Cloudreve4';
|
||||
} else if (Platform.isAndroid) {
|
||||
return ''; // Android 使用系统相册目录
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static const String defaultRemoteRoot = 'cloudreve://my';
|
||||
static const String defaultSyncMode = 'full';
|
||||
static const String defaultConflictStrategy = 'keep_both';
|
||||
static const int defaultMaxConcurrentTransfers = 3;
|
||||
static const int defaultBandwidthLimitKbps = 0;
|
||||
static const int defaultMaxWorkers = 0; // 0 = CPU 核心数
|
||||
static const String defaultLogLevel = 'info';
|
||||
}
|
||||
@@ -10,6 +10,10 @@ class AppLogger {
|
||||
static Logger? _logger;
|
||||
static File? _logFile;
|
||||
|
||||
/// 当前日志级别(默认 info,debug 模式下也是 info 避免刷屏)
|
||||
static Level _level = Level.info;
|
||||
static Level get level => _level;
|
||||
|
||||
/// 初始化日志,必须在 main 中 await
|
||||
static Future<void> init() async {
|
||||
if (_logger != null) return;
|
||||
@@ -22,6 +26,10 @@ class AppLogger {
|
||||
}
|
||||
_logFile = File(p.join(logDir.path, 'log.txt'));
|
||||
|
||||
_createLogger();
|
||||
}
|
||||
|
||||
static void _createLogger() {
|
||||
// 2. 配置多路输出:同时输出到控制台和文件
|
||||
_logger = Logger(
|
||||
printer: PrettyPrinter(
|
||||
@@ -38,10 +46,16 @@ class AppLogger {
|
||||
file: _logFile!,
|
||||
),
|
||||
]),
|
||||
filter: ProductionFilter(),
|
||||
filter: _LevelFilter(_level),
|
||||
);
|
||||
}
|
||||
|
||||
/// 运行时切换日志级别
|
||||
static void setLevel(Level level) {
|
||||
_level = level;
|
||||
_createLogger();
|
||||
}
|
||||
|
||||
// 使用 getter 确保 logger 已初始化,防止空指针
|
||||
static Logger get _instance {
|
||||
_logger ??= Logger(
|
||||
@@ -67,6 +81,9 @@ class AppLogger {
|
||||
/// Error 级别日志
|
||||
static void e(String message) => _instance.e(message);
|
||||
|
||||
/// Trace 级别日志(高频轮询/查询使用,仅 trace 级别可见)
|
||||
static void t(String message) => _instance.t(message);
|
||||
|
||||
/// Debug 级别日志(支持格式化)
|
||||
static void df(String message, List<Object> args) => _instance.d(message, error: args);
|
||||
|
||||
@@ -132,6 +149,17 @@ class AppLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义级别过滤器:低于设定级别的日志被过滤
|
||||
class _LevelFilter extends LogFilter {
|
||||
final Level minLevel;
|
||||
_LevelFilter(this.minLevel);
|
||||
|
||||
@override
|
||||
bool shouldLog(LogEvent event) {
|
||||
return event.level.index >= minLevel.index;
|
||||
}
|
||||
}
|
||||
|
||||
/// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加
|
||||
class CustomFileOutput extends LogOutput {
|
||||
final File file;
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
/// 文件工具类
|
||||
class FileUtils {
|
||||
/// 将路径转换为 Cloudreve URI 格式
|
||||
/// "/" → "cloudreve://my", "/subfolder" → "cloudreve://my/subfolder"
|
||||
static String safeDecodePathSegment(String value, {int maxPasses = 5}) {
|
||||
var decoded = value;
|
||||
|
||||
for (var i = 0; i < maxPasses; i++) {
|
||||
try {
|
||||
final next = Uri.decodeComponent(decoded);
|
||||
if (next == decoded) break;
|
||||
decoded = next;
|
||||
} on FormatException {
|
||||
break;
|
||||
} on ArgumentError {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
static String decodePathForDisplay(String path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => safeDecodePathSegment(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
static String toCloudreveUri(String path) {
|
||||
if (path.startsWith('cloudreve://')) return path;
|
||||
if (path == '/' || path.isEmpty) return 'cloudreve://my';
|
||||
@@ -11,69 +33,18 @@ class FileUtils {
|
||||
|
||||
static String toCloudreveUriWithQuery(
|
||||
String path,
|
||||
Map<String, Object?> queryParameters,
|
||||
Map<String, String> queryParameters,
|
||||
) {
|
||||
final baseUri = toCloudreveUri(path);
|
||||
final filteredQuery = <String, String>{};
|
||||
|
||||
for (final entry in queryParameters.entries) {
|
||||
final value = entry.value;
|
||||
if (value != null) {
|
||||
filteredQuery[entry.key] = value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredQuery.isEmpty) return baseUri;
|
||||
|
||||
final query = Uri(queryParameters: filteredQuery).query;
|
||||
final separator = baseUri.contains('?') ? '&' : '?';
|
||||
return '$baseUri$separator$query';
|
||||
final uri = Uri.parse(toCloudreveUri(path));
|
||||
return uri.replace(queryParameters: queryParameters).toString();
|
||||
}
|
||||
|
||||
static String toRelativePath(String path) {
|
||||
const prefix = 'cloudreve://my';
|
||||
if (!path.startsWith(prefix)) {
|
||||
return path.isEmpty ? '/' : path;
|
||||
}
|
||||
|
||||
var relative = path.substring(prefix.length);
|
||||
final queryIndex = relative.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
relative = relative.substring(0, queryIndex);
|
||||
}
|
||||
|
||||
return relative.isEmpty ? '/' : relative;
|
||||
}
|
||||
|
||||
static String decodePathForDisplay(String path) {
|
||||
final relativePath = toRelativePath(path);
|
||||
return relativePath.split('/').map(decodePathSegment).join('/');
|
||||
}
|
||||
|
||||
static String decodePathSegment(String value) {
|
||||
var decoded = value;
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
try {
|
||||
final next = Uri.decodeComponent(decoded);
|
||||
if (next == decoded) break;
|
||||
decoded = next;
|
||||
} on FormatException {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/// 获取文件扩展名
|
||||
static String getFileExtension(String fileName) {
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex == -1) return '';
|
||||
return fileName.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/// 判断是否为图片文件
|
||||
static bool isImageFile(String fileName) {
|
||||
const imageExtensions = [
|
||||
'jpg',
|
||||
@@ -88,24 +59,20 @@ class FileUtils {
|
||||
return imageExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为视频文件
|
||||
static bool isVideoFile(String fileName) {
|
||||
const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'];
|
||||
return videoExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为音频文件
|
||||
static bool isAudioFile(String fileName) {
|
||||
const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
return audioExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为PDF文件
|
||||
static bool isPdfFile(String fileName) {
|
||||
return getFileExtension(fileName) == 'pdf';
|
||||
}
|
||||
|
||||
/// 判断是否为文本文件
|
||||
static bool isTextFile(String fileName) {
|
||||
const textExtensions = [
|
||||
'txt',
|
||||
@@ -120,7 +87,6 @@ class FileUtils {
|
||||
return textExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为代码文件
|
||||
static bool isCodeFile(String fileName) {
|
||||
const codeExtensions = [
|
||||
'js',
|
||||
@@ -152,13 +118,11 @@ class FileUtils {
|
||||
return codeExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为压缩文件
|
||||
static bool isArchiveFile(String fileName) {
|
||||
const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
|
||||
return archiveExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为文档文件
|
||||
static bool isDocumentFile(String fileName) {
|
||||
const docExtensions = [
|
||||
'doc',
|
||||
@@ -174,7 +138,6 @@ class FileUtils {
|
||||
return docExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否可预览
|
||||
static bool isPreviewable(String fileName) {
|
||||
return isImageFile(fileName) ||
|
||||
isVideoFile(fileName) ||
|
||||
@@ -183,7 +146,6 @@ class FileUtils {
|
||||
isCodeFile(fileName);
|
||||
}
|
||||
|
||||
/// 获取MIME类型
|
||||
static String getMimeType(String fileName) {
|
||||
final ext = getFileExtension(fileName);
|
||||
final mimeTypes = {
|
||||
@@ -211,7 +173,6 @@ class FileUtils {
|
||||
return mimeTypes[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/// 获取文件图标
|
||||
static String getFileIcon(String fileName) {
|
||||
if (isImageFile(fileName)) return 'assets/icons/image.svg';
|
||||
if (isVideoFile(fileName)) return 'assets/icons/video.svg';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class AppUpdateInfo {
|
||||
final String version;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Uri pageUrl;
|
||||
final Uri? downloadUrl;
|
||||
final String? fileName;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const AppUpdateInfo({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.pageUrl,
|
||||
this.description,
|
||||
this.downloadUrl,
|
||||
this.fileName,
|
||||
this.publishedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import '../../src/rust/api/ffi_types.dart' as ffi;
|
||||
|
||||
class SyncConfigModel {
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String localRoot;
|
||||
final String remoteRoot;
|
||||
final String syncMode;
|
||||
final String conflictStrategy;
|
||||
final String wcfDeleteMode;
|
||||
final int maxConcurrentTransfers;
|
||||
final int bandwidthLimitKbps;
|
||||
final List<String> excludedPaths;
|
||||
final int maxWorkers;
|
||||
final String dataDir;
|
||||
final String clientId;
|
||||
final String logLevel;
|
||||
|
||||
const SyncConfigModel({
|
||||
required this.baseUrl,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.localRoot,
|
||||
required this.dataDir,
|
||||
required this.clientId,
|
||||
this.remoteRoot = 'cloudreve://my',
|
||||
this.syncMode = 'full',
|
||||
this.conflictStrategy = 'keep_both',
|
||||
this.wcfDeleteMode = 'wcf_delete_local_only',
|
||||
this.maxConcurrentTransfers = 3,
|
||||
this.bandwidthLimitKbps = 0,
|
||||
this.excludedPaths = const [],
|
||||
this.maxWorkers = 0,
|
||||
this.logLevel = 'info',
|
||||
});
|
||||
|
||||
SyncConfigModel copyWith({
|
||||
String? baseUrl,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
String? localRoot,
|
||||
String? remoteRoot,
|
||||
String? syncMode,
|
||||
String? conflictStrategy,
|
||||
String? wcfDeleteMode,
|
||||
int? maxConcurrentTransfers,
|
||||
int? bandwidthLimitKbps,
|
||||
List<String>? excludedPaths,
|
||||
int? maxWorkers,
|
||||
String? dataDir,
|
||||
String? clientId,
|
||||
String? logLevel,
|
||||
}) {
|
||||
return SyncConfigModel(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
localRoot: localRoot ?? this.localRoot,
|
||||
remoteRoot: remoteRoot ?? this.remoteRoot,
|
||||
syncMode: syncMode ?? this.syncMode,
|
||||
conflictStrategy: conflictStrategy ?? this.conflictStrategy,
|
||||
wcfDeleteMode: wcfDeleteMode ?? this.wcfDeleteMode,
|
||||
maxConcurrentTransfers:
|
||||
maxConcurrentTransfers ?? this.maxConcurrentTransfers,
|
||||
bandwidthLimitKbps: bandwidthLimitKbps ?? this.bandwidthLimitKbps,
|
||||
excludedPaths: excludedPaths ?? this.excludedPaths,
|
||||
maxWorkers: maxWorkers ?? this.maxWorkers,
|
||||
dataDir: dataDir ?? this.dataDir,
|
||||
clientId: clientId ?? this.clientId,
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
);
|
||||
}
|
||||
|
||||
ffi.SyncConfigFfi toFfi() {
|
||||
return ffi.SyncConfigFfi(
|
||||
baseUrl: baseUrl,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
localRoot: localRoot,
|
||||
remoteRoot: remoteRoot,
|
||||
syncMode: syncMode,
|
||||
conflictStrategy: conflictStrategy,
|
||||
wcfDeleteMode: wcfDeleteMode,
|
||||
maxConcurrentTransfers: maxConcurrentTransfers,
|
||||
bandwidthLimitKbps: BigInt.from(bandwidthLimitKbps),
|
||||
excludedPaths: excludedPaths,
|
||||
maxWorkers: maxWorkers,
|
||||
dataDir: dataDir,
|
||||
clientId: clientId,
|
||||
logLevel: logLevel,
|
||||
);
|
||||
}
|
||||
|
||||
static SyncConfigModel fromFfi(ffi.SyncConfigFfi ffi) {
|
||||
return SyncConfigModel(
|
||||
baseUrl: ffi.baseUrl,
|
||||
accessToken: ffi.accessToken,
|
||||
refreshToken: ffi.refreshToken,
|
||||
localRoot: ffi.localRoot,
|
||||
remoteRoot: ffi.remoteRoot,
|
||||
syncMode: ffi.syncMode,
|
||||
conflictStrategy: ffi.conflictStrategy,
|
||||
wcfDeleteMode: ffi.wcfDeleteMode,
|
||||
maxConcurrentTransfers: ffi.maxConcurrentTransfers,
|
||||
bandwidthLimitKbps: ffi.bandwidthLimitKbps.toInt(),
|
||||
excludedPaths: ffi.excludedPaths,
|
||||
maxWorkers: ffi.maxWorkers,
|
||||
dataDir: ffi.dataDir,
|
||||
clientId: ffi.clientId,
|
||||
logLevel: ffi.logLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import '../../src/rust/api/ffi_types.dart' as ffi;
|
||||
|
||||
sealed class SyncEventModel {}
|
||||
|
||||
class SyncStateChanged extends SyncEventModel {
|
||||
final String newState;
|
||||
SyncStateChanged(this.newState);
|
||||
}
|
||||
|
||||
class SyncProgress extends SyncEventModel {
|
||||
final int synced;
|
||||
final int total;
|
||||
final String currentFile;
|
||||
SyncProgress(this.synced, this.total, this.currentFile);
|
||||
}
|
||||
|
||||
class SyncFileUploaded extends SyncEventModel {
|
||||
final String localPath;
|
||||
final String remoteUri;
|
||||
SyncFileUploaded(this.localPath, this.remoteUri);
|
||||
}
|
||||
|
||||
class SyncFileDownloaded extends SyncEventModel {
|
||||
final String localPath;
|
||||
final String remoteUri;
|
||||
SyncFileDownloaded(this.localPath, this.remoteUri);
|
||||
}
|
||||
|
||||
class SyncConflictDetected extends SyncEventModel {
|
||||
final String localPath;
|
||||
final String conflictType;
|
||||
SyncConflictDetected(this.localPath, this.conflictType);
|
||||
}
|
||||
|
||||
class SyncError extends SyncEventModel {
|
||||
final String message;
|
||||
final bool recoverable;
|
||||
SyncError(this.message, this.recoverable);
|
||||
}
|
||||
|
||||
class SyncTokenExpired extends SyncEventModel {}
|
||||
|
||||
class SyncDiskSpaceWarning extends SyncEventModel {
|
||||
final int availableMb;
|
||||
SyncDiskSpaceWarning(this.availableMb);
|
||||
}
|
||||
|
||||
class SyncInitialSyncComplete extends SyncEventModel {
|
||||
final SyncSummaryModel summary;
|
||||
SyncInitialSyncComplete(this.summary);
|
||||
}
|
||||
|
||||
class SyncSummaryModel {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int conflicts;
|
||||
final int failed;
|
||||
final int skipped;
|
||||
final int deletedLocal;
|
||||
final int deletedRemote;
|
||||
final int durationMs;
|
||||
|
||||
const SyncSummaryModel({
|
||||
this.uploaded = 0,
|
||||
this.downloaded = 0,
|
||||
this.renamed = 0,
|
||||
this.moved = 0,
|
||||
this.conflicts = 0,
|
||||
this.failed = 0,
|
||||
this.skipped = 0,
|
||||
this.deletedLocal = 0,
|
||||
this.deletedRemote = 0,
|
||||
this.durationMs = 0,
|
||||
});
|
||||
|
||||
static SyncSummaryModel fromFfi(ffi.SyncSummaryFfi f) {
|
||||
return SyncSummaryModel(
|
||||
uploaded: f.uploaded,
|
||||
downloaded: f.downloaded,
|
||||
renamed: f.renamed,
|
||||
moved: f.moved,
|
||||
conflicts: f.conflicts,
|
||||
failed: f.failed,
|
||||
skipped: f.skipped,
|
||||
deletedLocal: f.deletedLocal,
|
||||
deletedRemote: f.deletedRemote,
|
||||
durationMs: f.durationMs.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import '../../src/rust/api/ffi_types.dart' as ffi;
|
||||
|
||||
class SyncStatusModel {
|
||||
final String state;
|
||||
final int syncedFiles;
|
||||
final int totalFiles;
|
||||
final int uploadingCount;
|
||||
final int downloadingCount;
|
||||
final int conflictCount;
|
||||
final int errorCount;
|
||||
final String? lastSyncTime;
|
||||
final String? errorMessage;
|
||||
|
||||
const SyncStatusModel({
|
||||
this.state = 'idle',
|
||||
this.syncedFiles = 0,
|
||||
this.totalFiles = 0,
|
||||
this.uploadingCount = 0,
|
||||
this.downloadingCount = 0,
|
||||
this.conflictCount = 0,
|
||||
this.errorCount = 0,
|
||||
this.lastSyncTime,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
bool get isIdle => state == 'idle';
|
||||
bool get isInitializing => state == 'initializing';
|
||||
bool get isInitialSync => state == 'initialSync';
|
||||
bool get isContinuous => state == 'continuous';
|
||||
bool get isPaused => state == 'paused';
|
||||
bool get isError => state == 'error';
|
||||
bool get isStopped => state == 'stopped';
|
||||
bool get isActive => isInitializing || isInitialSync || isContinuous;
|
||||
|
||||
static SyncStatusModel fromFfi(ffi.SyncStatusFfi f) {
|
||||
return SyncStatusModel(
|
||||
state: f.state,
|
||||
syncedFiles: f.syncedFiles.toInt(),
|
||||
totalFiles: f.totalFiles.toInt(),
|
||||
uploadingCount: f.uploadingCount,
|
||||
downloadingCount: f.downloadingCount,
|
||||
conflictCount: f.conflictCount,
|
||||
errorCount: f.errorCount,
|
||||
lastSyncTime: f.lastSyncTime,
|
||||
errorMessage: f.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
class SyncTaskModel {
|
||||
final String id;
|
||||
final String trigger;
|
||||
final int totalCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final String status;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final String? finishedAt;
|
||||
|
||||
const SyncTaskModel({
|
||||
required this.id,
|
||||
required this.trigger,
|
||||
required this.totalCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.finishedAt,
|
||||
});
|
||||
|
||||
String get shortId => id.length > 8 ? id.substring(0, 8) : id;
|
||||
|
||||
String get triggerLabel => switch (trigger) {
|
||||
'initial_sync' => '初始同步',
|
||||
'continuous' => '持续同步',
|
||||
'manual' => '手动同步',
|
||||
_ => trigger,
|
||||
};
|
||||
|
||||
String get statusLabel => switch (status) {
|
||||
'pending' => '等待中',
|
||||
'running' => '执行中',
|
||||
'completed' => '已完成',
|
||||
'failed' => '失败',
|
||||
'cancelled' => '已取消',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
double get progress =>
|
||||
totalCount > 0 ? completedCount / totalCount : 0.0;
|
||||
}
|
||||
|
||||
class SyncTaskItemModel {
|
||||
final int id;
|
||||
final String taskId;
|
||||
final String relativePath;
|
||||
final String actionType;
|
||||
final String status;
|
||||
final int fileSize;
|
||||
final String? errorMessage;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncTaskItemModel({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.relativePath,
|
||||
required this.actionType,
|
||||
required this.status,
|
||||
required this.fileSize,
|
||||
this.errorMessage,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String get actionLabel => switch (actionType) {
|
||||
'upload' => '上传',
|
||||
'download' => '下载',
|
||||
'delete_local' => '删本地',
|
||||
'delete_remote' => '删远程',
|
||||
'rename' => '重命名',
|
||||
'move' => '移动',
|
||||
'mkdir_remote' => '创建远程目录',
|
||||
'mkdir_local' => '创建本地目录',
|
||||
'conflict_resolve' => '冲突解决',
|
||||
'create_placeholder' => '创建占位符',
|
||||
_ => actionType,
|
||||
};
|
||||
|
||||
String get statusLabel => switch (status) {
|
||||
'pending' => '未开始',
|
||||
'running' => '进行中',
|
||||
'completed' => '已完成',
|
||||
'failed' => '失败',
|
||||
'skipped' => '跳过',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
String get filename {
|
||||
final parts = relativePath.split('/');
|
||||
return parts.isNotEmpty ? parts.last : relativePath;
|
||||
}
|
||||
}
|
||||
+36
-5
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
|
||||
import 'package:logger/logger.dart' show Level;
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:oktoast/oktoast.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'config/app_config.dart';
|
||||
import 'core/constants/storage_keys.dart';
|
||||
import 'presentation/providers/auth_provider.dart';
|
||||
import 'presentation/providers/file_manager_provider.dart';
|
||||
import 'presentation/providers/navigation_provider.dart';
|
||||
@@ -19,9 +21,11 @@ import 'presentation/providers/download_manager_provider.dart';
|
||||
import 'presentation/providers/user_setting_provider.dart';
|
||||
import 'presentation/providers/admin_provider.dart';
|
||||
import 'presentation/providers/quick_access_provider.dart';
|
||||
import 'presentation/providers/sync_provider.dart';
|
||||
import 'presentation/providers/theme_provider.dart';
|
||||
import 'services/upload_service.dart';
|
||||
import 'services/api_service.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/server_service.dart';
|
||||
import 'services/cache_manager_service.dart';
|
||||
import 'services/avatar_cache_service.dart';
|
||||
@@ -29,12 +33,42 @@ import 'core/utils/video_fullscreen.dart';
|
||||
import 'services/desktop_service.dart';
|
||||
import 'router/app_router.dart';
|
||||
import 'presentation/widgets/toast_helper.dart';
|
||||
import 'presentation/widgets/update_prompt.dart';
|
||||
import 'src/rust/frb_generated.dart' show RustSyncApi;
|
||||
|
||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
|
||||
Level _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'error' => Level.error,
|
||||
'warning' => Level.warning,
|
||||
'info' => Level.info,
|
||||
'debug' => Level.debug,
|
||||
'trace' => Level.trace,
|
||||
_ => Level.info,
|
||||
};
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await AppLogger.init();
|
||||
final savedLevel = await StorageService.instance.getString(
|
||||
StorageKeys.logLevel,
|
||||
);
|
||||
if (savedLevel != null) {
|
||||
final level = _parseLogLevel(savedLevel);
|
||||
AppLogger.setLevel(level);
|
||||
}
|
||||
AppLogger.i("应用启动,日志系统就绪");
|
||||
|
||||
try {
|
||||
await RustSyncApi.init();
|
||||
AppLogger.i("RustSyncApi 初始化成功");
|
||||
} catch (e) {
|
||||
AppLogger.e("RustSyncApi 初始化失败: $e");
|
||||
}
|
||||
|
||||
// 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常
|
||||
// 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略
|
||||
FlutterError.onError = (details) {
|
||||
@@ -51,10 +85,6 @@ void main() async {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 初始化日志
|
||||
await AppLogger.init();
|
||||
AppLogger.i("应用启动,日志系统就绪");
|
||||
|
||||
// 桌面端初始化窗口管理和系统托盘
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
// 实例化 FlutterSingleInstance 获取单实例句柄
|
||||
@@ -156,6 +186,7 @@ class CloudreveApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (_) => UserSettingProvider()),
|
||||
ChangeNotifierProvider(create: (_) => AdminProvider()),
|
||||
ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()),
|
||||
ChangeNotifierProvider(create: (_) => SyncProvider()),
|
||||
],
|
||||
child: const AppView(),
|
||||
);
|
||||
@@ -215,7 +246,7 @@ class AppView extends StatelessWidget {
|
||||
currentWidget = FilterQualityWidget(child: currentWidget);
|
||||
}
|
||||
// 添加全局错误处理
|
||||
return ErrorHandler(child: currentWidget);
|
||||
return UpdatePrompt(child: ErrorHandler(child: currentWidget));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -179,14 +179,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (fileManager.currentPath == '/') {
|
||||
return const Text('文件');
|
||||
}
|
||||
final segments = FileUtils.toRelativePath(
|
||||
fileManager.currentPath,
|
||||
).split('/').where((s) => s.isNotEmpty).toList();
|
||||
return Text(
|
||||
segments.isNotEmpty
|
||||
? FileUtils.decodePathSegment(segments.last)
|
||||
: '文件',
|
||||
);
|
||||
return _buildDesktopBreadcrumb(context, fileManager);
|
||||
}
|
||||
return _buildMobileBreadcrumb(context, fileManager);
|
||||
},
|
||||
@@ -195,15 +188,76 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String _decodePathSegment(String segment) {
|
||||
return FileUtils.safeDecodePathSegment(segment);
|
||||
}
|
||||
|
||||
Widget _buildDesktopBreadcrumb(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final pathParts = fileManager.currentPath.split('/');
|
||||
pathParts.removeWhere((part) => part.isEmpty);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => fileManager.currentPath != '/'
|
||||
? fileManager.enterFolder('/')
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Icon(LucideIcons.home, size: 18, color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
for (int i = 0; i < pathParts.length; i++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: theme.hintColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
||||
if (targetPath != fileManager.currentPath) {
|
||||
fileManager.enterFolder(targetPath);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Text(
|
||||
_decodePathSegment(pathParts[i]),
|
||||
style: TextStyle(
|
||||
color: i == pathParts.length - 1
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.primary,
|
||||
fontWeight: i == pathParts.length - 1
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileBreadcrumb(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final pathParts = FileUtils.toRelativePath(
|
||||
fileManager.currentPath,
|
||||
).split('/');
|
||||
final pathParts = fileManager.currentPath.split('/');
|
||||
pathParts.removeWhere((part) => part.isEmpty);
|
||||
|
||||
return SizedBox(
|
||||
@@ -231,7 +285,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
_buildBreadcrumbChip(
|
||||
context,
|
||||
label: FileUtils.decodePathSegment(pathParts[i]),
|
||||
label: _decodePathSegment(pathParts[i]),
|
||||
icon: null,
|
||||
color: colorScheme.primary,
|
||||
isLast: i == pathParts.length - 1,
|
||||
|
||||
@@ -71,6 +71,7 @@ class QuickAccessGrid extends StatelessWidget {
|
||||
child: _QuickAccessButton(
|
||||
item: items[index],
|
||||
onTap: () => _onTap(context, items[index]),
|
||||
fillHeight: fillHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -150,8 +151,13 @@ class QuickAccessGrid extends StatelessWidget {
|
||||
class _QuickAccessButton extends StatelessWidget {
|
||||
final QuickAccessConfig item;
|
||||
final VoidCallback onTap;
|
||||
final bool fillHeight;
|
||||
|
||||
const _QuickAccessButton({required this.item, required this.onTap});
|
||||
const _QuickAccessButton({
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
this.fillHeight = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -179,24 +185,27 @@ class _QuickAccessButton extends StatelessWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(item.icon, color: foreground, size: 22),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
child: SizedBox(
|
||||
height: fillHeight ? double.infinity : null,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(item.icon, color: foreground, size: 22),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,28 +1,67 @@
|
||||
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
|
||||
import 'package:cloudreve4_flutter/router/app_router.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class _QuickFunction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String route;
|
||||
final String? route;
|
||||
final void Function(BuildContext context)? onTap;
|
||||
|
||||
const _QuickFunction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.route,
|
||||
this.route,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class QuickFunctionsSection extends StatelessWidget {
|
||||
const QuickFunctionsSection({super.key});
|
||||
|
||||
static const _functions = [
|
||||
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
|
||||
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
|
||||
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
|
||||
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
|
||||
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
|
||||
static final _functions = [
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.share2,
|
||||
label: '我的分享',
|
||||
route: RouteNames.share,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'WebDAV',
|
||||
route: RouteNames.webdav,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.download,
|
||||
label: '离线下载',
|
||||
route: RouteNames.remoteDownload,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.trash2,
|
||||
label: '回收站',
|
||||
route: RouteNames.recycleBin,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.refreshCw,
|
||||
label: '文件同步',
|
||||
onTap: (ctx) {
|
||||
final isMobilePlatform =
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
if (isMobilePlatform) {
|
||||
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
|
||||
return;
|
||||
}
|
||||
ctx.read<NavigationProvider>().setIndex(3);
|
||||
},
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.settings,
|
||||
label: '设置',
|
||||
route: RouteNames.settings,
|
||||
),
|
||||
];
|
||||
|
||||
static const double _spacing = 12;
|
||||
@@ -43,9 +82,12 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
children: [
|
||||
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('快捷功能',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'快捷功能',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -59,7 +101,8 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
if (itemWidth < _minItemWidth) break;
|
||||
perRow = next;
|
||||
}
|
||||
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
final itemWidth =
|
||||
(availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
@@ -70,7 +113,13 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
child: _QuickFunctionCard(
|
||||
icon: fn.icon,
|
||||
label: fn.label,
|
||||
onTap: () => Navigator.of(context).pushNamed(fn.route),
|
||||
onTap: () {
|
||||
if (fn.onTap != null) {
|
||||
fn.onTap!(context);
|
||||
} else if (fn.route != null) {
|
||||
Navigator.of(context).pushNamed(fn.route!);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -106,9 +155,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
color: _hovered
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: null,
|
||||
color: _hovered ? colorScheme.surfaceContainerHighest : null,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:logger/logger.dart' show Level;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/constants/storage_keys.dart';
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
@@ -38,6 +39,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
String _logFilePath = '';
|
||||
int? _logFileSize;
|
||||
String _cacheDirPath = '';
|
||||
Level _logLevel = Level.info;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -46,6 +48,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
_loadWifiOnlySetting();
|
||||
_loadGravatarMirrorSetting();
|
||||
_loadLogInfo();
|
||||
_loadLogLevel();
|
||||
}
|
||||
|
||||
Future<void> _loadCacheSettings() async {
|
||||
@@ -127,6 +130,15 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLogLevel() async {
|
||||
final saved = await StorageService.instance.getString(StorageKeys.logLevel);
|
||||
if (saved != null && mounted) {
|
||||
setState(() {
|
||||
_logLevel = _parseLogLevel(saved);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -295,6 +307,13 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
_buildSection(
|
||||
title: '日志管理',
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('日志级别'),
|
||||
subtitle: Text(_logLevelLabel(_logLevel)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickLogLevel(),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('日志文件路径'),
|
||||
subtitle: Text(
|
||||
@@ -883,4 +902,70 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
if (mounted) ToastHelper.success('日志已清空');
|
||||
}
|
||||
}
|
||||
|
||||
String _logLevelLabel(Level level) {
|
||||
return switch (level) {
|
||||
Level.error => 'Error — 仅错误',
|
||||
Level.warning => 'Warning — 错误 + 警告',
|
||||
Level.info => 'Info — 常规信息',
|
||||
Level.debug => 'Debug — 调试信息(含FFI交互)',
|
||||
Level.trace => 'Trace — 全量追踪',
|
||||
_ => level.name,
|
||||
};
|
||||
}
|
||||
|
||||
Level _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'error' => Level.error,
|
||||
'warning' => Level.warning,
|
||||
'info' => Level.info,
|
||||
'debug' => Level.debug,
|
||||
'trace' => Level.trace,
|
||||
_ => Level.info,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _pickLogLevel() async {
|
||||
final levels = [
|
||||
(Level.error, 'Error — 仅错误'),
|
||||
(Level.warning, 'Warning — 错误 + 警告'),
|
||||
(Level.info, 'Info — 常规信息'),
|
||||
(Level.debug, 'Debug — 调试信息(含FFI交互)'),
|
||||
(Level.trace, 'Trace — 全量追踪'),
|
||||
];
|
||||
|
||||
final result = await showDialog<Level>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: const Text('日志级别'),
|
||||
children: levels.map((e) {
|
||||
final isSelected = _logLevel == e.$1;
|
||||
return SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, e.$1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 20,
|
||||
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(e.$2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result != _logLevel) {
|
||||
setState(() => _logLevel = result);
|
||||
AppLogger.setLevel(result);
|
||||
await StorageService.instance.setString(
|
||||
StorageKeys.logLevel,
|
||||
result.name,
|
||||
);
|
||||
if (mounted) ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'file_preferences_page.dart';
|
||||
import 'app_settings_page.dart';
|
||||
import 'credit_history_page.dart';
|
||||
import 'quick_access_settings_page.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
|
||||
/// 设置主页
|
||||
class SettingsPage extends StatefulWidget {
|
||||
@@ -105,6 +106,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_buildSection(
|
||||
title: '偏好',
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.sync_outlined,
|
||||
title: '文件同步',
|
||||
subtitle: '本地与云端文件自动同步',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.apps_outlined,
|
||||
title: '快捷入口',
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
import '../files/files_page.dart';
|
||||
import '../overview/overview_page.dart';
|
||||
import '../sync/sync_page.dart';
|
||||
import '../tasks/tasks_page.dart';
|
||||
import '../profile/profile_page.dart';
|
||||
|
||||
class _ShellPageSlot extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const _ShellPageSlot({required this.child});
|
||||
|
||||
@override
|
||||
State<_ShellPageSlot> createState() => _ShellPageSlotState();
|
||||
}
|
||||
|
||||
class _ShellPageSlotState extends State<_ShellPageSlot>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
||||
@@ -23,7 +46,52 @@ class AppShell extends StatefulWidget {
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
class _AppShellState extends State<AppShell>
|
||||
with GestureHandlerMixin, TickerProviderStateMixin {
|
||||
final Set<int> _visitedPageIndexes = <int>{0};
|
||||
late AnimationController _syncSpinController;
|
||||
|
||||
bool get _showSyncTab =>
|
||||
defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS;
|
||||
|
||||
List<Widget> get _pages => _showSyncTab
|
||||
? [
|
||||
const OverviewPage(),
|
||||
const FilesPage(),
|
||||
const TasksPage(),
|
||||
const SyncPage(),
|
||||
const ProfilePage(),
|
||||
]
|
||||
: [
|
||||
const OverviewPage(),
|
||||
const FilesPage(),
|
||||
const TasksPage(),
|
||||
const ProfilePage(),
|
||||
];
|
||||
|
||||
int _clampedIndex(int index) {
|
||||
final maxIndex = _pages.length - 1;
|
||||
if (index < 0) return 0;
|
||||
if (index > maxIndex) return maxIndex;
|
||||
return index;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncSpinController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_syncSpinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
@@ -33,12 +101,19 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (!didPop) {
|
||||
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final navProvider = Provider.of<NavigationProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') {
|
||||
await fileManager.goBack();
|
||||
} else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) {
|
||||
} else if (navProvider.currentIndex != 0 &&
|
||||
navProvider.currentIndex != 1) {
|
||||
navProvider.setIndex(0);
|
||||
} else {
|
||||
await checkExitApp(context);
|
||||
@@ -57,42 +132,74 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
}
|
||||
|
||||
Widget _buildPageContent(BuildContext context, int currentIndex) {
|
||||
const pages = [
|
||||
OverviewPage(),
|
||||
FilesPage(),
|
||||
TasksPage(),
|
||||
ProfilePage(),
|
||||
];
|
||||
final pages = _pages;
|
||||
final visibleIndex = _clampedIndex(currentIndex);
|
||||
_visitedPageIndexes.add(visibleIndex);
|
||||
|
||||
return PageTransitionSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation, secondaryAnimation) {
|
||||
return SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(currentIndex),
|
||||
child: pages[currentIndex],
|
||||
return RepaintBoundary(
|
||||
child: IndexedStack(
|
||||
index: visibleIndex,
|
||||
children: List.generate(pages.length, (index) {
|
||||
if (!_visitedPageIndexes.contains(index)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _ShellPageSlot(child: pages[index]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(BuildContext context, NavigationProvider navProvider) {
|
||||
Widget _buildSyncIcon({required bool isSelected, required double size}) {
|
||||
return Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final hasWorkers = sync.activeWorkerCount > 0;
|
||||
|
||||
if (hasWorkers && !_syncSpinController.isAnimating) {
|
||||
_syncSpinController.repeat();
|
||||
} else if (!hasWorkers && _syncSpinController.isAnimating) {
|
||||
_syncSpinController.stop();
|
||||
_syncSpinController.value = 0;
|
||||
}
|
||||
|
||||
final icon = Icon(
|
||||
LucideIcons.refreshCw,
|
||||
size: size,
|
||||
weight: isSelected ? 700 : 400,
|
||||
);
|
||||
|
||||
if (!hasWorkers) return icon;
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: _syncSpinController,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: _syncSpinController.value * 2 * 3.14159265,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: icon,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(
|
||||
BuildContext context,
|
||||
NavigationProvider navProvider,
|
||||
) {
|
||||
return Scaffold(
|
||||
body: _buildPageContent(context, navProvider.currentIndex),
|
||||
bottomNavigationBar: GlassmorphismContainer(
|
||||
borderRadius: 0,
|
||||
child: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
|
||||
builder: (context, uploadManager, downloadManager, _) {
|
||||
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
|
||||
final activeCount =
|
||||
uploadManager.activeTasks.length +
|
||||
downloadManager.downloadingCount;
|
||||
|
||||
return NavigationBar(
|
||||
height: 64,
|
||||
selectedIndex: navProvider.currentIndex,
|
||||
selectedIndex: _clampedIndex(navProvider.currentIndex),
|
||||
onDestinationSelected: (i) => navProvider.setIndex(i),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
@@ -118,6 +225,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
),
|
||||
label: '任务',
|
||||
),
|
||||
if (_showSyncTab)
|
||||
NavigationDestination(
|
||||
icon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: false, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
selectedIcon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: true, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
label: '同步',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(LucideIcons.user),
|
||||
selectedIcon: Icon(LucideIcons.user, weight: 700),
|
||||
@@ -131,7 +262,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopLayout(BuildContext context, NavigationProvider navProvider) {
|
||||
Widget _buildDesktopLayout(
|
||||
BuildContext context,
|
||||
NavigationProvider navProvider,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
final user = authProvider.user;
|
||||
@@ -141,16 +275,16 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: navProvider.currentIndex,
|
||||
selectedIndex: _clampedIndex(navProvider.currentIndex),
|
||||
onDestinationSelected: (i) => navProvider.setIndex(i),
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => navProvider.setIndex(3),
|
||||
onTap: () => navProvider.setIndex(_pages.length - 1),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: navProvider.currentIndex == 3
|
||||
border: navProvider.currentIndex == _pages.length - 1
|
||||
? Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2.5,
|
||||
@@ -180,7 +314,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
NavigationRailDestination(
|
||||
icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
|
||||
builder: (context, uploadManager, downloadManager, _) {
|
||||
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
|
||||
final activeCount =
|
||||
uploadManager.activeTasks.length +
|
||||
downloadManager.downloadingCount;
|
||||
return Badge(
|
||||
isLabelVisible: activeCount > 0,
|
||||
label: Text('$activeCount'),
|
||||
@@ -191,6 +327,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
|
||||
label: const Text('任务'),
|
||||
),
|
||||
if (_showSyncTab)
|
||||
NavigationRailDestination(
|
||||
icon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: false, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
selectedIcon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: true, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
label: const Text('同步'),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(LucideIcons.user),
|
||||
selectedIcon: Icon(LucideIcons.user, weight: 700),
|
||||
@@ -206,32 +366,38 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
context,
|
||||
icon: LucideIcons.share2,
|
||||
label: '我的分享',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.share),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.share),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'WebDAV',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.webdav),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.download,
|
||||
label: '离线下载',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.remoteDownload),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.remoteDownload),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.trash2,
|
||||
label: '回收站',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.recycleBin),
|
||||
),
|
||||
const Divider(indent: 12, endIndent: 12),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.settings,
|
||||
label: '设置',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.settings),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.settings),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
@@ -245,9 +411,7 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
),
|
||||
),
|
||||
const VerticalDivider(thickness: 1, width: 1),
|
||||
Expanded(
|
||||
child: _buildPageContent(context, navProvider.currentIndex),
|
||||
),
|
||||
Expanded(child: _buildPageContent(context, navProvider.currentIndex)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -278,7 +442,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
|
||||
Future<void> _handleLogout(BuildContext context) async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -302,7 +469,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
await authProvider.logout();
|
||||
fileManager.clearFiles();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../services/api_service.dart';
|
||||
import '../../../services/server_service.dart';
|
||||
import '../../../services/storage_service.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/sync_provider.dart';
|
||||
|
||||
/// 启动页
|
||||
class SplashPage extends StatefulWidget {
|
||||
@@ -45,6 +46,16 @@ class _SplashPageState extends State<SplashPage> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (authProvider.isAuthenticated) {
|
||||
// 自动恢复同步(如果之前处于同步状态)
|
||||
if (!mounted) return;
|
||||
final syncProvider = Provider.of<SyncProvider>(context, listen: false);
|
||||
final token = authProvider.token;
|
||||
await syncProvider.autoResumeIfNeeded(
|
||||
currentAccessToken: token?.accessToken,
|
||||
currentRefreshToken: token?.refreshToken,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed(RouteNames.home);
|
||||
} else {
|
||||
Navigator.of(context).pushReplacementNamed(RouteNames.login);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
|
||||
/// 同步引擎日志预览页面
|
||||
class SyncLogViewerPage extends StatefulWidget {
|
||||
const SyncLogViewerPage({super.key});
|
||||
|
||||
@override
|
||||
State<SyncLogViewerPage> createState() => _SyncLogViewerPageState();
|
||||
}
|
||||
|
||||
class _SyncLogViewerPageState extends State<SyncLogViewerPage> {
|
||||
String _logContent = '';
|
||||
bool _isLoading = true;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLog();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<String> _getSyncLogPath() async {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt';
|
||||
}
|
||||
|
||||
Future<void> _loadLog() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final path = await _getSyncLogPath();
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logContent = '';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final lines = await file.readAsLines();
|
||||
const maxLines = 1000;
|
||||
String content;
|
||||
if (lines.length <= maxLines) {
|
||||
content = lines.join('\n');
|
||||
} else {
|
||||
content = '... (仅显示最近 $maxLines 行)\n\n'
|
||||
'${lines.sublist(lines.length - maxLines).join('\n')}';
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logContent = content;
|
||||
_isLoading = false;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ToastHelper.error('读取同步日志失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('同步日志'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadLog,
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _logContent.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.description_outlined,
|
||||
size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
|
||||
const SizedBox(height: 16),
|
||||
Text('暂无同步日志', style: TextStyle(color: theme.hintColor)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
_logContent,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceCodePro',
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: isDark ? Colors.grey[300] : Colors.grey[900],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models/sync_task_model.dart';
|
||||
import '../../providers/sync_provider.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
import 'sync_settings_page.dart';
|
||||
|
||||
/// 同步 Tab 页 - 展示实时同步状态、活跃任务和已完成任务
|
||||
class SyncPage extends StatefulWidget {
|
||||
const SyncPage({super.key});
|
||||
|
||||
@override
|
||||
State<SyncPage> createState() => _SyncPageState();
|
||||
}
|
||||
|
||||
class _SyncPageState extends State<SyncPage> {
|
||||
// 展开的任务 ID
|
||||
final Set<String> _expandedTasks = {};
|
||||
// 正在加载详情的任务 ID
|
||||
final Set<String> _loadingDetails = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SyncProvider>().loadRecentTasks();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sync = context.watch<SyncProvider>();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('文件同步'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => _navigateToSettings(),
|
||||
tooltip: '同步设置',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
final sync = context.read<SyncProvider>();
|
||||
sync.invalidateAllTaskDetails();
|
||||
await sync.loadRecentTasks();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
_buildStatusCard(sync, theme),
|
||||
const SizedBox(height: 8),
|
||||
_buildActiveTasksSection(sync, theme),
|
||||
const SizedBox(height: 8),
|
||||
_buildCompletedTasksSection(sync, theme),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSettings() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SyncSettingsPage()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard(SyncProvider sync, ThemeData theme) {
|
||||
final isActive = sync.isActive;
|
||||
final isPaused = sync.isPaused;
|
||||
final hasError = sync.hasError;
|
||||
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
String statusText;
|
||||
|
||||
if (isActive) {
|
||||
statusColor = theme.colorScheme.primary;
|
||||
statusIcon = Icons.sync;
|
||||
statusText = _syncModeLabel(sync);
|
||||
} else if (isPaused) {
|
||||
statusColor = Colors.orange;
|
||||
statusIcon = Icons.pause_circle_outline;
|
||||
statusText = '已暂停';
|
||||
} else if (hasError) {
|
||||
statusColor = theme.colorScheme.error;
|
||||
statusIcon = Icons.error_outline;
|
||||
statusText = '同步错误';
|
||||
} else if (sync.state == SyncState.stopped) {
|
||||
statusColor = theme.disabledColor;
|
||||
statusIcon = Icons.stop_circle_outlined;
|
||||
statusText = '已停止';
|
||||
} else {
|
||||
statusColor = theme.disabledColor;
|
||||
statusIcon = Icons.cloud_off;
|
||||
statusText = '未启动';
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(statusIcon, color: statusColor, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
statusText,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (sync.errorMessage != null)
|
||||
Text(
|
||||
sync.errorMessage!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (sync.activeWorkerCount > 0)
|
||||
Badge(
|
||||
label: Text('${sync.activeWorkerCount}'),
|
||||
child: Icon(LucideIcons.loader, color: statusColor, size: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isActive || isPaused) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: sync.totalFiles > 0 ? sync.progress : null,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
sync.totalFiles > 0
|
||||
? '${sync.syncedFiles} / ${sync.totalFiles} 文件'
|
||||
: sync.state == SyncState.continuous
|
||||
? '持续同步中'
|
||||
: '正在同步...',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (sync.lastSummary != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_summaryChip(theme, '上传', sync.lastSummary!.uploaded),
|
||||
_summaryChip(theme, '下载', sync.lastSummary!.downloaded),
|
||||
_summaryChip(theme, '冲突', sync.lastSummary!.conflicts),
|
||||
_summaryChip(theme, '失败', sync.lastSummary!.failed),
|
||||
_summaryChip(theme, '重命名', sync.lastSummary!.renamed),
|
||||
_summaryChip(theme, '移动', sync.lastSummary!.moved),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (!sync.isActive && !sync.isPaused)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _navigateToSettings(),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('开始同步'),
|
||||
),
|
||||
),
|
||||
if (sync.isActive)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => sync.pause(),
|
||||
icon: const Icon(Icons.pause, size: 18),
|
||||
label: const Text('暂停'),
|
||||
),
|
||||
),
|
||||
if (sync.isPaused)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => sync.resume(),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('恢复'),
|
||||
),
|
||||
),
|
||||
if (sync.isActive || sync.isPaused) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _stopSync(sync),
|
||||
icon: const Icon(Icons.stop, size: 18),
|
||||
label: const Text('停止'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryChip(ThemeData theme, String label, int value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$label:', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
|
||||
Text(' $value', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _syncModeLabel(SyncProvider sync) {
|
||||
final mode = sync.persistedConfig?.syncMode ?? 'full';
|
||||
return switch (mode) {
|
||||
'full' => '全量同步中',
|
||||
'upload_only' => '仅上传中',
|
||||
'download_only' => '仅下载中',
|
||||
'album' => '相册同步中',
|
||||
'mirror_wcf' => '镜像同步中',
|
||||
_ => '同步中',
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildActiveTasksSection(SyncProvider sync, ThemeData theme) {
|
||||
final tasks = sync.activeTasks;
|
||||
|
||||
return _buildTaskSection(
|
||||
theme: theme,
|
||||
title: '正在同步',
|
||||
icon: LucideIcons.loader,
|
||||
tasks: tasks,
|
||||
emptyText: '暂无正在进行的同步任务',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) {
|
||||
final tasks = sync.recentTasks
|
||||
.where((t) =>
|
||||
(t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') &&
|
||||
t.totalCount > 0)
|
||||
.toList();
|
||||
|
||||
return _buildTaskSection(
|
||||
theme: theme,
|
||||
title: '已完成',
|
||||
icon: LucideIcons.checkCircle2,
|
||||
tasks: tasks,
|
||||
emptyText: '暂无已完成的同步任务',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskSection({
|
||||
required ThemeData theme,
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<SyncTaskModel> tasks,
|
||||
required String emptyText,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (tasks.isEmpty)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(emptyText, style: TextStyle(color: theme.hintColor)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...tasks.map((task) => _buildTaskCard(task, theme)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(SyncTaskModel task, ThemeData theme) {
|
||||
final isExpanded = _expandedTasks.contains(task.id);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _toggleTaskExpand(task.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: task.id));
|
||||
ToastHelper.success('已复制任务 ID');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
task.shortId,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.copy, size: 14, color: theme.hintColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColor(task.status, theme).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
task.statusLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _statusColor(task.status, theme),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${task.completedCount}/${task.totalCount}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (task.failedCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'失败${task.failedCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded)
|
||||
_buildTaskDetailList(task, theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskDetailList(SyncTaskModel task, ThemeData theme) {
|
||||
final sync = context.watch<SyncProvider>();
|
||||
final items = sync.getCachedTaskDetail(task.id);
|
||||
|
||||
if (_loadingDetails.contains(task.id)) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
);
|
||||
}
|
||||
|
||||
if (items == null || items.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final hasMore = sync.hasMoreTaskDetail(task.id);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 70, child: Text('操作', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
|
||||
Expanded(child: Text('文件名', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
|
||||
SizedBox(width: 55, child: Text('状态', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
|
||||
SizedBox(width: 110, child: Text('时间', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
|
||||
],
|
||||
),
|
||||
),
|
||||
...items.map((item) => _buildTaskItemRow(item, theme)),
|
||||
if (hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _loadMoreDetail(task.id),
|
||||
icon: const Icon(Icons.expand_more, size: 16),
|
||||
label: const Text('加载更多'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskItemRow(SyncTaskItemModel item, ThemeData theme) {
|
||||
final timeStr = _formatTime(item.updatedAt);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _actionColor(item.actionType, theme).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
item.actionLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
color: _actionColor(item.actionType, theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.filename,
|
||||
style: theme.textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 55,
|
||||
child: Text(
|
||||
item.statusLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _itemStatusColor(item.status, theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
timeStr,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(String status, ThemeData theme) {
|
||||
return switch (status) {
|
||||
'running' => theme.colorScheme.primary,
|
||||
'completed' => Colors.green,
|
||||
'failed' => theme.colorScheme.error,
|
||||
'cancelled' => theme.hintColor,
|
||||
_ => theme.hintColor,
|
||||
};
|
||||
}
|
||||
|
||||
Color _actionColor(String actionType, ThemeData theme) {
|
||||
return switch (actionType) {
|
||||
'upload' => Colors.blue,
|
||||
'download' => Colors.green,
|
||||
'create_placeholder' => Colors.teal,
|
||||
'delete_local' || 'delete_remote' => theme.colorScheme.error,
|
||||
'rename' || 'move' => Colors.orange,
|
||||
'conflict_resolve' => Colors.purple,
|
||||
_ => theme.colorScheme.primary,
|
||||
};
|
||||
}
|
||||
|
||||
Color _itemStatusColor(String status, ThemeData theme) {
|
||||
return switch (status) {
|
||||
'completed' => Colors.green,
|
||||
'failed' => theme.colorScheme.error,
|
||||
'running' => theme.colorScheme.primary,
|
||||
_ => theme.hintColor,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatTime(String isoTime) {
|
||||
try {
|
||||
final dt = DateTime.parse(isoTime).toLocal();
|
||||
return '${dt.year}/${dt.month.toString().padLeft(2, '0')}/${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
} catch (_) {
|
||||
return isoTime;
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleTaskExpand(String taskId) {
|
||||
final sync = context.read<SyncProvider>();
|
||||
if (_expandedTasks.contains(taskId)) {
|
||||
setState(() => _expandedTasks.remove(taskId));
|
||||
sync.unwatchTaskDetail(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _expandedTasks.add(taskId));
|
||||
sync.watchTaskDetail(taskId);
|
||||
|
||||
if (sync.getCachedTaskDetail(taskId) == null && !_loadingDetails.contains(taskId)) {
|
||||
setState(() => _loadingDetails.add(taskId));
|
||||
sync.getTaskDetail(taskId).whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() => _loadingDetails.remove(taskId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _loadMoreDetail(String taskId) {
|
||||
final sync = context.read<SyncProvider>();
|
||||
sync.loadMoreTaskDetail(taskId);
|
||||
}
|
||||
|
||||
Future<void> _stopSync(SyncProvider sync) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('停止同步'),
|
||||
content: const Text('确定要停止文件同步吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: const Text('停止'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await sync.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,611 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
import '../../../data/models/sync_config_model.dart';
|
||||
import '../../../data/models/sync_event_model.dart';
|
||||
import '../../../data/models/sync_task_model.dart';
|
||||
import '../../../services/storage_service.dart';
|
||||
import '../../../services/sync_service.dart';
|
||||
|
||||
enum SyncState {
|
||||
idle,
|
||||
initializing,
|
||||
initialSync,
|
||||
continuous,
|
||||
paused,
|
||||
error,
|
||||
stopped,
|
||||
}
|
||||
|
||||
class SyncProvider extends ChangeNotifier {
|
||||
|
||||
SyncState _state = SyncState.idle;
|
||||
String? _errorMessage;
|
||||
SyncSummaryModel? _lastSummary;
|
||||
int _syncedFiles = 0;
|
||||
int _totalFiles = 0;
|
||||
int _conflictCount = 0;
|
||||
int _uploadingCount = 0;
|
||||
int _downloadingCount = 0;
|
||||
String? _currentFile;
|
||||
StreamSubscription<SyncEventModel>? _eventSub;
|
||||
Timer? _pollTimer;
|
||||
int _pollIntervalSeconds = 1; // 动态调节:活跃1s,空闲3s
|
||||
|
||||
// 任务列表
|
||||
List<SyncTaskModel> _activeTasks = [];
|
||||
List<SyncTaskModel> _recentTasks = [];
|
||||
int _activeWorkerCount = 0;
|
||||
// 任务详情缓存: taskId -> items
|
||||
final Map<String, List<SyncTaskItemModel>> _taskDetailCache = {};
|
||||
// 任务详情是否有更多: taskId -> hasMore
|
||||
final Map<String, bool> _taskDetailHasMore = {};
|
||||
// 需要实时刷新详情的任务ID(UI 展开中的任务)
|
||||
final Set<String> _watchedTaskIds = {};
|
||||
bool _recentTasksLoaded = false;
|
||||
|
||||
// 持久化的同步配置
|
||||
SyncConfigModel? _persistedConfig;
|
||||
|
||||
SyncState get state => _state;
|
||||
String? get errorMessage => _errorMessage;
|
||||
SyncSummaryModel? get lastSummary => _lastSummary;
|
||||
int get syncedFiles => _syncedFiles;
|
||||
int get totalFiles => _totalFiles;
|
||||
int get conflictCount => _conflictCount;
|
||||
int get uploadingCount => _uploadingCount;
|
||||
int get downloadingCount => _downloadingCount;
|
||||
String? get currentFile => _currentFile;
|
||||
SyncConfigModel? get persistedConfig => _persistedConfig;
|
||||
List<SyncTaskModel> get activeTasks => _activeTasks;
|
||||
List<SyncTaskModel> get recentTasks => _recentTasks;
|
||||
int get activeWorkerCount => _activeWorkerCount;
|
||||
|
||||
bool get isActive =>
|
||||
_state == SyncState.initializing ||
|
||||
_state == SyncState.initialSync ||
|
||||
_state == SyncState.continuous;
|
||||
|
||||
bool get isPaused => _state == SyncState.paused;
|
||||
|
||||
bool get hasError => _state == SyncState.error;
|
||||
|
||||
/// Rust 同步引擎是否已初始化(点过"开始同步"后为 true)
|
||||
bool _engineInitialized = false;
|
||||
bool get engineInitialized => _engineInitialized;
|
||||
|
||||
double get progress =>
|
||||
_totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0;
|
||||
|
||||
/// 从持久化存储恢复同步配置和状态
|
||||
Future<void> restoreFromStorage() async {
|
||||
final configMap = await StorageService.instance.getSyncConfig();
|
||||
if (configMap != null) {
|
||||
try {
|
||||
_persistedConfig = SyncConfigModel(
|
||||
baseUrl: configMap['baseUrl'] as String? ?? '',
|
||||
accessToken: configMap['accessToken'] as String? ?? '',
|
||||
refreshToken: configMap['refreshToken'] as String? ?? '',
|
||||
localRoot: configMap['localRoot'] as String? ?? '',
|
||||
remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my',
|
||||
syncMode: configMap['syncMode'] as String? ?? 'full',
|
||||
conflictStrategy: configMap['conflictStrategy'] as String? ?? 'keep_both',
|
||||
wcfDeleteMode: configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only',
|
||||
maxConcurrentTransfers: configMap['maxConcurrentTransfers'] as int? ?? 3,
|
||||
bandwidthLimitKbps: configMap['bandwidthLimitKbps'] as int? ?? 0,
|
||||
maxWorkers: configMap['maxWorkers'] as int? ?? 0,
|
||||
dataDir: configMap['dataDir'] as String? ?? '',
|
||||
clientId: configMap['clientId'] as String? ?? '',
|
||||
logLevel: configMap['logLevel'] as String? ?? 'info',
|
||||
);
|
||||
AppLogger.i('恢复同步配置: 模式=${_persistedConfig!.syncMode}, 冲突=${_persistedConfig!.conflictStrategy}, 并发=${_persistedConfig!.maxConcurrentTransfers}, 带宽=${_persistedConfig!.bandwidthLimitKbps}kbps, maxWorkers=${_persistedConfig!.maxWorkers}');
|
||||
} catch (e) {
|
||||
AppLogger.e('恢复同步配置失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final savedState = await StorageService.instance.getSyncState();
|
||||
if (savedState != null && savedState != 'idle' && savedState != 'stopped') {
|
||||
AppLogger.i('恢复同步状态: $savedState');
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存同步配置到持久化存储
|
||||
Future<void> _persistConfig(SyncConfigModel config) async {
|
||||
_persistedConfig = config;
|
||||
await StorageService.instance.setSyncConfig({
|
||||
'baseUrl': config.baseUrl,
|
||||
'accessToken': config.accessToken,
|
||||
'refreshToken': config.refreshToken,
|
||||
'localRoot': config.localRoot,
|
||||
'remoteRoot': config.remoteRoot,
|
||||
'syncMode': config.syncMode,
|
||||
'conflictStrategy': config.conflictStrategy,
|
||||
'wcfDeleteMode': config.wcfDeleteMode,
|
||||
'maxConcurrentTransfers': config.maxConcurrentTransfers,
|
||||
'bandwidthLimitKbps': config.bandwidthLimitKbps,
|
||||
'maxWorkers': config.maxWorkers,
|
||||
'dataDir': config.dataDir,
|
||||
'clientId': config.clientId,
|
||||
'logLevel': config.logLevel,
|
||||
});
|
||||
}
|
||||
|
||||
/// 持久化同步状态
|
||||
Future<void> _persistState(SyncState state) async {
|
||||
final stateStr = switch (state) {
|
||||
SyncState.idle => 'idle',
|
||||
SyncState.initializing => 'initializing',
|
||||
SyncState.initialSync => 'initialSync',
|
||||
SyncState.continuous => 'continuous',
|
||||
SyncState.paused => 'paused',
|
||||
SyncState.error => 'error',
|
||||
SyncState.stopped => 'stopped',
|
||||
};
|
||||
await StorageService.instance.setSyncState(stateStr);
|
||||
}
|
||||
|
||||
/// 初始化并启动同步
|
||||
Future<void> startSync(SyncConfigModel config) async {
|
||||
_state = SyncState.initializing;
|
||||
_errorMessage = null;
|
||||
_syncedFiles = 0;
|
||||
_totalFiles = 0;
|
||||
_currentFile = null;
|
||||
notifyListeners();
|
||||
|
||||
// 确保 clientId 已注入(Dart 生成、持久化、全层共享)
|
||||
final clientId = await StorageService.instance.getOrCreateClientId();
|
||||
final configWithClientId = config.copyWith(clientId: clientId);
|
||||
|
||||
// 持久化配置和状态
|
||||
await _persistConfig(configWithClientId);
|
||||
await _persistState(SyncState.initializing);
|
||||
|
||||
try {
|
||||
await SyncService.instance.init(configWithClientId);
|
||||
_engineInitialized = true;
|
||||
_subscribeEvents();
|
||||
|
||||
_state = SyncState.initialSync;
|
||||
await _persistState(SyncState.initialSync);
|
||||
notifyListeners();
|
||||
|
||||
// 启动状态轮询
|
||||
_startPolling();
|
||||
|
||||
// 启动初始同步(后台运行)
|
||||
SyncService.instance.startInitialSync().then((summary) async {
|
||||
_lastSummary = summary;
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
AppLogger.i('初始同步完成');
|
||||
notifyListeners();
|
||||
|
||||
// 自动启动持续同步
|
||||
SyncService.instance.startContinuousSync();
|
||||
}).catchError((e) async {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e.toString();
|
||||
await _persistState(SyncState.error);
|
||||
AppLogger.e('初始同步失败: $e');
|
||||
notifyListeners();
|
||||
});
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e.toString();
|
||||
await _persistState(SyncState.error);
|
||||
AppLogger.e('同步启动失败: $e');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动恢复同步(启动时调用,如果之前处于同步状态则恢复)
|
||||
Future<void> autoResumeIfNeeded({
|
||||
String? currentAccessToken,
|
||||
String? currentRefreshToken,
|
||||
}) async {
|
||||
if (_persistedConfig == null) {
|
||||
await restoreFromStorage();
|
||||
}
|
||||
if (_persistedConfig == null) return;
|
||||
|
||||
final savedState = await StorageService.instance.getSyncState();
|
||||
if (savedState == null || savedState == 'idle' || savedState == 'stopped' || savedState == 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('自动恢复同步,上次状态: $savedState');
|
||||
|
||||
var config = _persistedConfig!;
|
||||
if (currentAccessToken != null && currentRefreshToken != null) {
|
||||
config = config.copyWith(
|
||||
accessToken: currentAccessToken,
|
||||
refreshToken: currentRefreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
await startSync(config);
|
||||
}
|
||||
|
||||
/// 更新配置(持久化 + 推送到 Rust 引擎)
|
||||
Future<void> updateConfig(SyncConfigModel config) async {
|
||||
await _persistConfig(config);
|
||||
|
||||
// 引擎已初始化时(无论是否运行中),推送配置到 Rust
|
||||
try {
|
||||
await SyncService.instance.updateConfig(config);
|
||||
AppLogger.i('同步配置已更新到引擎: 模式=${config.syncMode}');
|
||||
} catch (e) {
|
||||
AppLogger.e('更新配置到引擎失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 热修改日志级别(立即生效,无需重启引擎)
|
||||
Future<void> setLogLevel(String level) async {
|
||||
// 持久化
|
||||
if (_persistedConfig != null) {
|
||||
final updated = _persistedConfig!.copyWith(logLevel: level);
|
||||
await _persistConfig(updated);
|
||||
}
|
||||
|
||||
// 引擎已初始化时立即通知 Rust(不限制仅活跃状态)
|
||||
try {
|
||||
await SyncService.instance.setLogLevel(level);
|
||||
AppLogger.i('日志级别已切换为: $level');
|
||||
} catch (e) {
|
||||
AppLogger.e('切换日志级别失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询 Rust 引擎状态(动态间隔:活跃1s,空闲3s)
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollIntervalSeconds = isActive || isPaused ? 1 : 3;
|
||||
_pollTimer = Timer.periodic(
|
||||
Duration(seconds: _pollIntervalSeconds),
|
||||
(_) => _pollStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
void _adjustPollInterval() {
|
||||
final target = isActive || isPaused ? 1 : 3;
|
||||
if (target != _pollIntervalSeconds) {
|
||||
_pollIntervalSeconds = target;
|
||||
_startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
int _pollErrorCount = 0;
|
||||
|
||||
Future<void> _pollStatus() async {
|
||||
try {
|
||||
final status = await SyncService.instance.getStatus();
|
||||
_syncedFiles = status.syncedFiles;
|
||||
_totalFiles = status.totalFiles;
|
||||
_uploadingCount = status.uploadingCount;
|
||||
_downloadingCount = status.downloadingCount;
|
||||
_conflictCount = status.conflictCount;
|
||||
_pollErrorCount = 0;
|
||||
|
||||
final rustState = _parseState(status.state);
|
||||
if (rustState != _state && rustState != SyncState.idle) {
|
||||
_state = rustState;
|
||||
await _persistState(rustState);
|
||||
}
|
||||
if (status.errorMessage != null && status.errorMessage!.isNotEmpty) {
|
||||
_errorMessage = status.errorMessage;
|
||||
}
|
||||
|
||||
// 轮询活跃任务 + 刷新已完成任务
|
||||
try {
|
||||
final newActiveWorkerCount = await SyncService.instance.getActiveWorkerCount();
|
||||
final newActiveTasks = await SyncService.instance.getActiveTasksTyped();
|
||||
|
||||
bool changed = newActiveWorkerCount != _activeWorkerCount;
|
||||
_activeWorkerCount = newActiveWorkerCount;
|
||||
|
||||
// 活跃任务列表有变化时才更新
|
||||
if (!_taskListEqual(newActiveTasks, _activeTasks)) {
|
||||
_activeTasks = newActiveTasks;
|
||||
changed = true;
|
||||
// 清除不再活跃的任务的详情缓存
|
||||
_taskDetailCache.removeWhere(
|
||||
(key, _) => !newActiveTasks.any((t) => t.id == key),
|
||||
);
|
||||
}
|
||||
|
||||
// 每次 polling 都刷新已完成任务
|
||||
await _refreshRecentTasks();
|
||||
|
||||
// 刷新展开中的任务详情(实时更新 item 状态)
|
||||
await _refreshWatchedTaskDetails();
|
||||
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
notifyListeners();
|
||||
_adjustPollInterval();
|
||||
} catch (_) {
|
||||
// 引擎未初始化等连续错误,停止轮询避免刷屏
|
||||
_pollErrorCount++;
|
||||
if (_pollErrorCount >= 3) {
|
||||
_stopPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 首次加载已完成任务(如果轮询未运行则启动慢速轮询)
|
||||
Future<void> loadRecentTasks() async {
|
||||
if (_recentTasksLoaded) return;
|
||||
await _refreshRecentTasks();
|
||||
if (_pollTimer == null) {
|
||||
_startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshRecentTasks() async {
|
||||
try {
|
||||
final tasks = await SyncService.instance.getRecentTasksTyped(limit: 50);
|
||||
if (!_taskListEqual(tasks, _recentTasks)) {
|
||||
_recentTasks = tasks;
|
||||
}
|
||||
_recentTasksLoaded = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool _taskListEqual(List<SyncTaskModel> a, List<SyncTaskModel> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].id != b[i].id ||
|
||||
a[i].completedCount != b[i].completedCount ||
|
||||
a[i].failedCount != b[i].failedCount ||
|
||||
a[i].status != b[i].status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 刷新展开中的任务详情(轮询时调用,只刷新 watched 的任务)
|
||||
Future<void> _refreshWatchedTaskDetails() async {
|
||||
if (_watchedTaskIds.isEmpty) return;
|
||||
for (final taskId in _watchedTaskIds.toList()) {
|
||||
try {
|
||||
// 重新加载前 20 条(和当前缓存量一致)
|
||||
final cachedCount = _taskDetailCache[taskId]?.length ?? 20;
|
||||
final newItems = await SyncService.instance.queryTaskItemsTyped(
|
||||
taskId: taskId,
|
||||
limit: cachedCount.clamp(20, 100),
|
||||
offset: 0,
|
||||
);
|
||||
final oldItems = _taskDetailCache[taskId];
|
||||
if (oldItems != null && !_itemListEqual(oldItems, newItems)) {
|
||||
_taskDetailCache[taskId] = newItems;
|
||||
notifyListeners();
|
||||
} else if (oldItems == null) {
|
||||
_taskDetailCache[taskId] = newItems;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
bool _itemListEqual(List<SyncTaskItemModel> a, List<SyncTaskItemModel> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].id != b[i].id || a[i].status != b[i].status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _subscribeEvents() {
|
||||
_eventSub = SyncService.instance.events.listen((event) async {
|
||||
switch (event) {
|
||||
case SyncStateChanged(:final newState):
|
||||
_state = _parseState(newState);
|
||||
await _persistState(_state);
|
||||
case SyncProgress(:final synced, :final total, :final currentFile):
|
||||
_syncedFiles = synced;
|
||||
_totalFiles = total;
|
||||
_currentFile = currentFile;
|
||||
case SyncFileUploaded():
|
||||
_syncedFiles++;
|
||||
case SyncFileDownloaded():
|
||||
_syncedFiles++;
|
||||
case SyncConflictDetected():
|
||||
_conflictCount++;
|
||||
case SyncError(:final message, :final recoverable):
|
||||
if (!recoverable) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = message;
|
||||
await _persistState(SyncState.error);
|
||||
}
|
||||
case SyncTokenExpired():
|
||||
_handleTokenExpired();
|
||||
case SyncInitialSyncComplete(:final summary):
|
||||
_lastSummary = summary;
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
SyncService.instance.startContinuousSync();
|
||||
case SyncDiskSpaceWarning():
|
||||
AppLogger.w('磁盘空间不足');
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
SyncState _parseState(String state) {
|
||||
return switch (state) {
|
||||
'idle' => SyncState.idle,
|
||||
'initializing' => SyncState.initializing,
|
||||
'initialSync' => SyncState.initialSync,
|
||||
'continuous' => SyncState.continuous,
|
||||
'paused' => SyncState.paused,
|
||||
'error' => SyncState.error,
|
||||
'stopped' => SyncState.stopped,
|
||||
_ => SyncState.idle,
|
||||
};
|
||||
}
|
||||
|
||||
void _handleTokenExpired() {
|
||||
AppLogger.w('Token 过期,需要刷新');
|
||||
}
|
||||
|
||||
/// 获取任务详情(分页加载,带缓存)
|
||||
Future<List<SyncTaskItemModel>> getTaskDetail(String taskId) async {
|
||||
if (_taskDetailCache.containsKey(taskId)) {
|
||||
return _taskDetailCache[taskId]!;
|
||||
}
|
||||
final items = await SyncService.instance.queryTaskItemsTyped(
|
||||
taskId: taskId,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
);
|
||||
_taskDetailCache[taskId] = items;
|
||||
_taskDetailHasMore[taskId] = items.length >= 20;
|
||||
notifyListeners();
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 加载更多任务详情
|
||||
Future<List<SyncTaskItemModel>> loadMoreTaskDetail(String taskId) async {
|
||||
final current = _taskDetailCache[taskId] ?? [];
|
||||
final offset = current.length;
|
||||
final newItems = await SyncService.instance.queryTaskItemsTyped(
|
||||
taskId: taskId,
|
||||
limit: 20,
|
||||
offset: offset,
|
||||
);
|
||||
final merged = [...current, ...newItems];
|
||||
_taskDetailCache[taskId] = merged;
|
||||
_taskDetailHasMore[taskId] = newItems.length >= 20;
|
||||
notifyListeners();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// 指定任务是否还有更多详情可加载
|
||||
bool hasMoreTaskDetail(String taskId) {
|
||||
return _taskDetailHasMore[taskId] ?? true;
|
||||
}
|
||||
|
||||
/// 标记任务详情需要实时刷新(UI 展开时调用)
|
||||
void watchTaskDetail(String taskId) {
|
||||
_watchedTaskIds.add(taskId);
|
||||
}
|
||||
|
||||
/// 取消实时刷新(UI 收起时调用)
|
||||
void unwatchTaskDetail(String taskId) {
|
||||
_watchedTaskIds.remove(taskId);
|
||||
}
|
||||
|
||||
/// 读取缓存的任务详情(同步,无则返回 null)
|
||||
List<SyncTaskItemModel>? getCachedTaskDetail(String taskId) {
|
||||
return _taskDetailCache[taskId];
|
||||
}
|
||||
|
||||
/// 清除任务详情缓存,下次获取时重新请求
|
||||
void invalidateTaskDetail(String taskId) {
|
||||
_taskDetailCache.remove(taskId);
|
||||
_taskDetailHasMore.remove(taskId);
|
||||
}
|
||||
|
||||
/// 清除所有任务详情缓存
|
||||
void invalidateAllTaskDetails() {
|
||||
_taskDetailCache.clear();
|
||||
_taskDetailHasMore.clear();
|
||||
_recentTasksLoaded = false;
|
||||
}
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pause() async {
|
||||
await SyncService.instance.pause();
|
||||
_state = SyncState.paused;
|
||||
await _persistState(SyncState.paused);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resume() async {
|
||||
await SyncService.instance.resume();
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stop() async {
|
||||
await SyncService.instance.stop();
|
||||
_state = SyncState.stopped;
|
||||
await _persistState(SyncState.stopped);
|
||||
notifyListeners();
|
||||
_adjustPollInterval();
|
||||
}
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync() async {
|
||||
// 引擎未初始化时仅清空本地状态
|
||||
try {
|
||||
await SyncService.instance.resetSync();
|
||||
} catch (_) {}
|
||||
_state = SyncState.idle;
|
||||
_errorMessage = null;
|
||||
_syncedFiles = 0;
|
||||
_totalFiles = 0;
|
||||
_conflictCount = 0;
|
||||
_uploadingCount = 0;
|
||||
_downloadingCount = 0;
|
||||
_currentFile = null;
|
||||
_lastSummary = null;
|
||||
_activeTasks = [];
|
||||
_recentTasks = [];
|
||||
_activeWorkerCount = 0;
|
||||
_taskDetailCache.clear();
|
||||
_recentTasksLoaded = false;
|
||||
_engineInitialized = false;
|
||||
await _persistState(SyncState.idle);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 强制重新同步
|
||||
Future<void> forceSync() async {
|
||||
_state = SyncState.initialSync;
|
||||
_syncedFiles = 0;
|
||||
_totalFiles = 0;
|
||||
await _persistState(SyncState.initialSync);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final summary = await SyncService.instance.forceSync();
|
||||
_lastSummary = summary;
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
// 重新启动持续同步
|
||||
SyncService.instance.startContinuousSync();
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e.toString();
|
||||
await _persistState(SyncState.error);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopPolling();
|
||||
_eventSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../core/utils/file_utils.dart';
|
||||
|
||||
/// 面包屑导航组件
|
||||
@@ -15,7 +16,7 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pathParts = FileUtils.toRelativePath(currentPath).split('/');
|
||||
final pathParts = currentPath.split('/');
|
||||
pathParts.removeWhere((part) => part.isEmpty);
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
@@ -55,7 +56,7 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
),
|
||||
_buildBreadcrumbItem(
|
||||
context,
|
||||
name: FileUtils.decodePathSegment(pathParts[i]),
|
||||
name: _decodePathSegment(pathParts[i]),
|
||||
path: '/${pathParts.sublist(0, i + 1).join('/')}',
|
||||
icon: null,
|
||||
primaryColor: colorScheme.primary,
|
||||
@@ -69,6 +70,10 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _decodePathSegment(String value) {
|
||||
return FileUtils.safeDecodePathSegment(value);
|
||||
}
|
||||
|
||||
Widget _buildBreadcrumbItem(
|
||||
BuildContext context, {
|
||||
required String name,
|
||||
|
||||
@@ -210,7 +210,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
_buildInfoRow(
|
||||
LucideIcons.folderOpen,
|
||||
'位置',
|
||||
FileUtils.decodePathForDisplay(file.relativePath),
|
||||
FileUtils.safeDecodePathSegment(file.relativePath),
|
||||
),
|
||||
if (file.isFile)
|
||||
_buildInfoRow(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:cloudreve4_flutter/data/models/file_model.dart';
|
||||
import 'package:cloudreve4_flutter/services/file_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../../core/utils/file_utils.dart';
|
||||
|
||||
/// 文件夹选择器
|
||||
class FolderPicker extends StatefulWidget {
|
||||
@@ -62,7 +61,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
|
||||
void _enterFolder(FileModel folder) {
|
||||
setState(() {
|
||||
_currentPath = folder.relativePath;
|
||||
_currentPath = folder.path;
|
||||
});
|
||||
_loadFolders();
|
||||
}
|
||||
@@ -81,10 +80,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: widget.maxVisibleItems != null
|
||||
? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(
|
||||
80.0,
|
||||
maxHeight,
|
||||
)
|
||||
? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(80.0, maxHeight)
|
||||
: maxHeight,
|
||||
),
|
||||
child: _buildListContent(context, primaryColor),
|
||||
@@ -112,10 +108,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
children: [
|
||||
Icon(Icons.folder_off, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'此文件夹为空',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
||||
),
|
||||
Text('此文件夹为空', style: TextStyle(color: Colors.grey.shade600, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -153,13 +146,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
folder.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
child: Text(folder.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20),
|
||||
],
|
||||
@@ -169,7 +156,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
}
|
||||
|
||||
Widget _buildBreadcrumb(BuildContext context, Color primaryColor) {
|
||||
final pathParts = FileUtils.toRelativePath(_currentPath).split('/');
|
||||
final pathParts = _currentPath.split('/');
|
||||
pathParts.removeWhere((part) => part.isEmpty);
|
||||
|
||||
return Column(
|
||||
@@ -193,8 +180,8 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
),
|
||||
...pathParts.asMap().entries.expand((entry) {
|
||||
final index = entry.key;
|
||||
final path =
|
||||
'/${pathParts.sublist(0, index + 1).join('/')}';
|
||||
final part = entry.value;
|
||||
final path = '/${pathParts.sublist(0, index + 1).join('/')}';
|
||||
|
||||
return [
|
||||
Padding(
|
||||
@@ -207,7 +194,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
),
|
||||
_buildBreadcrumbItem(
|
||||
context,
|
||||
name: FileUtils.decodePathSegment(entry.value),
|
||||
name: part,
|
||||
path: path,
|
||||
isLast: index == pathParts.length - 1,
|
||||
primaryColor: primaryColor,
|
||||
@@ -221,14 +208,13 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final relative = FileUtils.toRelativePath(_currentPath);
|
||||
final relative = _currentPath.startsWith('cloudreve://my')
|
||||
? _currentPath.replaceFirst('cloudreve://my', '')
|
||||
: _currentPath;
|
||||
widget.onFolderSelected(relative.isEmpty ? '/' : relative);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
child: const Text('选择'),
|
||||
),
|
||||
|
||||
@@ -436,7 +436,7 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
FileUtils.decodePathForDisplay(file.relativePath),
|
||||
FileUtils.safeDecodePathSegment(file.relativePath),
|
||||
style: TextStyle(fontSize: 12, color: theme.hintColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../data/models/app_update_model.dart';
|
||||
import '../../services/update_service.dart';
|
||||
import 'toast_helper.dart';
|
||||
|
||||
class UpdatePrompt extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const UpdatePrompt({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<UpdatePrompt> createState() => _UpdatePromptState();
|
||||
}
|
||||
|
||||
class _UpdatePromptState extends State<UpdatePrompt> {
|
||||
static bool _checkedThisRun = false;
|
||||
bool _dialogVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdate());
|
||||
}
|
||||
|
||||
Future<void> _checkUpdate() async {
|
||||
if (_checkedThisRun || _dialogVisible || !mounted) return;
|
||||
_checkedThisRun = true;
|
||||
|
||||
final update = await UpdateService.instance.checkForUpdate();
|
||||
if (update == null || !mounted) return;
|
||||
|
||||
_dialogVisible = true;
|
||||
try {
|
||||
await _showUpdateDialog(update);
|
||||
} finally {
|
||||
_dialogVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
||||
var downloading = false;
|
||||
var progress = 0.0;
|
||||
String? status;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final canDownload =
|
||||
update.downloadUrl != null &&
|
||||
(Platform.isAndroid || Platform.isWindows);
|
||||
|
||||
Future<void> startUpdate() async {
|
||||
if (downloading || !canDownload) return;
|
||||
|
||||
setDialogState(() {
|
||||
downloading = true;
|
||||
progress = 0;
|
||||
status = '正在下载更新...';
|
||||
});
|
||||
|
||||
try {
|
||||
final file = await UpdateService.instance.downloadUpdate(
|
||||
update,
|
||||
onProgress: (received, total) {
|
||||
if (!mounted || total <= 0) return;
|
||||
setDialogState(() {
|
||||
progress = received / total;
|
||||
});
|
||||
},
|
||||
);
|
||||
if (file == null) return;
|
||||
|
||||
setDialogState(() {
|
||||
progress = 1;
|
||||
status = '下载完成,正在打开安装包...';
|
||||
});
|
||||
await UpdateService.instance.openInstaller(file);
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
} catch (e) {
|
||||
setDialogState(() {
|
||||
downloading = false;
|
||||
status = '下载或打开安装包失败';
|
||||
});
|
||||
ToastHelper.failure('更新失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('发现新版本 ${update.version}'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(update.title),
|
||||
if ((update.description ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
update.description!,
|
||||
maxLines: 6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (downloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: progress == 0 ? null : progress,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(status ?? ''),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: downloading
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('稍后'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: downloading ? null : startUpdate,
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart';
|
||||
import '../presentation/pages/webdav/webdav_page.dart';
|
||||
import '../presentation/pages/remote_download/remote_download_page.dart';
|
||||
import '../presentation/pages/settings/settings_page.dart';
|
||||
import '../presentation/pages/sync/sync_settings_page.dart';
|
||||
import '../presentation/pages/preview/image_preview_page.dart';
|
||||
import '../presentation/pages/preview/pdf_preview_page.dart';
|
||||
import '../presentation/pages/preview/video_preview_page.dart';
|
||||
@@ -35,6 +36,7 @@ class RouteNames {
|
||||
static const String documentPreview = '/document-preview';
|
||||
static const String markdownPreview = '/markdown-preview';
|
||||
static const String categoryFiles = '/category-files';
|
||||
static const String syncSettings = '/sync-settings';
|
||||
}
|
||||
|
||||
/// 应用路由
|
||||
@@ -218,6 +220,12 @@ class AppRouter {
|
||||
),
|
||||
);
|
||||
|
||||
case RouteNames.syncSettings:
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => const SyncSettingsPage(),
|
||||
);
|
||||
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../core/exceptions/app_exception.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
|
||||
@@ -120,6 +121,11 @@ class ApiService {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
// 附加 X-Cr-Client-Id,服务端据此过滤 SSE 自身事件
|
||||
try {
|
||||
final clientId = await StorageService.instance.getOrCreateClientId();
|
||||
options.headers['X-Cr-Client-Id'] = clientId;
|
||||
} catch (_) {}
|
||||
return handler.next(options);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:tray_manager/tray_manager.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../presentation/providers/theme_provider.dart';
|
||||
import 'sync_service.dart';
|
||||
import '../src/rust/api/ffi.dart' as ffi;
|
||||
|
||||
/// 桌面端服务(窗口管理 + 系统托盘)
|
||||
class DesktopService with TrayListener, WindowListener {
|
||||
@@ -188,6 +190,22 @@ class DesktopService with TrayListener, WindowListener {
|
||||
try {
|
||||
AppLogger.d('DesktopService: Cleaning up before exit...');
|
||||
|
||||
// 同步引擎清理(WCF 模式下必须调用,同步释放占位符、注销 sync root)
|
||||
try {
|
||||
await SyncService.instance.stop();
|
||||
AppLogger.d('DesktopService: Sync engine stopped');
|
||||
} catch (e) {
|
||||
AppLogger.e('DesktopService: Error stopping sync: $e');
|
||||
}
|
||||
|
||||
// 进程退出前同步清理(确保 WCF 资源释放,不依赖 tokio runtime)
|
||||
try {
|
||||
await ffi.syncShutdown();
|
||||
AppLogger.d('DesktopService: Sync shutdown complete');
|
||||
} catch (e) {
|
||||
AppLogger.e('DesktopService: Error in sync shutdown: $e');
|
||||
}
|
||||
|
||||
// 彻底解绑
|
||||
windowManager.removeListener(this);
|
||||
trayManager.removeListener(this);
|
||||
|
||||
@@ -213,7 +213,7 @@ class FileService {
|
||||
int? pageSize,
|
||||
}) async {
|
||||
// 构造搜索 URI: cloudreve://my?name=xxx
|
||||
final cloudreveUri = FileUtils.toCloudreveUriWithQuery(uri, {'name': name});
|
||||
final cloudreveUri = '${FileUtils.toCloudreveUri(uri)}?name=$name';
|
||||
|
||||
final params = <String, dynamic>{
|
||||
'uri': cloudreveUri,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../data/models/server_model.dart';
|
||||
|
||||
@@ -149,4 +150,55 @@ class StorageService {
|
||||
Future<void> clearSearchHistory() async {
|
||||
await remove(StorageKeys.searchHistory);
|
||||
}
|
||||
|
||||
// ===== 同步配置持久化 =====
|
||||
|
||||
/// 保存同步配置
|
||||
Future<bool> setSyncConfig(Map<String, dynamic> config) async {
|
||||
try {
|
||||
final json = jsonEncode(config);
|
||||
return await setString(StorageKeys.syncConfig, json);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取同步配置
|
||||
Future<Map<String, dynamic>?> getSyncConfig() async {
|
||||
final json = await getString(StorageKeys.syncConfig);
|
||||
if (json == null || json.isEmpty) return null;
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存同步状态
|
||||
Future<bool> setSyncState(String state) async {
|
||||
return await setString(StorageKeys.syncState, state);
|
||||
}
|
||||
|
||||
/// 读取同步状态
|
||||
Future<String?> getSyncState() async {
|
||||
return await getString(StorageKeys.syncState);
|
||||
}
|
||||
|
||||
/// 清除同步配置和状态
|
||||
Future<void> clearSyncData() async {
|
||||
await remove(StorageKeys.syncConfig);
|
||||
await remove(StorageKeys.syncState);
|
||||
}
|
||||
|
||||
// ===== client_id 持久化 =====
|
||||
|
||||
/// 获取 client_id,首次调用自动生成并持久化
|
||||
Future<String> getOrCreateClientId() async {
|
||||
var id = await getString(StorageKeys.clientId);
|
||||
if (id == null || id.isEmpty) {
|
||||
id = const Uuid().v4();
|
||||
await setString(StorageKeys.clientId, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/sync_config_model.dart';
|
||||
import '../data/models/sync_event_model.dart';
|
||||
import '../data/models/sync_status_model.dart';
|
||||
import '../data/models/sync_task_model.dart';
|
||||
import '../src/rust/api/ffi.dart' as ffi;
|
||||
import '../src/rust/api/ffi_types.dart' as ffi_types;
|
||||
|
||||
/// 同步服务单例 - 桥接 Flutter UI 和 Rust 同步引擎
|
||||
class SyncService {
|
||||
static final SyncService instance = SyncService._();
|
||||
SyncService._();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
/// 事件流,供 SyncProvider 订阅
|
||||
final _eventController = StreamController<SyncEventModel>.broadcast();
|
||||
Stream<SyncEventModel> get events => _eventController.stream;
|
||||
|
||||
/// 初始化同步引擎(已初始化时更新配置)
|
||||
Future<void> init(SyncConfigModel config) async {
|
||||
if (_initialized) {
|
||||
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
|
||||
await ffi.updateSyncConfig(config: config.toFfi());
|
||||
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.d('[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}');
|
||||
|
||||
await ffi.initSyncEngine(config: config.toFfi());
|
||||
|
||||
_initialized = true;
|
||||
AppLogger.d('[FFI] ← initSyncEngine: ok');
|
||||
}
|
||||
|
||||
/// 执行初始全量同步
|
||||
Future<SyncSummaryModel> startInitialSync() async {
|
||||
AppLogger.d('[FFI] → startInitialSync');
|
||||
final summary = await ffi.startInitialSync();
|
||||
AppLogger.d('[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
|
||||
return SyncSummaryModel.fromFfi(summary);
|
||||
}
|
||||
|
||||
/// 启动持续同步
|
||||
Future<void> startContinuousSync() async {
|
||||
AppLogger.d('[FFI] → startContinuousSync');
|
||||
await ffi.startContinuousSync();
|
||||
AppLogger.d('[FFI] ← startContinuousSync: spawned');
|
||||
}
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stop() async {
|
||||
AppLogger.d('[FFI] → stopSync');
|
||||
await ffi.stopSync();
|
||||
AppLogger.d('[FFI] ← stopSync: ok');
|
||||
}
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pause() async {
|
||||
AppLogger.d('[FFI] → pauseSync');
|
||||
await ffi.pauseSync();
|
||||
AppLogger.d('[FFI] ← pauseSync: ok');
|
||||
}
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resume() async {
|
||||
AppLogger.d('[FFI] → resumeSync');
|
||||
await ffi.resumeSync();
|
||||
AppLogger.d('[FFI] ← resumeSync: ok');
|
||||
}
|
||||
|
||||
/// 强制重新同步
|
||||
Future<SyncSummaryModel> forceSync() async {
|
||||
AppLogger.d('[FFI] → forceSync');
|
||||
final summary = await ffi.forceSync();
|
||||
AppLogger.d('[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}');
|
||||
return SyncSummaryModel.fromFfi(summary);
|
||||
}
|
||||
|
||||
/// Token 变更时推送给 Rust
|
||||
Future<void> updateTokens(String accessToken) async {
|
||||
AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}');
|
||||
await ffi.updateTokens(accessToken: accessToken);
|
||||
AppLogger.d('[FFI] ← updateTokens: ok');
|
||||
}
|
||||
|
||||
/// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略)
|
||||
Future<void> updateConfig(SyncConfigModel config) async {
|
||||
if (!_initialized) return;
|
||||
AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps');
|
||||
try {
|
||||
await ffi.updateSyncConfig(config: config.toFfi());
|
||||
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||
} catch (e) {
|
||||
AppLogger.e('[FFI] ← updateSyncConfig: error=$e');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 以下为轮询/查询类,用 trace 避免刷屏 ==========
|
||||
|
||||
/// 获取同步状态快照(轮询高频调用,trace 级别)
|
||||
Future<SyncStatusModel> getStatus() async {
|
||||
final status = await ffi.getSyncStatus();
|
||||
AppLogger.t('[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}');
|
||||
return SyncStatusModel.fromFfi(status);
|
||||
}
|
||||
|
||||
/// 获取活跃 Worker 数量(轮询高频调用,trace 级别)
|
||||
Future<int> getActiveWorkerCount() async {
|
||||
final count = await ffi.getActiveWorkerCount();
|
||||
AppLogger.t('[FFI] ← getActiveWorkerCount: $count');
|
||||
return count;
|
||||
}
|
||||
|
||||
/// 获取活跃的同步任务列表(轮询高频调用,trace 级别)
|
||||
Future<List<SyncTaskModel>> getActiveTasksTyped() async {
|
||||
final tasks = await ffi.getActiveTasks();
|
||||
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
|
||||
return tasks.map((t) => SyncTaskModel(
|
||||
id: t.id,
|
||||
trigger: t.trigger,
|
||||
totalCount: t.totalCount,
|
||||
completedCount: t.completedCount,
|
||||
failedCount: t.failedCount,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
/// 获取最近同步任务列表(轮询高频调用,trace 级别)
|
||||
Future<List<SyncTaskModel>> getRecentTasksTyped({int limit = 20}) async {
|
||||
AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
|
||||
final tasks = await ffi.getRecentTasks(limit: limit);
|
||||
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
|
||||
return tasks.map((t) => SyncTaskModel(
|
||||
id: t.id,
|
||||
trigger: t.trigger,
|
||||
totalCount: t.totalCount,
|
||||
completedCount: t.completedCount,
|
||||
failedCount: t.failedCount,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
/// 获取任务详情(按需查询,trace 级别)
|
||||
Future<List<SyncTaskItemModel>> getTaskDetailTyped(String taskId) async {
|
||||
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
|
||||
final items = await ffi.getTaskDetail(taskId: taskId);
|
||||
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
|
||||
return items.map((i) => SyncTaskItemModel(
|
||||
id: i.id.toInt(),
|
||||
taskId: i.taskId,
|
||||
relativePath: i.relativePath,
|
||||
actionType: i.actionType,
|
||||
status: i.status,
|
||||
fileSize: i.fileSize.toInt(),
|
||||
errorMessage: i.errorMessage,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
/// 分页查询任务详情(trace 级别)
|
||||
Future<List<SyncTaskItemModel>> queryTaskItemsTyped({
|
||||
required String taskId,
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset');
|
||||
final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi(
|
||||
taskId: taskId,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
));
|
||||
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
|
||||
return items.map((i) => SyncTaskItemModel(
|
||||
id: i.id.toInt(),
|
||||
taskId: i.taskId,
|
||||
relativePath: i.relativePath,
|
||||
actionType: i.actionType,
|
||||
status: i.status,
|
||||
fileSize: i.fileSize.toInt(),
|
||||
errorMessage: i.errorMessage,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
// ========== 以下为低频操作,保持 debug 级别 ==========
|
||||
|
||||
/// 水合文件 (Windows CFAPi)
|
||||
Future<void> hydrateFile(String localPath) async {
|
||||
AppLogger.d('[FFI] → hydrateFile: path=$localPath');
|
||||
await ffi.hydrateFile(localPath: localPath);
|
||||
AppLogger.d('[FFI] ← hydrateFile: ok');
|
||||
}
|
||||
|
||||
/// 检查云端相册目录 (Android)
|
||||
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
|
||||
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
|
||||
final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri);
|
||||
AppLogger.d('[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}');
|
||||
return {
|
||||
'dcimExists': result.dcimExists,
|
||||
'picturesExists': result.picturesExists,
|
||||
'dcimUri': result.dcimUri,
|
||||
'picturesUri': result.picturesUri,
|
||||
};
|
||||
}
|
||||
|
||||
/// 创建云端相册目录 (Android)
|
||||
Future<void> createCloudAlbumDirs(String baseUri) async {
|
||||
AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri');
|
||||
await ffi.createCloudAlbumDirs(baseUri: baseUri);
|
||||
AppLogger.d('[FFI] ← createCloudAlbumDirs: ok');
|
||||
}
|
||||
|
||||
/// 销毁同步引擎
|
||||
Future<void> dispose() async {
|
||||
AppLogger.d('[FFI] → disposeSyncEngine');
|
||||
await _eventController.close();
|
||||
await ffi.disposeSyncEngine();
|
||||
_initialized = false;
|
||||
AppLogger.d('[FFI] ← disposeSyncEngine: ok');
|
||||
}
|
||||
|
||||
/// 热修改日志级别(立即生效,无需重启)
|
||||
Future<void> setLogLevel(String level) async {
|
||||
AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
|
||||
await ffi.setSyncLogLevel(level: level);
|
||||
AppLogger.d('[FFI] ← setSyncLogLevel: ok');
|
||||
}
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync() async {
|
||||
AppLogger.d('[FFI] → resetSync');
|
||||
await ffi.resetSync();
|
||||
AppLogger.d('[FFI] ← resetSync: ok');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/app_update_model.dart';
|
||||
|
||||
class UpdateService {
|
||||
UpdateService._();
|
||||
|
||||
static final UpdateService instance = UpdateService._();
|
||||
|
||||
static final Uri releasesRssUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases.rss',
|
||||
);
|
||||
static final Uri releasesPageUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases',
|
||||
);
|
||||
|
||||
final Dio _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
followRedirects: true,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
|
||||
bool _checking = false;
|
||||
|
||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||
if (_checking) return null;
|
||||
if (!_supportsAutoDownload) return null;
|
||||
|
||||
_checking = true;
|
||||
try {
|
||||
final current = await PackageInfo.fromPlatform();
|
||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||
if (latest == null) return null;
|
||||
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
||||
return latest;
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||
return null;
|
||||
} finally {
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> downloadUpdate(
|
||||
AppUpdateInfo update, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
final url = update.downloadUrl;
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final updateDir = Directory(p.join(dir.path, 'app_updates'));
|
||||
await updateDir.create(recursive: true);
|
||||
|
||||
final fileName = update.fileName ?? _fileNameFromUrl(url);
|
||||
final file = File(p.join(updateDir.path, fileName));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
await _dio.downloadUri(
|
||||
url,
|
||||
file.path,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> openInstaller(File file) async {
|
||||
final result = await OpenFile.open(file.path);
|
||||
if (result.type != ResultType.done) {
|
||||
throw Exception(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openReleasePage(AppUpdateInfo update) async {
|
||||
final url = update.pageUrl;
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('无法打开更新页面: $url');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) return null;
|
||||
|
||||
final document = XmlDocument.parse(body);
|
||||
final items = document.findAllElements('item').toList();
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
final releases =
|
||||
items.map(_releaseFromRssItem).whereType<_RssRelease>().toList()
|
||||
..sort(_compareRssReleaseDesc);
|
||||
if (releases.isEmpty) return null;
|
||||
|
||||
for (final release in releases) {
|
||||
if (currentVersion != null &&
|
||||
_compareVersions(release.version, currentVersion) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final update = await _buildUpdateInfo(release);
|
||||
if (update == null) continue;
|
||||
|
||||
if (update.downloadUrl == null) continue;
|
||||
return update;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_RssRelease? _releaseFromRssItem(XmlElement item) {
|
||||
final title = _elementText(item, 'title') ?? '';
|
||||
final link = _elementText(item, 'link') ?? releasesPageUrl.toString();
|
||||
final description = _elementText(item, 'description');
|
||||
final content = _elementText(item, 'encoded');
|
||||
final enclosure = item
|
||||
.findElements('enclosure')
|
||||
.map((e) => e.getAttribute('url'))
|
||||
.whereType<String>()
|
||||
.map(_absoluteUriOrNull)
|
||||
.whereType<Uri>()
|
||||
.toList();
|
||||
|
||||
final candidates = <Uri>[
|
||||
...enclosure,
|
||||
..._extractLinks(description ?? ''),
|
||||
..._extractLinks(content ?? ''),
|
||||
..._extractLinks(item.toXmlString()),
|
||||
];
|
||||
|
||||
final pageUrl = _absoluteUriOrNull(link) ?? releasesPageUrl;
|
||||
final version = _versionFromText(
|
||||
'$title $link ${description ?? ''} ${content ?? ''}',
|
||||
);
|
||||
if (version == null) return null;
|
||||
|
||||
return _RssRelease(
|
||||
version: version,
|
||||
title: title.isEmpty ? 'v$version' : title,
|
||||
description: _stripHtml(
|
||||
(content ?? '').isNotEmpty ? content : description,
|
||||
),
|
||||
pageUrl: pageUrl,
|
||||
candidates: candidates,
|
||||
publishedAt: _parseRssDate(_elementText(item, 'pubDate')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _buildUpdateInfo(_RssRelease release) async {
|
||||
final candidates = <Uri>[...release.candidates];
|
||||
final downloadUrl = _pickDownloadUrl(candidates);
|
||||
Uri? resolvedDownloadUrl = downloadUrl;
|
||||
|
||||
resolvedDownloadUrl ??= await _downloadUrlFromReleasePage(release.pageUrl);
|
||||
|
||||
return AppUpdateInfo(
|
||||
version: release.version,
|
||||
title: release.title,
|
||||
description: release.description,
|
||||
pageUrl: release.pageUrl,
|
||||
downloadUrl: resolvedDownloadUrl,
|
||||
fileName: resolvedDownloadUrl == null
|
||||
? null
|
||||
: _fileNameFromUrl(resolvedDownloadUrl),
|
||||
publishedAt: release.publishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uri?> _downloadUrlFromReleasePage(Uri pageUrl) async {
|
||||
try {
|
||||
final response = await _dio.getUri<String>(pageUrl);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) return null;
|
||||
return _pickDownloadUrl(_extractLinks(body));
|
||||
} catch (e) {
|
||||
AppLogger.w('鑾峰彇鏇存柊闄勪欢澶辫触: $pageUrl $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uri? _pickDownloadUrl(List<Uri> candidates) {
|
||||
final normalized = candidates.toSet().toList();
|
||||
final preferred = normalized
|
||||
.where(_isReleaseAttachment)
|
||||
.where(_isSupportedPackage)
|
||||
.toList();
|
||||
if (preferred.isNotEmpty) return preferred.first;
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
||||
|
||||
bool _isReleaseAttachment(Uri uri) {
|
||||
return uri.path.contains('/releases/download/');
|
||||
}
|
||||
|
||||
bool _isSupportedPackage(Uri uri) {
|
||||
final name = _fileNameFromUrl(uri).toLowerCase();
|
||||
if (Platform.isAndroid) {
|
||||
return name.endsWith('.apk');
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return name.endsWith('.exe') ||
|
||||
name.endsWith('.msix') ||
|
||||
name.endsWith('.msi') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return name.endsWith('.appimage') ||
|
||||
name.endsWith('.deb') ||
|
||||
name.endsWith('.rpm') ||
|
||||
name.endsWith('.tar.gz') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
return name.endsWith('.dmg') ||
|
||||
name.endsWith('.pkg') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Uri> _extractLinks(String html) {
|
||||
final matches = RegExp(
|
||||
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
|
||||
caseSensitive: false,
|
||||
).allMatches(html);
|
||||
return matches
|
||||
.map((m) => m.group(1) ?? m.group(0))
|
||||
.whereType<String>()
|
||||
.map(_absoluteUriOrNull)
|
||||
.whereType<Uri>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Uri? _absoluteUriOrNull(String value) {
|
||||
final cleaned = _decodeHtmlEntities(value.trim());
|
||||
final parsed = Uri.tryParse(cleaned);
|
||||
if (parsed == null) return null;
|
||||
if (parsed.hasScheme) return parsed;
|
||||
return releasesPageUrl.resolveUri(parsed);
|
||||
}
|
||||
|
||||
String? _elementText(XmlElement element, String name) {
|
||||
for (final child in element.children.whereType<XmlElement>()) {
|
||||
if (child.name.qualified == name || child.name.local == name) {
|
||||
return child.innerText.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _versionFromText(String text) {
|
||||
final match = RegExp(
|
||||
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
|
||||
).firstMatch(text);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
String? _stripHtml(String? value) {
|
||||
if (value == null) return null;
|
||||
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
return _decodeHtmlEntities(
|
||||
withoutTags,
|
||||
).replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
String _decodeHtmlEntities(String value) {
|
||||
return value
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('+', '+')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
DateTime? _parseRssDate(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
final direct = DateTime.tryParse(value);
|
||||
if (direct != null) return direct;
|
||||
try {
|
||||
return HttpDate.parse(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
|
||||
final version = _compareVersions(b.version, a.version);
|
||||
if (version != 0) return version;
|
||||
|
||||
final left = a.publishedAt;
|
||||
final right = b.publishedAt;
|
||||
if (left == null && right == null) return 0;
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
}
|
||||
|
||||
int _compareVersions(String a, String b) {
|
||||
final left = _versionParts(a);
|
||||
final right = _versionParts(b);
|
||||
final length = left.length > right.length ? left.length : right.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final l = i < left.length ? left[i] : 0;
|
||||
final r = i < right.length ? right[i] : 0;
|
||||
if (l != r) return l.compareTo(r);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<int> _versionParts(String version) {
|
||||
final core = version.split(RegExp(r'[+\-]')).first;
|
||||
return core
|
||||
.split('.')
|
||||
.map((part) => int.tryParse(part) ?? 0)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _fileNameFromUrl(Uri uri) {
|
||||
final segment = uri.pathSegments.isEmpty
|
||||
? 'app_update'
|
||||
: uri.pathSegments.last;
|
||||
return Uri.decodeComponent(segment);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||
}
|
||||
|
||||
class _RssRelease {
|
||||
final String version;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Uri pageUrl;
|
||||
final List<Uri> candidates;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const _RssRelease({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.pageUrl,
|
||||
required this.candidates,
|
||||
this.description,
|
||||
this.publishedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
|
||||
|
||||
import '../frb_generated.dart';
|
||||
import 'ffi_types.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `album_result_to_ffi`, `apply_log_level`, `config_from_ffi`, `config_to_ffi`, `error_to_ffi`, `get_engine`, `status_to_ffi`, `summary_to_ffi`, `task_item_to_ffi`, `task_to_ffi`
|
||||
|
||||
/// 初始化同步引擎
|
||||
Future<void> initSyncEngine({required SyncConfigFfi config}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiInitSyncEngine(config: config);
|
||||
|
||||
/// 销毁同步引擎
|
||||
Future<void> disposeSyncEngine() =>
|
||||
RustSyncApi.instance.api.crateApiFfiDisposeSyncEngine();
|
||||
|
||||
/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放)
|
||||
/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用
|
||||
Future<void> syncShutdown() =>
|
||||
RustSyncApi.instance.api.crateApiFfiSyncShutdown();
|
||||
|
||||
/// 执行初始全量同步
|
||||
Future<SyncSummaryFfi> startInitialSync() =>
|
||||
RustSyncApi.instance.api.crateApiFfiStartInitialSync();
|
||||
|
||||
/// 启动持续同步(后台运行,立即返回)
|
||||
Future<void> startContinuousSync() =>
|
||||
RustSyncApi.instance.api.crateApiFfiStartContinuousSync();
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stopSync() => RustSyncApi.instance.api.crateApiFfiStopSync();
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pauseSync() => RustSyncApi.instance.api.crateApiFfiPauseSync();
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync();
|
||||
|
||||
/// 强制同步(重新扫描全量差异)
|
||||
Future<SyncSummaryFfi> forceSync() =>
|
||||
RustSyncApi.instance.api.crateApiFfiForceSync();
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync() => RustSyncApi.instance.api.crateApiFfiResetSync();
|
||||
|
||||
/// 获取同步状态快照
|
||||
Future<SyncStatusFfi> getSyncStatus() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetSyncStatus();
|
||||
|
||||
/// 获取活跃 Worker 数量
|
||||
Future<int> getActiveWorkerCount() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetActiveWorkerCount();
|
||||
|
||||
/// 获取同步配置
|
||||
Future<SyncConfigFfi> getSyncConfig() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetSyncConfig();
|
||||
|
||||
/// 更新同步配置
|
||||
Future<void> updateSyncConfig({required SyncConfigFfi config}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiUpdateSyncConfig(config: config);
|
||||
|
||||
/// Dart 推送新 Token 给 Rust
|
||||
Future<void> updateTokens({required String accessToken}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiUpdateTokens(accessToken: accessToken);
|
||||
|
||||
/// 水合文件(Windows 按需下载)
|
||||
Future<void> hydrateFile({required String localPath}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiHydrateFile(localPath: localPath);
|
||||
|
||||
/// 同步相册到云端
|
||||
Future<void> syncAlbumToCloud({
|
||||
required List<String> albumPaths,
|
||||
required String remoteDcimUri,
|
||||
}) => RustSyncApi.instance.api.crateApiFfiSyncAlbumToCloud(
|
||||
albumPaths: albumPaths,
|
||||
remoteDcimUri: remoteDcimUri,
|
||||
);
|
||||
|
||||
/// 检查云端是否存在 DCIM/Pictures 目录
|
||||
Future<CloudAlbumCheckResultFfi> checkCloudAlbumDirs({
|
||||
required String baseUri,
|
||||
}) => RustSyncApi.instance.api.crateApiFfiCheckCloudAlbumDirs(baseUri: baseUri);
|
||||
|
||||
/// 在云端创建 DCIM/Pictures 目录
|
||||
Future<void> createCloudAlbumDirs({required String baseUri}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiCreateCloudAlbumDirs(baseUri: baseUri);
|
||||
|
||||
/// 注册 Rust→Dart 事件推送通道
|
||||
Stream<SyncEventFfi> registerSyncEventSink() =>
|
||||
RustSyncApi.instance.api.crateApiFfiRegisterSyncEventSink();
|
||||
|
||||
/// 运行时热修改日志级别(立即生效,无需重启)
|
||||
Future<void> setSyncLogLevel({required String level}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiSetSyncLogLevel(level: level);
|
||||
|
||||
/// 获取活跃的同步任务列表
|
||||
Future<List<SyncTaskFfi>> getActiveTasks() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetActiveTasks();
|
||||
|
||||
/// 获取最近同步任务列表
|
||||
Future<List<SyncTaskFfi>> getRecentTasks({required int limit}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetRecentTasks(limit: limit);
|
||||
|
||||
/// 获取任务详情(任务项列表)
|
||||
Future<List<SyncTaskItemFfi>> getTaskDetail({required String taskId}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetTaskDetail(taskId: taskId);
|
||||
|
||||
/// 多维度查询任务项
|
||||
Future<List<SyncTaskItemFfi>> queryTaskItems({
|
||||
required TaskItemFilterFfi filter,
|
||||
}) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter);
|
||||
@@ -0,0 +1,450 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
|
||||
|
||||
import '../frb_generated.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
part 'ffi_types.freezed.dart';
|
||||
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`
|
||||
|
||||
/// Android: 云端相册目录检查结果
|
||||
class CloudAlbumCheckResultFfi {
|
||||
final bool dcimExists;
|
||||
final bool picturesExists;
|
||||
final String? dcimUri;
|
||||
final String? picturesUri;
|
||||
|
||||
const CloudAlbumCheckResultFfi({
|
||||
required this.dcimExists,
|
||||
required this.picturesExists,
|
||||
this.dcimUri,
|
||||
this.picturesUri,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
dcimExists.hashCode ^
|
||||
picturesExists.hashCode ^
|
||||
dcimUri.hashCode ^
|
||||
picturesUri.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CloudAlbumCheckResultFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
dcimExists == other.dcimExists &&
|
||||
picturesExists == other.picturesExists &&
|
||||
dcimUri == other.dcimUri &&
|
||||
picturesUri == other.picturesUri;
|
||||
}
|
||||
|
||||
/// 同步配置
|
||||
class SyncConfigFfi {
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String localRoot;
|
||||
final String remoteRoot;
|
||||
final String syncMode;
|
||||
final String conflictStrategy;
|
||||
final String wcfDeleteMode;
|
||||
final int maxConcurrentTransfers;
|
||||
final BigInt bandwidthLimitKbps;
|
||||
final List<String> excludedPaths;
|
||||
final int maxWorkers;
|
||||
final String dataDir;
|
||||
final String clientId;
|
||||
final String logLevel;
|
||||
|
||||
const SyncConfigFfi({
|
||||
required this.baseUrl,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.localRoot,
|
||||
required this.remoteRoot,
|
||||
required this.syncMode,
|
||||
required this.conflictStrategy,
|
||||
required this.wcfDeleteMode,
|
||||
required this.maxConcurrentTransfers,
|
||||
required this.bandwidthLimitKbps,
|
||||
required this.excludedPaths,
|
||||
required this.maxWorkers,
|
||||
required this.dataDir,
|
||||
required this.clientId,
|
||||
required this.logLevel,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
baseUrl.hashCode ^
|
||||
accessToken.hashCode ^
|
||||
refreshToken.hashCode ^
|
||||
localRoot.hashCode ^
|
||||
remoteRoot.hashCode ^
|
||||
syncMode.hashCode ^
|
||||
conflictStrategy.hashCode ^
|
||||
wcfDeleteMode.hashCode ^
|
||||
maxConcurrentTransfers.hashCode ^
|
||||
bandwidthLimitKbps.hashCode ^
|
||||
excludedPaths.hashCode ^
|
||||
maxWorkers.hashCode ^
|
||||
dataDir.hashCode ^
|
||||
clientId.hashCode ^
|
||||
logLevel.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncConfigFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
baseUrl == other.baseUrl &&
|
||||
accessToken == other.accessToken &&
|
||||
refreshToken == other.refreshToken &&
|
||||
localRoot == other.localRoot &&
|
||||
remoteRoot == other.remoteRoot &&
|
||||
syncMode == other.syncMode &&
|
||||
conflictStrategy == other.conflictStrategy &&
|
||||
wcfDeleteMode == other.wcfDeleteMode &&
|
||||
maxConcurrentTransfers == other.maxConcurrentTransfers &&
|
||||
bandwidthLimitKbps == other.bandwidthLimitKbps &&
|
||||
excludedPaths == other.excludedPaths &&
|
||||
maxWorkers == other.maxWorkers &&
|
||||
dataDir == other.dataDir &&
|
||||
clientId == other.clientId &&
|
||||
logLevel == other.logLevel;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException {
|
||||
const SyncErrorFfi._();
|
||||
|
||||
const factory SyncErrorFfi.notInitialized() = SyncErrorFfi_NotInitialized;
|
||||
const factory SyncErrorFfi.networkError({required String message}) =
|
||||
SyncErrorFfi_NetworkError;
|
||||
const factory SyncErrorFfi.diskFull({
|
||||
required BigInt needed,
|
||||
required BigInt available,
|
||||
}) = SyncErrorFfi_DiskFull;
|
||||
const factory SyncErrorFfi.authError({required String message}) =
|
||||
SyncErrorFfi_AuthError;
|
||||
const factory SyncErrorFfi.conflictError({required int count}) =
|
||||
SyncErrorFfi_ConflictError;
|
||||
const factory SyncErrorFfi.internalError({required String message}) =
|
||||
SyncErrorFfi_InternalError;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SyncEventFfi with _$SyncEventFfi {
|
||||
const SyncEventFfi._();
|
||||
|
||||
const factory SyncEventFfi.stateChanged({required String newState}) =
|
||||
SyncEventFfi_StateChanged;
|
||||
const factory SyncEventFfi.progress({
|
||||
required BigInt synced,
|
||||
required BigInt total,
|
||||
required String currentFile,
|
||||
}) = SyncEventFfi_Progress;
|
||||
const factory SyncEventFfi.fileUploaded({
|
||||
required String localPath,
|
||||
required String remoteUri,
|
||||
}) = SyncEventFfi_FileUploaded;
|
||||
const factory SyncEventFfi.fileDownloaded({
|
||||
required String localPath,
|
||||
required String remoteUri,
|
||||
}) = SyncEventFfi_FileDownloaded;
|
||||
const factory SyncEventFfi.conflictDetected({
|
||||
required String localPath,
|
||||
required String conflictType,
|
||||
}) = SyncEventFfi_ConflictDetected;
|
||||
const factory SyncEventFfi.error({
|
||||
required String message,
|
||||
required bool recoverable,
|
||||
}) = SyncEventFfi_Error;
|
||||
const factory SyncEventFfi.tokenExpired() = SyncEventFfi_TokenExpired;
|
||||
const factory SyncEventFfi.diskSpaceWarning({required BigInt availableMb}) =
|
||||
SyncEventFfi_DiskSpaceWarning;
|
||||
const factory SyncEventFfi.initialSyncComplete({
|
||||
required SyncSummaryFfi summary,
|
||||
}) = SyncEventFfi_InitialSyncComplete;
|
||||
const factory SyncEventFfi.workerStarted({
|
||||
required String taskId,
|
||||
required String trigger,
|
||||
required int uploadCount,
|
||||
required int downloadCount,
|
||||
}) = SyncEventFfi_WorkerStarted;
|
||||
const factory SyncEventFfi.workerCompleted({
|
||||
required String taskId,
|
||||
required int uploaded,
|
||||
required int downloaded,
|
||||
required int renamed,
|
||||
required int moved,
|
||||
required int failed,
|
||||
required BigInt durationMs,
|
||||
}) = SyncEventFfi_WorkerCompleted;
|
||||
const factory SyncEventFfi.workerFailed({
|
||||
required String taskId,
|
||||
required String message,
|
||||
}) = SyncEventFfi_WorkerFailed;
|
||||
const factory SyncEventFfi.taskItemUpdated({
|
||||
required String taskId,
|
||||
required String relativePath,
|
||||
required String action,
|
||||
required String status,
|
||||
}) = SyncEventFfi_TaskItemUpdated;
|
||||
}
|
||||
|
||||
/// 同步状态快照
|
||||
class SyncStatusFfi {
|
||||
final String state;
|
||||
final BigInt syncedFiles;
|
||||
final BigInt totalFiles;
|
||||
final int uploadingCount;
|
||||
final int downloadingCount;
|
||||
final int conflictCount;
|
||||
final int errorCount;
|
||||
final String? lastSyncTime;
|
||||
final String? errorMessage;
|
||||
|
||||
const SyncStatusFfi({
|
||||
required this.state,
|
||||
required this.syncedFiles,
|
||||
required this.totalFiles,
|
||||
required this.uploadingCount,
|
||||
required this.downloadingCount,
|
||||
required this.conflictCount,
|
||||
required this.errorCount,
|
||||
this.lastSyncTime,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
state.hashCode ^
|
||||
syncedFiles.hashCode ^
|
||||
totalFiles.hashCode ^
|
||||
uploadingCount.hashCode ^
|
||||
downloadingCount.hashCode ^
|
||||
conflictCount.hashCode ^
|
||||
errorCount.hashCode ^
|
||||
lastSyncTime.hashCode ^
|
||||
errorMessage.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncStatusFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
state == other.state &&
|
||||
syncedFiles == other.syncedFiles &&
|
||||
totalFiles == other.totalFiles &&
|
||||
uploadingCount == other.uploadingCount &&
|
||||
downloadingCount == other.downloadingCount &&
|
||||
conflictCount == other.conflictCount &&
|
||||
errorCount == other.errorCount &&
|
||||
lastSyncTime == other.lastSyncTime &&
|
||||
errorMessage == other.errorMessage;
|
||||
}
|
||||
|
||||
/// 初始同步摘要
|
||||
class SyncSummaryFfi {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int conflicts;
|
||||
final int failed;
|
||||
final int skipped;
|
||||
final int deletedLocal;
|
||||
final int deletedRemote;
|
||||
final BigInt durationMs;
|
||||
|
||||
const SyncSummaryFfi({
|
||||
required this.uploaded,
|
||||
required this.downloaded,
|
||||
required this.renamed,
|
||||
required this.moved,
|
||||
required this.conflicts,
|
||||
required this.failed,
|
||||
required this.skipped,
|
||||
required this.deletedLocal,
|
||||
required this.deletedRemote,
|
||||
required this.durationMs,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
uploaded.hashCode ^
|
||||
downloaded.hashCode ^
|
||||
renamed.hashCode ^
|
||||
moved.hashCode ^
|
||||
conflicts.hashCode ^
|
||||
failed.hashCode ^
|
||||
skipped.hashCode ^
|
||||
deletedLocal.hashCode ^
|
||||
deletedRemote.hashCode ^
|
||||
durationMs.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncSummaryFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uploaded == other.uploaded &&
|
||||
downloaded == other.downloaded &&
|
||||
renamed == other.renamed &&
|
||||
moved == other.moved &&
|
||||
conflicts == other.conflicts &&
|
||||
failed == other.failed &&
|
||||
skipped == other.skipped &&
|
||||
deletedLocal == other.deletedLocal &&
|
||||
deletedRemote == other.deletedRemote &&
|
||||
durationMs == other.durationMs;
|
||||
}
|
||||
|
||||
/// 同步任务摘要(FFI)
|
||||
class SyncTaskFfi {
|
||||
final String id;
|
||||
final String trigger;
|
||||
final int totalCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final String status;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final String? finishedAt;
|
||||
|
||||
const SyncTaskFfi({
|
||||
required this.id,
|
||||
required this.trigger,
|
||||
required this.totalCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.finishedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
trigger.hashCode ^
|
||||
totalCount.hashCode ^
|
||||
completedCount.hashCode ^
|
||||
failedCount.hashCode ^
|
||||
status.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
finishedAt.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncTaskFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
trigger == other.trigger &&
|
||||
totalCount == other.totalCount &&
|
||||
completedCount == other.completedCount &&
|
||||
failedCount == other.failedCount &&
|
||||
status == other.status &&
|
||||
createdAt == other.createdAt &&
|
||||
updatedAt == other.updatedAt &&
|
||||
finishedAt == other.finishedAt;
|
||||
}
|
||||
|
||||
/// 同步任务项(FFI)
|
||||
class SyncTaskItemFfi {
|
||||
final PlatformInt64 id;
|
||||
final String taskId;
|
||||
final String relativePath;
|
||||
final String actionType;
|
||||
final String status;
|
||||
final BigInt fileSize;
|
||||
final String? errorMessage;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncTaskItemFfi({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.relativePath,
|
||||
required this.actionType,
|
||||
required this.status,
|
||||
required this.fileSize,
|
||||
this.errorMessage,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
taskId.hashCode ^
|
||||
relativePath.hashCode ^
|
||||
actionType.hashCode ^
|
||||
status.hashCode ^
|
||||
fileSize.hashCode ^
|
||||
errorMessage.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncTaskItemFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
taskId == other.taskId &&
|
||||
relativePath == other.relativePath &&
|
||||
actionType == other.actionType &&
|
||||
status == other.status &&
|
||||
fileSize == other.fileSize &&
|
||||
errorMessage == other.errorMessage &&
|
||||
createdAt == other.createdAt &&
|
||||
updatedAt == other.updatedAt;
|
||||
}
|
||||
|
||||
/// 任务项查询过滤器(FFI)
|
||||
class TaskItemFilterFfi {
|
||||
final String? taskId;
|
||||
final String? relativePathContains;
|
||||
final String? actionType;
|
||||
final String? status;
|
||||
final int limit;
|
||||
final int offset;
|
||||
|
||||
const TaskItemFilterFfi({
|
||||
this.taskId,
|
||||
this.relativePathContains,
|
||||
this.actionType,
|
||||
this.status,
|
||||
required this.limit,
|
||||
required this.offset,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
taskId.hashCode ^
|
||||
relativePathContains.hashCode ^
|
||||
actionType.hashCode ^
|
||||
status.hashCode ^
|
||||
limit.hashCode ^
|
||||
offset.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TaskItemFilterFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
taskId == other.taskId &&
|
||||
relativePathContains == other.relativePathContains &&
|
||||
actionType == other.actionType &&
|
||||
status == other.status &&
|
||||
limit == other.limit &&
|
||||
offset == other.offset;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
|
||||
|
||||
import 'api/ffi.dart';
|
||||
import 'api/ffi_types.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'frb_generated.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
|
||||
|
||||
abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
|
||||
RustSyncApiApiImplPlatform({
|
||||
required super.handler,
|
||||
required super.wire,
|
||||
required super.generalizedFrbRustBinding,
|
||||
required super.portManager,
|
||||
});
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> dco_decode_StreamSink_sync_event_ffi_Sse(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> dco_decode_list_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncEventFfi dco_decode_sync_event_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_8(dynamic raw);
|
||||
|
||||
@protected
|
||||
void dco_decode_unit(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> sse_decode_StreamSink_sync_event_ffi_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> sse_decode_list_sync_task_item_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_8(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_decode_unit(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_sync_event_ffi_Sse(
|
||||
RustStreamSink<SyncEventFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_config_ffi(
|
||||
SyncConfigFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_cloud_album_check_result_ffi(
|
||||
CloudAlbumCheckResultFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_prim_u_8_strict(
|
||||
Uint8List self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_ffi(
|
||||
List<SyncTaskFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_item_ffi(
|
||||
List<SyncTaskItemFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_item_ffi(
|
||||
SyncTaskItemFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_8(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_unit(void self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_32(int self, SseSerializer serializer);
|
||||
}
|
||||
|
||||
// Section: wire_class
|
||||
|
||||
class RustSyncApiWire implements BaseWire {
|
||||
factory RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib) =>
|
||||
RustSyncApiWire(lib.ffiDynamicLibrary);
|
||||
|
||||
/// Holds the symbol lookup function.
|
||||
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
|
||||
_lookup;
|
||||
|
||||
/// The symbols are looked up in [dynamicLibrary].
|
||||
RustSyncApiWire(ffi.DynamicLibrary dynamicLibrary)
|
||||
: _lookup = dynamicLibrary.lookup;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
|
||||
|
||||
// Static analysis wrongly picks the IO variant, thus ignore this
|
||||
// ignore_for_file: argument_type_not_assignable
|
||||
|
||||
import 'api/ffi.dart';
|
||||
import 'api/ffi_types.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'frb_generated.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
|
||||
|
||||
abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
|
||||
RustSyncApiApiImplPlatform({
|
||||
required super.handler,
|
||||
required super.wire,
|
||||
required super.generalizedFrbRustBinding,
|
||||
required super.portManager,
|
||||
});
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> dco_decode_StreamSink_sync_event_ffi_Sse(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> dco_decode_list_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncEventFfi dco_decode_sync_event_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_8(dynamic raw);
|
||||
|
||||
@protected
|
||||
void dco_decode_unit(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> sse_decode_StreamSink_sync_event_ffi_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> sse_decode_list_sync_task_item_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_8(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_decode_unit(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_sync_event_ffi_Sse(
|
||||
RustStreamSink<SyncEventFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_config_ffi(
|
||||
SyncConfigFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_cloud_album_check_result_ffi(
|
||||
CloudAlbumCheckResultFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_prim_u_8_strict(
|
||||
Uint8List self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_ffi(
|
||||
List<SyncTaskFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_item_ffi(
|
||||
List<SyncTaskItemFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_item_ffi(
|
||||
SyncTaskItemFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_8(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_unit(void self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_32(int self, SseSerializer serializer);
|
||||
}
|
||||
|
||||
// Section: wire_class
|
||||
|
||||
class RustSyncApiWire implements BaseWire {
|
||||
RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib);
|
||||
}
|
||||
|
||||
@JS('wasm_bindgen')
|
||||
external RustSyncApiWasmModule get wasmModule;
|
||||
|
||||
@JS()
|
||||
@anonymous
|
||||
extension type RustSyncApiWasmModule._(JSObject _) implements JSObject {}
|
||||
Reference in New Issue
Block a user