7 Commits

Author SHA1 Message Date
gongyun c208a06af5 t push origin mainMerge branch 'dev'
Android APK Release / Build Android APK (push) Successful in 1h1m49s
2026-05-28 03:25:24 +08:00
gongyun c2db8b38e9 修复apk build error 2026-05-28 00:46:52 +08:00
gongyun 24d20cbfcb 修复处理内容包括公告 WebView、文件预览页、分享页兜底获取下载链接的隐藏 WebView。 2026-05-27 23:47:10 +08:00
gongyun 12f2c2660e Version number update 2026-05-27 19:27:27 +08:00
gongyun 98af110531 merge new features 2026-05-27 19:19:11 +08:00
gongyun 886046a72b 更新 README.md 2026-05-26 17:02:18 +08:00
gongyun d20f7790e3 更新 README.md
dev分支README文件修改
2026-05-26 16:49:14 +08:00
38 changed files with 6729 additions and 1000 deletions
+2
View File
@@ -21,6 +21,7 @@ migrate_working_dir/
.frontend/
# custom
.claude/
CLAUDE.md
claude_project.md
PROJECT_REQUIREMENTS.md
@@ -67,6 +68,7 @@ native/logs
sync_refactory.md
tools/*
*.diff
android/app/src/main/jniLibs/*
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
+26 -6
View File
@@ -30,6 +30,14 @@ if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
val releaseStoreFile = keystoreProperties.getProperty("storeFile")?.let { rootProject.file(it) }
val hasReleaseSigning = listOf(
"keyAlias",
"keyPassword",
"storePassword",
).all { !keystoreProperties.getProperty(it).isNullOrBlank() } &&
releaseStoreFile?.exists() == true
android {
namespace = "com.limo.cloudreve4_flutter"
compileSdk = getLocalProperty("flutter.compileSdkVersion", 36)
@@ -38,10 +46,12 @@ android {
// 2. 配置签名选项
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
if (hasReleaseSigning) {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = releaseStoreFile
storePassword = keystoreProperties.getProperty("storePassword")
}
}
}
@@ -54,15 +64,25 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.limo.cloudreve4_flutter"
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = getLocalProperty("flutter.minSdkVersion", 24)
targetSdk = getLocalProperty("flutter.targetSdkVersion", 35)
versionCode = flutter.versionCode
versionName = flutter.versionName
ndk {
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
}
}
packaging {
@@ -76,7 +96,7 @@ android {
buildTypes {
release {
// 1. 将原来的 getByName("debug") 替换为 getByName("release")
signingConfig = signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug")
// 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码
isMinifyEnabled = true
+5 -1
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.workers.max=2
org.gradle.vfs.watch=false
kotlin.compiler.execution.strategy=in-process
kotlin.incremental=false
android.useAndroidX=true
+4
View File
@@ -35,4 +35,8 @@ class StorageKeys {
// 日志级别
static const String logLevel = 'app_log_level';
// 公告
static const String siteAnnouncementDismissedFingerprint =
'site_announcement_dismissed_fingerprint';
}
+62 -1
View File
@@ -55,12 +55,42 @@ class FileUtils {
'bmp',
'svg',
'heic',
'heif',
'avif',
'tif',
'tiff',
];
return imageExtensions.contains(getFileExtension(fileName));
}
static bool isFlutterRenderableImageFile(String fileName) {
const renderableImageExtensions = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
];
return renderableImageExtensions.contains(getFileExtension(fileName));
}
static bool isVideoFile(String fileName) {
const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'];
const videoExtensions = [
'mp4',
'webm',
'mkv',
'avi',
'mov',
'flv',
'wmv',
'm4v',
'mpg',
'mpeg',
'3gp',
'ts',
'm2ts',
];
return videoExtensions.contains(getFileExtension(fileName));
}
@@ -69,6 +99,17 @@ class FileUtils {
return audioExtensions.contains(getFileExtension(fileName));
}
static bool isPsdFile(String fileName) {
const psdExtensions = ['psd', 'psb'];
return psdExtensions.contains(getFileExtension(fileName));
}
static bool isThumbnailableFile(String fileName) {
final ext = getFileExtension(fileName);
if (ext.isEmpty || ext == 'svg') return false;
return isImageFile(fileName) || isVideoFile(fileName) || isPsdFile(fileName);
}
static bool isPdfFile(String fileName) {
return getFileExtension(fileName) == 'pdf';
}
@@ -155,13 +196,33 @@ class FileUtils {
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
'bmp': 'image/bmp',
'heic': 'image/heic',
'heif': 'image/heif',
'avif': 'image/avif',
'tif': 'image/tiff',
'tiff': 'image/tiff',
'psd': 'image/vnd.adobe.photoshop',
'psb': 'image/vnd.adobe.photoshop',
'mp4': 'video/mp4',
'webm': 'video/webm',
'mkv': 'video/x-matroska',
'avi': 'video/x-msvideo',
'mov': 'video/quicktime',
'flv': 'video/x-flv',
'wmv': 'video/x-ms-wmv',
'm4v': 'video/x-m4v',
'mpg': 'video/mpeg',
'mpeg': 'video/mpeg',
'3gp': 'video/3gpp',
'ts': 'video/mp2t',
'm2ts': 'video/mp2t',
'mp3': 'audio/mpeg',
'wav': 'audio/wav',
'ogg': 'audio/ogg',
'flac': 'audio/flac',
'aac': 'audio/aac',
'm4a': 'audio/mp4',
'pdf': 'application/pdf',
'txt': 'text/plain',
'json': 'application/json',
+26
View File
@@ -0,0 +1,26 @@
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
/// 通过 Win32 SetEnvironmentVariableW 设置/清除进程环境变量
/// 仅 Windows 有效,其他平台直接返回 false
bool winSetEnvVar(String name, String? value) {
if (!Platform.isWindows) return false;
final dylib = DynamicLibrary.open('kernel32.dll');
final fn = dylib.lookupFunction<
Int32 Function(Pointer<Utf16>, Pointer<Utf16>),
int Function(Pointer<Utf16>, Pointer<Utf16>)>(
'SetEnvironmentVariableW',
);
final namePtr = name.toNativeUtf16();
final valuePtr = value != null ? value.toNativeUtf16() : nullptr;
try {
return fn(namePtr, valuePtr) != 0;
} finally {
calloc.free(namePtr);
if (value != null) calloc.free(valuePtr);
}
}
@@ -1,7 +1,59 @@
import 'dart:convert';
import 'dart:io';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:cloudreve4_flutter/core/utils/win_env.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:webview_flutter/webview_flutter.dart' as mobile;
// ═════════════════════════════════════════════════════
// WebView 代理配置(仅 Windows,无认证)
// ═════════════════════════════════════════════════════
class CaptchaProxyConfig {
final String host;
final int port;
const CaptchaProxyConfig({required this.host, required this.port});
String get proxyArg => '--proxy-server=http://$host:$port';
@override
String toString() => '$host:$port';
}
// ═════════════════════════════════════════════════════
// WebView2 代理环境变量管理
// ═════════════════════════════════════════════════════
const _envVarName = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS';
/// 为 WebView2 设置代理环境变量(进程级,不影响其他程序)
void _applyWebView2Proxy(CaptchaProxyConfig? proxy) {
if (!Platform.isWindows) return;
if (proxy != null) {
final existing = Platform.environment[_envVarName];
final newValue = existing != null && existing.isNotEmpty
? '$existing ${proxy.proxyArg}'
: proxy.proxyArg;
winSetEnvVar(_envVarName, newValue);
AppLogger.i('WebView2 代理环境变量已设置: $newValue');
}
}
/// 清除 WebView2 代理环境变量
void _clearWebView2Proxy() {
if (!Platform.isWindows) return;
winSetEnvVar(_envVarName, null);
AppLogger.i('WebView2 代理环境变量已清除');
}
// ═════════════════════════════════════════════════════
// CaptchaWebConfig
// ═════════════════════════════════════════════════════
class CaptchaWebConfig {
final String type;
@@ -21,12 +73,20 @@ class CaptchaWebConfig {
const CaptchaWebConfig.recaptchaV2({
required String siteKey,
String displayName = 'reCAPTCHA V2',
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
}) : this._(
type: 'recaptcha',
displayName: displayName,
siteKey: siteKey,
);
const CaptchaWebConfig.turnstile({
required String siteKey,
String displayName = 'Cloudflare Turnstile',
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
}) : this._(
type: 'turnstile',
displayName: displayName,
siteKey: siteKey,
);
const CaptchaWebConfig.cap({
required String instanceUrl,
@@ -34,22 +94,36 @@ class CaptchaWebConfig {
String? assetServer,
String displayName = 'Cap',
}) : this._(
type: 'cap',
displayName: displayName,
instanceUrl: instanceUrl,
siteKey: siteKey,
assetServer: assetServer,
);
type: 'cap',
displayName: displayName,
instanceUrl: instanceUrl,
siteKey: siteKey,
assetServer: assetServer,
);
}
// ─── 桌面端判断 ───────────────────────────────────────
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
// ─── 桌面 User-Agent ──────────────────────────────────
const _desktopUserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
// ═════════════════════════════════════════════════════
// CaptchaChallengePage
// ═════════════════════════════════════════════════════
class CaptchaChallengePage extends StatefulWidget {
final CaptchaWebConfig config;
final String baseUrl;
final CaptchaProxyConfig? proxyConfig;
const CaptchaChallengePage({
super.key,
required this.config,
required this.baseUrl,
this.proxyConfig,
});
@override
@@ -57,61 +131,98 @@ class CaptchaChallengePage extends StatefulWidget {
}
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
late final WebViewController _controller;
// ── 移动端 ──
mobile.WebViewController? _mobileController;
// ── 桌面端 ──
InAppWebViewController? _desktopController;
Key _desktopKey = UniqueKey();
// ── 共享状态 ──
bool _isLoading = true;
int _progress = 0;
String? _errorMessage;
String? _statusText;
bool _disposed = false;
// ── 是否设置了代理环境变量(用于清理时判断)──
bool _proxyEnvSet = false;
// ── HTML ──
late String _currentHtml;
@override
void initState() {
super.initState();
_currentHtml = _buildHtml(widget.config);
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel(
'CaptchaBridge',
onMessageReceived: (message) {
_handleBridgeMessage(message.message);
},
)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (progress) {
if (mounted) setState(() => _progress = progress);
},
onPageFinished: (_) {
if (mounted) setState(() => _isLoading = false);
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = '${error.errorCode}: ${error.description}'
.trim();
});
}
},
),
);
// Windows: 在 WebView2 创建前设置代理环境变量
if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) {
_applyWebView2Proxy(widget.proxyConfig);
_proxyEnvSet = true;
}
_loadCaptcha();
if (!_isDesktop) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel(
'CaptchaBridge',
onMessageReceived: (message) {
_handleBridgeMessage(message.message);
},
)
..setNavigationDelegate(
mobile.NavigationDelegate(
onProgress: (progress) {
if (mounted) setState(() => _progress = progress);
},
onPageFinished: (_) {
if (mounted) setState(() => _isLoading = false);
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage =
'${error.errorCode}: ${error.description}'.trim();
});
}
},
),
)
..loadHtmlString(_currentHtml, baseUrl: widget.baseUrl);
}
}
// ─── 加载 / 刷新 ────────────────────────────────────
Future<void> _loadCaptcha() async {
if (_disposed) return;
setState(() {
_isLoading = true;
_errorMessage = null;
_statusText = null;
_progress = 0;
_currentHtml = _buildHtml(widget.config);
});
final html = _buildHtml(widget.config);
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
if (!_isDesktop && _mobileController != null) {
await _mobileController!.loadHtmlString(
_currentHtml,
baseUrl: widget.baseUrl,
);
} else if (_isDesktop) {
setState(() => _desktopKey = UniqueKey());
}
}
// ─── Bridge 消息处理 ─────────────────────────────────
void _handleBridgeMessage(String rawMessage) {
AppLogger.d(
'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}',
);
try {
final decoded = jsonDecode(rawMessage);
if (decoded is! Map) return;
@@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
if (type == 'success') {
final token = decoded['token']?.toString() ?? '';
if (token.isNotEmpty && mounted) {
final jsTs = decoded['_jsTs'];
if (jsTs is num) {
final delayMs = DateTime.now().millisecondsSinceEpoch - jsTs.toInt();
AppLogger.d('Bridge 收到 success, JS→Dart 传输延迟=${delayMs}ms, token长度=${token.length}');
} else {
AppLogger.d('Bridge 收到 success, token长度=${token.length}');
}
if (token.isNotEmpty && mounted && !_disposed) {
_disposed = true;
AppLogger.d('准备 pop 返回登录页');
Navigator.of(context).pop(token);
AppLogger.d('pop 完成');
}
return;
}
@@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
final progress = decoded['progress']?.toString();
if (mounted) {
setState(() {
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
_statusText =
progress == null ? '正在验证...' : '正在验证... $progress';
});
}
return;
@@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
return;
}
if (type == 'debug') {
AppLogger.d('JS debug: ${decoded['message']}');
return;
}
if (type == 'expired') {
if (mounted) {
setState(() {
@@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
}
return;
}
} catch (_) {
// 忽略非 JSON 消息。
} catch (_) {}
}
// ─── WebView 销毁 ───────────────────────────────────
void _cleanupWebView() {
if (_isDesktop) {
final ctrl = _desktopController;
_desktopController = null;
AppLogger.d('开始清理 WebView controller');
ctrl?.dispose();
AppLogger.d('WebView controller 已 dispose');
if (_proxyEnvSet) {
_clearWebView2Proxy();
_proxyEnvSet = false;
}
}
}
@override
void dispose() {
AppLogger.d('CaptchaChallengePage dispose 开始');
_disposed = true;
_cleanupWebView();
super.dispose();
AppLogger.d('CaptchaChallengePage dispose 完成');
}
// ═════════════════════════════════════════════════════
// HTML 生成
// ═════════════════════════════════════════════════════
String _buildHtml(CaptchaWebConfig config) {
switch (config.type) {
case 'turnstile':
return _buildTurnstileHtml(config.siteKey!);
return _baseHtml(
title: 'Cloudflare Turnstile',
body: '<div id="widget"></div>',
script: '''
function onTurnstileLoad() {
try {
turnstile.render('#widget', {
sitekey: '${_js(config.siteKey!)}',
callback: function(token) { solved(token); },
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
'expired-callback': function() { expired(); },
'after-interactive-callback': function() {
sendBridge({ type: 'debug', message: 'after-interactive fired' });
markStatus('正在与 Cloudflare 服务器验证,请稍候...', false);
sendBridge({ type: 'progress', progress: '服务器验证中' });
}
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
<script>
''',
);
case 'recaptcha':
return _buildRecaptchaHtml(config.siteKey!);
return _baseHtml(
title: 'reCAPTCHA V2',
body: '<div id="widget"></div>',
script: '''
function onRecaptchaLoad() {
try {
grecaptcha.render('widget', {
sitekey: '${_js(config.siteKey!)}',
callback: function(token) { solved(token); },
'expired-callback': function() { expired(); },
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
<script>
''',
);
case 'cap':
return _buildCapHtml(
instanceUrl: config.instanceUrl!,
siteKey: config.siteKey!,
assetServer: config.assetServer,
final endpoint = _capEndpoint(config.instanceUrl!, config.siteKey!);
final scriptUrl = _capWidgetScript(config.assetServer);
final safeEndpoint = const HtmlEscape().convert(endpoint);
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
return _baseHtml(
title: 'Cap',
body:
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
script: '''
window.CAP_DISABLE_HAPTICS = true;
const cap = document.getElementById('cap');
if (cap) {
cap.addEventListener('solve', function(e) {
solved(e.detail && e.detail.token ? e.detail.token : '');
});
cap.addEventListener('progress', function(e) {
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
markStatus('正在验证... ' + progress, false);
sendBridge({ type: 'progress', progress: progress });
});
cap.addEventListener('error', function(e) {
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
failed(message);
});
markStatus('请完成人机验证', false);
}
</script>
<script type="module" src="$safeScriptUrl"></script>
<script>
''',
);
default:
return _buildErrorHtml('不支持的验证码类型: ${config.type}');
return _baseHtml(
title: '验证码错误',
body:
'<div id="widget" class="error">${const HtmlEscape().convert('不支持的验证码类型: ${config.type}')}</div>',
script: "failed('不支持的验证码类型');",
);
}
}
@@ -260,8 +493,14 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
</div>
<script>
function sendBridge(payload) {
payload._jsTs = Date.now();
var json = JSON.stringify(payload);
try {
CaptchaBridge.postMessage(JSON.stringify(payload));
if (typeof CaptchaBridge !== 'undefined' && CaptchaBridge.postMessage) {
CaptchaBridge.postMessage(json);
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
window.flutter_inappwebview.callHandler('CaptchaBridge', json);
}
} catch (e) {}
}
function markStatus(text, isError) {
@@ -289,139 +528,9 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
''';
}
String _buildTurnstileHtml(String siteKey) {
return _baseHtml(
title: 'Cloudflare Turnstile',
body: '<div id="widget"></div>',
script:
'''
function onTurnstileLoad() {
try {
turnstile.render('#widget', {
sitekey: '${_js(siteKey)}',
callback: function(token) { solved(token); },
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
'expired-callback': function() { expired(); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
<script>
''',
);
}
String _buildRecaptchaHtml(String siteKey) {
return _baseHtml(
title: 'reCAPTCHA V2',
body: '<div id="widget"></div>',
script:
'''
function onRecaptchaLoad() {
try {
grecaptcha.render('widget', {
sitekey: '${_js(siteKey)}',
callback: function(token) { solved(token); },
'expired-callback': function() { expired(); },
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
<script>
''',
);
}
String _buildCapHtml({
required String instanceUrl,
required String siteKey,
String? assetServer,
}) {
final endpoint = _capEndpoint(instanceUrl, siteKey);
final scriptUrl = _capWidgetScript(assetServer);
final safeEndpoint = const HtmlEscape().convert(endpoint);
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
return _baseHtml(
title: 'Cap',
body:
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
script:
'''
window.CAP_DISABLE_HAPTICS = true;
const cap = document.getElementById('cap');
if (cap) {
cap.addEventListener('solve', function(e) {
solved(e.detail && e.detail.token ? e.detail.token : '');
});
cap.addEventListener('progress', function(e) {
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
markStatus('正在验证... ' + progress, false);
sendBridge({ type: 'progress', progress: progress });
});
cap.addEventListener('error', function(e) {
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
failed(message);
});
markStatus('请完成人机验证', false);
}
</script>
<script type="module" src="$safeScriptUrl"></script>
<script>
''',
);
}
String _buildErrorHtml(String message) {
return _baseHtml(
title: '验证码错误',
body:
'<div id="widget" class="error">${const HtmlEscape().convert(message)}</div>',
script: 'failed(${jsonEncode(message)});',
);
}
String _capEndpoint(String instanceUrl, String siteKey) {
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
return '$trimmedInstance/$trimmedSiteKey/';
}
String _capWidgetScript(String? assetServer) {
final asset = assetServer?.trim();
if (asset != null && asset.isNotEmpty) {
if (asset.startsWith('http://') || asset.startsWith('https://')) {
return asset;
}
if (asset.toLowerCase() == 'jsdelivr') {
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
if (asset.toLowerCase() == 'unpkg') {
return 'https://unpkg.com/cap-widget';
}
}
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
String _js(String input) {
return input
.replaceAll(r'\\', r'\\\\')
.replaceAll("'", r"\\'")
.replaceAll('\\n', r'\\n')
.replaceAll('\\r', r'\\r');
}
// ═════════════════════════════════════════════════════
// Widget 构建
// ═════════════════════════════════════════════════════
@override
Widget build(BuildContext context) {
@@ -446,51 +555,173 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_isLoading) const Center(child: CircularProgressIndicator()),
_isDesktop ? _buildDesktopWebView() : _buildMobileWebView(),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
if (_errorMessage != null)
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
_buildOverlayBanner(
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
color: Theme.of(context).colorScheme.errorContainer,
)
else if (_statusText != null)
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
),
child: Text(_statusText!, textAlign: TextAlign.center),
),
_buildOverlayBanner(
child: Text(
_statusText!,
textAlign: TextAlign.center,
),
),
],
),
);
}
// ── 移动端 WebView ──────────────────────────────────
Widget _buildMobileWebView() {
return mobile.WebViewWidget(controller: _mobileController!);
}
// ── 桌面端 WebView ──────────────────────────────────
Widget _buildDesktopWebView() {
AppLogger.i('WebView 验证码 BaseUrl: ${WebUri(widget.baseUrl)}');
return InAppWebView(
key: _desktopKey,
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
safeBrowsingEnabled: true,
isInspectable: true,
cacheMode: CacheMode.LOAD_NO_CACHE,
supportMultipleWindows: true,
allowUniversalAccessFromFileURLs: true,
allowFileAccessFromFileURLs: true,
userAgent: _desktopUserAgent,
transparentBackground: true,
supportZoom: false,
useHybridComposition: true,
),
onWebViewCreated: (controller) {
_desktopController = controller;
controller.addJavaScriptHandler(
handlerName: 'CaptchaBridge',
callback: (args) {
if (args.isNotEmpty) {
_handleBridgeMessage(args[0].toString());
}
},
);
controller.loadUrl(
urlRequest: URLRequest(
url: WebUri("${widget.baseUrl}/virtual_captcha.html"),
),
);
},
shouldInterceptRequest: (controller, request) async {
if (request.url.toString().contains('virtual_captcha.html')) {
AppLogger.i('黑魔法 -> 拦截成功,正在注入动态 HTML');
return WebResourceResponse(
contentType: 'text/html',
contentEncoding: 'utf-8',
data: Uint8List.fromList(utf8.encode(_currentHtml)),
statusCode: 200,
reasonPhrase: 'OK',
);
}
if (request.url.toString().contains('/h/b/rc') && request.method == 'POST') {
AppLogger.w('发现黑魔法后遗症校验请求 (POST),执行强制 404');
await Future.delayed(Duration(seconds: 2));
return WebResourceResponse(
contentType: 'text/plain',
statusCode: 404,
reasonPhrase: 'Not Found',
data: Uint8List(0),
);
}
return null;
},
onLoadStart: (controller, url) {},
onLoadStop: (controller, url) {
if (mounted) setState(() => _isLoading = false);
},
onProgressChanged: (controller, progress) {
if (mounted) setState(() => _progress = progress);
},
onReceivedError: (controller, request, error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = '${error.type}: ${error.description}'.trim();
});
}
},
);
}
// ── 通用底部提示条 ──────────────────────────────────
Widget _buildOverlayBanner({required Widget child, Color? color}) {
return Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color ??
Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(12),
),
child: child,
),
),
);
}
// ═════════════════════════════════════════════════════
// 辅助
// ═════════════════════════════════════════════════════
String _capEndpoint(String instanceUrl, String siteKey) {
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
return '$trimmedInstance/$trimmedSiteKey/';
}
String _capWidgetScript(String? assetServer) {
final asset = assetServer?.trim();
if (asset != null && asset.isNotEmpty) {
if (asset.startsWith('http://') || asset.startsWith('https://')) {
return asset;
}
if (asset.toLowerCase() == 'jsdelivr') {
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
if (asset.toLowerCase() == 'unpkg') {
return 'https://unpkg.com/cap-widget';
}
}
return 'https://cdn.jsdelivr.net/npm/cap-widget';
}
String _js(String input) {
return input
.replaceAll(r'\', r'\\')
.replaceAll("'", r"\'")
.replaceAll('\n', r'\n')
.replaceAll('\r', r'\r');
}
}
@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../core/utils/date_utils.dart' as date_utils;
import '../../../core/utils/file_type_utils.dart';
import '../../../data/models/file_model.dart';
import '../../../router/app_router.dart';
import '../../../services/file_service.dart';
import '../../providers/file_manager_provider.dart';
import '../../widgets/file_info_dialog.dart';
import '../../widgets/file_operation_dialogs.dart';
import '../../widgets/selection_toolbar.dart';
import '../../widgets/thumbnail_image.dart';
import '../../widgets/toast_helper.dart';
@@ -46,23 +52,34 @@ class CategoryFilesPageArgs {
class CategoryFilesPage extends StatefulWidget {
final CategoryFilesPageArgs args;
const CategoryFilesPage({super.key, required this.args});
const CategoryFilesPage({
super.key,
required this.args,
});
@override
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
}
class _CategoryFilesPageState extends State<CategoryFilesPage> {
class _CategoryFilesPageState extends State<CategoryFilesPage>
with TickerProviderStateMixin {
final _fileService = FileService();
final _scrollController = ScrollController();
final List<FileModel> _files = [];
final Set<String> _selectedFilePaths = <String>{};
String? _nextPageToken;
String? _contextHint;
bool _isLoading = true;
bool _isLoadingMore = false;
String? _errorMessage;
bool get _hasSelection => _selectedFilePaths.isNotEmpty;
List<FileModel> get _selectedFiles => _files
.where((file) => _selectedFilePaths.contains(file.path))
.toList(growable: false);
@override
void initState() {
super.initState();
@@ -74,6 +91,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.args.category != widget.args.category) {
_clearSelection();
_loadFiles(refresh: true);
}
}
@@ -102,6 +120,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
_nextPageToken = null;
_contextHint = null;
_files.clear();
_selectedFilePaths.clear();
});
} else {
setState(() {
@@ -118,8 +137,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
);
final filesData = response['files'] as List<dynamic>? ?? const [];
final pagination =
response['pagination'] as Map<String, dynamic>? ?? const {};
final pagination = response['pagination'] as Map<String, dynamic>? ?? const {};
final newFiles = filesData
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
.where((file) => file.isFile)
@@ -134,9 +152,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
..addAll(newFiles);
} else {
final existingIds = _files.map((e) => e.id).toSet();
_files.addAll(
newFiles.where((file) => !existingIds.contains(file.id)),
);
_files.addAll(newFiles.where((file) => !existingIds.contains(file.id)));
}
_nextPageToken = pagination['next_token'] as String?;
@@ -156,22 +172,149 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
Future<void> _refresh() => _loadFiles(refresh: true);
void _toggleSelection(FileModel file) {
HapticFeedback.selectionClick();
setState(() {
if (_selectedFilePaths.contains(file.path)) {
_selectedFilePaths.remove(file.path);
} else {
_selectedFilePaths.add(file.path);
}
});
}
void _clearSelection() {
if (_selectedFilePaths.isEmpty) return;
setState(_selectedFilePaths.clear);
}
void _selectAllVisible() {
setState(() {
_selectedFilePaths
..clear()
..addAll(_files.map((file) => file.path));
});
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_hasSelection,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _hasSelection) {
_clearSelection();
}
},
child: Scaffold(
appBar: _buildAppBar(context),
body: RefreshIndicator(
onRefresh: _refresh,
child: _buildBody(context),
),
bottomNavigationBar: _buildSelectionBottomBar(context),
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
final args = widget.args;
return Scaffold(
appBar: AppBar(
title: Text(args.title),
if (_hasSelection) {
return AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(LucideIcons.x),
tooltip: '取消选择',
onPressed: _clearSelection,
),
centerTitle: true,
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新',
onPressed: _refresh,
TextButton(
onPressed: _selectAllVisible,
child: const Text('全选'),
),
],
);
}
return AppBar(
title: Text(args.title),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新',
onPressed: _refresh,
),
],
);
}
Widget _buildSelectionBottomBar(BuildContext context) {
final selected = _selectedFiles;
final singleSelected = selected.length == 1 ? selected.first : null;
return AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
...previousChildren,
?currentChild,
],
);
},
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
final slide = Tween<Offset>(
begin: const Offset(0, 0.10),
end: Offset.zero,
).animate(curved);
return FadeTransition(
opacity: curved,
child: SlideTransition(position: slide, child: child),
);
},
child: _hasSelection
? SelectionToolbar(
key: const ValueKey('category-selection-toolbar'),
selectionCount: _selectedFilePaths.length,
totalCount: _files.length,
onCancel: _clearSelection,
onSelectAll: _selectAllVisible,
onMore: singleSelected == null
? null
: () => _showSelectionMore(context, singleSelected),
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
true,
),
onDelete: () => _deleteSelectedFiles(context, selected),
)
: const SizedBox.shrink(
key: ValueKey('category-selection-toolbar-empty'),
),
),
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
);
}
@@ -216,7 +359,11 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon(widget.args.icon, size: 52, color: widget.args.color),
Icon(
widget.args.icon,
size: 52,
color: widget.args.color,
),
const SizedBox(height: 12),
Center(
child: Text(
@@ -236,7 +383,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
final columnWidth =
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
columnCount;
columnCount;
final columns = List.generate(columnCount, (_) => <FileModel>[]);
final heights = List.generate(columnCount, (_) => 0.0);
@@ -244,8 +391,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
for (final file in _files) {
final targetIndex = _indexOfMin(heights);
columns[targetIndex].add(file);
heights[targetIndex] +=
_estimatedTileHeight(file, columnWidth) + spacing;
heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing;
}
return ListView(
@@ -255,7 +401,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
horizontalPadding,
10,
horizontalPadding,
24,
_hasSelection ? 92 : 24,
),
children: [
_buildSummaryHeader(context),
@@ -271,11 +417,22 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: _CategoryFileTile(
key: ValueKey('category-file-${file.id.isNotEmpty ? file.id : file.path}'),
file: file,
contextHint: _contextHint,
category: widget.args.category,
accentColor: widget.args.color,
onTap: () => _openFile(context, file),
isSelected: _selectedFilePaths.contains(file.path),
selectionMode: _hasSelection,
onTap: () {
if (_hasSelection) {
_toggleSelection(file);
} else {
_openFile(context, file);
}
},
onLongPress: () => _toggleSelection(file),
onSelect: () => _toggleSelection(file),
),
),
],
@@ -379,19 +536,147 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
}
}
Future<void> _deleteSelectedFiles(
BuildContext context,
List<FileModel> selectedFiles,
) async {
if (selectedFiles.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除确认'),
content: Text('确定删除这 ${selectedFiles.length} 个文件吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _fileService.deleteFiles(
uris: selectedFiles.map((file) => file.path).toList(),
);
if (!mounted) return;
setState(() {
final selectedPaths = selectedFiles.map((file) => file.path).toSet();
_files.removeWhere((file) => selectedPaths.contains(file.path));
_selectedFilePaths.clear();
});
ToastHelper.success('删除成功');
} catch (e) {
if (context.mounted) {
ToastHelper.failure('删除失败: $e');
}
}
}
Future<void> _renameFile(BuildContext context, FileModel file) async {
final controller = TextEditingController(text: file.name);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('重命名'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '新名称',
prefixIcon: Icon(LucideIcons.edit3, size: 20),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(dialogContext).pop(true),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('确定'),
),
],
),
);
final newName = controller.text.trim();
if (confirmed != true || newName.isEmpty || newName == file.name) return;
try {
final response = await _fileService.renameFile(
uri: file.path,
newName: newName,
);
if (!mounted) return;
if (response.isEmpty) {
await _refresh();
} else {
final updatedFile = FileModel.fromJson(response);
setState(() {
final index = _files.indexWhere((item) => item.path == file.path);
if (index != -1) _files[index] = updatedFile;
_selectedFilePaths
..remove(file.path)
..add(updatedFile.path);
});
}
ToastHelper.success('重命名成功');
} catch (e) {
if (context.mounted) {
ToastHelper.failure('重命名失败: $e');
}
}
}
void _showSelectionMore(BuildContext context, FileModel file) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
_renameFile(context, file);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('查看详情'),
onTap: () {
Navigator.of(sheetContext).pop();
FileInfoPanel.showAsBottomSheet(context, file);
},
),
],
),
),
);
}
}
class _CategoryFileTile extends StatelessWidget {
@@ -399,14 +684,23 @@ class _CategoryFileTile extends StatelessWidget {
final String? contextHint;
final String category;
final Color accentColor;
final bool isSelected;
final bool selectionMode;
final VoidCallback onTap;
final VoidCallback onLongPress;
final VoidCallback onSelect;
const _CategoryFileTile({
super.key,
required this.file,
required this.contextHint,
required this.category,
required this.accentColor,
required this.isSelected,
required this.selectionMode,
required this.onTap,
required this.onLongPress,
required this.onSelect,
});
@override
@@ -419,73 +713,128 @@ class _CategoryFileTile extends StatelessWidget {
final ext = FileTypeUtils.getExtension(file.name);
final isPsd = ext == 'psd' || ext == 'psb';
return Material(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor.withValues(alpha: 0.12),
final borderColor = isSelected
? theme.colorScheme.primary
: theme.dividerColor.withValues(alpha: 0.12);
final showSelectionCircle = selectionMode || isSelected;
return RepaintBoundary(
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
scale: isSelected ? 0.985 : 1.0,
child: Material(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: isMedia || isPsd ? 1 : 1.45,
child: Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: ThumbnailImage(
file: file,
contextHint: contextHint,
borderRadius: 0,
),
),
Positioned(
top: 7,
left: 7,
child: _TypeBadge(
icon: _badgeIcon(),
label: _badgeLabel(ext),
color: accentColor,
compact: isMedia,
),
),
if (category == 'video')
const Center(
child: _PlayOverlay(),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
maxLines: isAudio || isDocument ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
Positioned.fill(
child: IgnorePointer(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: borderColor,
width: isSelected ? 2.2 : 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary
.withValues(alpha: 0.12),
blurRadius: 10,
spreadRadius: 0.5,
),
]
: const [],
),
),
),
),
Positioned(
top: 7,
right: 7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 130),
opacity: showSelectionCircle ? 1 : 0,
child: IgnorePointer(
ignoring: !showSelectionCircle,
child: _SelectionCircle(
selected: isSelected,
onTap: onSelect,
),
),
),
),
],
),
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: isMedia || isPsd ? 1 : 1.45,
child: Stack(
fit: StackFit.expand,
children: [
ThumbnailImage(
file: file,
contextHint: contextHint,
borderRadius: 0,
),
Positioned(
top: 7,
left: 7,
child: _TypeBadge(
icon: _badgeIcon(),
label: _badgeLabel(ext),
color: accentColor,
compact: isMedia,
),
),
if (category == 'video')
const Center(child: _PlayOverlay()),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
maxLines: isAudio || isDocument ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
),
),
@@ -524,6 +873,60 @@ class _CategoryFileTile extends StatelessWidget {
}
}
class _SelectionCircle extends StatelessWidget {
final bool selected;
final VoidCallback? onTap;
const _SelectionCircle({
required this.selected,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.86),
border: Border.all(
color: selected
? colorScheme.primary
: colorScheme.outline.withValues(alpha: 0.42),
width: 1.4,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: selected
? const Icon(
LucideIcons.check,
color: Colors.white,
size: 16,
)
: null,
),
),
);
}
}
class _TypeBadge extends StatelessWidget {
final IconData icon;
final String label;
@@ -545,7 +948,10 @@ class _TypeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(9),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 7,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -580,7 +986,11 @@ class _PlayOverlay extends StatelessWidget {
),
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
child: Icon(
LucideIcons.play,
color: Colors.white,
size: 22,
),
),
);
}
+136 -216
View File
@@ -56,10 +56,7 @@ class _FilesPageState extends State<FilesPage> {
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= 1000) {
fileManager.setViewType(FileViewType.grid);
@@ -70,10 +67,7 @@ class _FilesPageState extends State<FilesPage> {
fileManager.loadFiles();
_isFirstLoad = false;
}
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
downloadManager.initialize();
}
});
@@ -81,13 +75,8 @@ class _FilesPageState extends State<FilesPage> {
// 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final normalizedCurrent = FileUtils.toCloudreveUri(
fileManager.currentPath,
);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName'
@@ -112,6 +101,38 @@ class _FilesPageState extends State<FilesPage> {
});
}
void _showSelectionMore(
FileModel file,
FileManagerProvider fileManager,
) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
FileOperationDialogs.showRenameDialog(context, fileManager, file);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('查看详情'),
onTap: () {
Navigator.of(sheetContext).pop();
_showFileInfo(file);
},
),
],
),
),
);
}
// ---- FAB 显隐控制 ----
void _hideFab() {
@@ -188,14 +209,22 @@ class _FilesPageState extends State<FilesPage> {
);
}
/// 循环解码路径段,处理多重 URL 编码(如 %25E4%25B8%25AD → 中文)
String _decodePathSegment(String segment) {
return FileUtils.safeDecodePathSegment(segment);
var decoded = segment;
for (var i = 0; i < 5; i++) {
try {
final next = Uri.decodeComponent(decoded);
if (next == decoded) break;
decoded = next;
} catch (_) {
break;
}
}
return decoded;
}
Widget _buildDesktopBreadcrumb(
BuildContext context,
FileManagerProvider fileManager,
) {
Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/');
@@ -205,9 +234,7 @@ class _FilesPageState extends State<FilesPage> {
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => fileManager.currentPath != '/'
? fileManager.enterFolder('/')
: null,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
@@ -217,18 +244,12 @@ class _FilesPageState extends State<FilesPage> {
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),
),
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);
}
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
},
borderRadius: BorderRadius.circular(6),
child: Padding(
@@ -236,12 +257,8 @@ class _FilesPageState extends State<FilesPage> {
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,
color: i == pathParts.length - 1 ? colorScheme.onSurface : colorScheme.primary,
fontWeight: i == pathParts.length - 1 ? FontWeight.w600 : FontWeight.normal,
),
),
),
@@ -251,10 +268,7 @@ class _FilesPageState extends State<FilesPage> {
);
}
Widget _buildMobileBreadcrumb(
BuildContext context,
FileManagerProvider fileManager,
) {
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/');
@@ -263,6 +277,7 @@ class _FilesPageState extends State<FilesPage> {
return SizedBox(
height: 40,
child: ListView(
key: const PageStorageKey('mobile_breadcrumb'),
scrollDirection: Axis.horizontal,
children: [
_buildBreadcrumbChip(
@@ -270,18 +285,12 @@ class _FilesPageState extends State<FilesPage> {
label: '文件',
icon: LucideIcons.home,
color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/'
? fileManager.enterFolder('/')
: null,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
),
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),
),
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
),
_buildBreadcrumbChip(
context,
@@ -291,9 +300,7 @@ class _FilesPageState extends State<FilesPage> {
isLast: i == pathParts.length - 1,
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) {
fileManager.enterFolder(targetPath);
}
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
},
),
],
@@ -317,9 +324,7 @@ class _FilesPageState extends State<FilesPage> {
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: isLast
? color.withValues(alpha: 0.15)
: color.withValues(alpha: 0.06),
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
),
child: Row(
@@ -353,9 +358,7 @@ class _FilesPageState extends State<FilesPage> {
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return IconButton(
icon: Icon(
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
),
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
onPressed: () => fileManager.refreshFiles(),
tooltip: '刷新',
);
@@ -375,19 +378,14 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
);
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
FileOperationDialogs.showCreateDialog(context, fileManager);
},
tooltip: '新建',
@@ -399,8 +397,7 @@ class _FilesPageState extends State<FilesPage> {
),
IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () =>
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载',
),
];
@@ -422,9 +419,7 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
);
},
),
@@ -476,16 +471,8 @@ class _FilesPageState extends State<FilesPage> {
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
_onFabSubAction(
() => FileOperationDialogs.showCreateDialog(
context,
fileManager,
),
);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
},
),
_buildFabSubItem(
@@ -495,10 +482,7 @@ class _FilesPageState extends State<FilesPage> {
label: '离线下载',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(
() =>
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, _) {
@@ -584,10 +568,7 @@ class _FilesPageState extends State<FilesPage> {
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
@@ -668,8 +649,7 @@ class _FilesPageState extends State<FilesPage> {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context);
if (!isDesktop ||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child;
}
@@ -693,19 +673,13 @@ class _FilesPageState extends State<FilesPage> {
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.85),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.upload,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
Text(
'释放文件以上传到当前目录',
@@ -735,14 +709,8 @@ class _FilesPageState extends State<FilesPage> {
}
if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
uploadManager.markShouldShowDialog();
uploadManager.startUpload(files, fileManager.currentPath);
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
@@ -772,19 +740,12 @@ class _FilesPageState extends State<FilesPage> {
);
}
Widget _buildErrorView(
BuildContext context,
FileManagerProvider fileManager,
) {
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text(
fileManager.errorMessage!,
@@ -832,12 +793,13 @@ class _FilesPageState extends State<FilesPage> {
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: ListView.builder(
key: PageStorageKey('files_list_${fileManager.currentPath}'),
cacheExtent: 900,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: fileManager.files.length,
itemBuilder: (context, index) {
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(
file.path,
);
final isSelected = fileManager.selectedFiles.contains(file.path);
return FileListItem(
key: ValueKey('file_${file.id}'),
@@ -859,37 +821,14 @@ class _FilesPageState extends State<FilesPage> {
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder
? () => _downloadFile(context, fileManager, file)
: null,
onOpenInBrowser: !file.isFolder
? () => _openInBrowser(context, file)
: null,
onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () =>
FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
onShare: () => FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
onInfo: () => _showFileInfo(file),
);
},
@@ -918,8 +857,7 @@ class _FilesPageState extends State<FilesPage> {
crossAxisCount = 5;
}
final itemWidth =
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 160;
final showCheckbox = fileManager.hasSelection;
@@ -928,6 +866,9 @@ class _FilesPageState extends State<FilesPage> {
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: GridView.builder(
key: PageStorageKey('files_grid_${fileManager.currentPath}'),
cacheExtent: 1100,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
@@ -959,36 +900,14 @@ class _FilesPageState extends State<FilesPage> {
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder
? () => _downloadFile(context, fileManager, file)
: null,
onOpenInBrowser: !file.isFolder
? () => _openInBrowser(context, file)
: null,
onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
onShare: () => FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
onInfo: () => _showFileInfo(file),
);
},
@@ -1006,33 +925,34 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.hasSelection) {
return SelectionToolbar(
selectionCount: fileManager.selectedFiles.length,
totalCount: fileManager.files.length,
onCancel: () => fileManager.clearSelection(),
onRename: fileManager.selectedFiles.length == 1
? () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
)
onSelectAll: () => fileManager.selectAll(),
onMore: fileManager.selectedFiles.length == 1
? () => _showSelectionMore(
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
fileManager,
)
: null,
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
false,
),
context,
fileManager,
fileManager.selectedFiles,
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
true,
),
context,
fileManager,
fileManager.selectedFiles,
true,
),
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
context,
fileManager,
fileManager.selectedFiles,
),
context,
fileManager,
fileManager.selectedFiles,
),
);
}
@@ -1056,17 +976,11 @@ class _FilesPageState extends State<FilesPage> {
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
}
}
@@ -1075,10 +989,7 @@ class _FilesPageState extends State<FilesPage> {
FileManagerProvider fileManager,
FileModel file,
) async {
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
final task = await downloadManager.addDownloadTask(
fileName: file.name,
fileUri: file.relativePath,
@@ -1124,4 +1035,13 @@ class _FilesPageState extends State<FilesPage> {
}
}
}
void _openInCloudreveApp(BuildContext context, FileModel file) {
Navigator.of(context).pushNamed(
RouteNames.cloudreveFileApp,
arguments: {
'file': file,
},
);
}
}
@@ -0,0 +1,351 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:webview_flutter/webview_flutter.dart' as mobile;
import '../../../data/models/file_model.dart';
import '../../../services/api_service.dart';
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
/// 完全交给 Cloudreve 官方 Web 前端处理文件打开。
///
/// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。
/// Cloudreve 前端本身会根据 `/home?path=...&open=...` 打开对应文件,
/// 并使用它自己的文件应用、WOPI、Markdown、表格、压缩包、EPUB 等逻辑。
class CloudreveFileAppPage extends StatefulWidget {
final FileModel file;
final String? preferredAction;
const CloudreveFileAppPage({
super.key,
required this.file,
this.preferredAction,
});
@override
State<CloudreveFileAppPage> createState() => _CloudreveFileAppPageState();
}
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
bool _isPreparing = true;
bool _sessionInjected = false;
int _progress = 0;
String? _error;
late Uri _targetUri;
late Uri _originUri;
String? _sessionStateJson;
@override
void initState() {
super.initState();
if (!_useDesktopWebView) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
mobile.NavigationDelegate(
onProgress: (progress) {
if (!mounted) return;
setState(() => _progress = progress);
},
onPageFinished: (_) => _injectSessionAndOpenIfNeeded(),
onWebResourceError: (error) {
if (!mounted) return;
if (error.isForMainFrame == true) {
setState(() {
_error = '${error.errorCode}: ${error.description}';
});
}
},
),
);
}
_prepareAndOpen();
}
@override
void dispose() {
_desktopController?.dispose();
super.dispose();
}
Future<void> _prepareAndOpen() async {
setState(() {
_isPreparing = true;
_error = null;
_progress = 0;
_sessionInjected = false;
if (_useDesktopWebView) {
_desktopController = null;
}
});
try {
_originUri = _buildOriginUri();
_targetUri = _buildCloudreveHomeUri();
_sessionStateJson = await _buildCloudreveFrontendSessionJson();
// 先打开同源页面,再注入 localStorage。
// 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。
await _loadOrigin();
if (!mounted) return;
setState(() => _isPreparing = false);
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isPreparing = false;
});
}
}
Uri _buildOriginUri() {
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
return Uri(
scheme: base.scheme,
host: base.host,
port: base.hasPort ? base.port : null,
path: '/',
);
}
Uri _buildCloudreveHomeUri() {
final parent = _parentUri(widget.file.relativePath);
final openTarget = widget.file.id.isNotEmpty
? widget.file.id
: widget.file.relativePath;
return Uri(
scheme: _originUri.scheme,
host: _originUri.host,
port: _originUri.hasPort ? _originUri.port : null,
path: '/home',
queryParameters: {
'path': parent,
'open': openTarget,
'size': widget.file.size.toString(),
},
);
}
String _parentUri(String uri) {
final normalized = uri.endsWith('/')
? uri.substring(0, uri.length - 1)
: uri;
final index = normalized.lastIndexOf('/');
if (index <= 'cloudreve://my'.length) {
return 'cloudreve://my';
}
return normalized.substring(0, index);
}
Future<String?> _buildCloudreveFrontendSessionJson() async {
final tokenGetter = ApiService.instance.getTokenCallback;
final token = tokenGetter == null ? null : await tokenGetter();
if (token == null || token.isEmpty) {
return null;
}
Map<String, dynamic>? user;
try {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/me',
);
user = Map<String, dynamic>.from(response);
} catch (_) {
user = null;
}
final userId = user?['id']?.toString() ?? user?['uid']?.toString() ?? 'app';
final now = DateTime.now().toUtc();
final accessExpires = now.add(const Duration(hours: 2)).toIso8601String();
final refreshExpires = now.add(const Duration(hours: 2)).toIso8601String();
final sessionState = {
'current': userId,
'sessions': {
userId: {
'user': user ?? {'id': userId},
'token': {
'access_token': token,
// App 侧暂时没有把 refresh token 暴露给这里。
// 给 Cloudreve 前端一个短期可用 session;过期后 WebView 内刷新会要求重新登录。
'refresh_token': '',
'access_expires': accessExpires,
'refresh_expires': refreshExpires,
},
'settings': {},
},
},
'anonymousSettings': {},
};
return jsonEncode(sessionState);
}
Future<void> _injectSessionAndOpenIfNeeded() async {
if (_sessionInjected) return;
_sessionInjected = true;
final sessionJson = _sessionStateJson;
final target = _targetUri.toString();
try {
if (sessionJson != null) {
final script =
'''
try {
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
} catch (e) {}
window.location.replace(${jsonEncode(target)});
''';
await _runJavaScript(script);
} else {
await _loadTarget();
}
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _openTargetAgain() async {
_sessionInjected = true;
await _loadTarget();
}
Future<void> _reloadWebView() async {
if (_useDesktopWebView) {
await _desktopController?.reload();
} else {
await _mobileController?.reload();
}
}
Future<void> _loadOrigin() async {
if (_useDesktopWebView) {
final controller = _desktopController;
if (controller == null) return;
await _desktopController?.loadUrl(
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_originUri)),
);
} else {
await _mobileController!.loadRequest(_originUri);
}
}
Future<void> _loadTarget() async {
if (_useDesktopWebView) {
await _desktopController?.loadUrl(
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)),
);
} else {
await _mobileController!.loadRequest(_targetUri);
}
}
Future<void> _runJavaScript(String source) async {
if (_useDesktopWebView) {
await _desktopController?.evaluateJavascript(source: source);
} else {
await _mobileController!.runJavaScript(source);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.file.name, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: '重新打开',
icon: const Icon(Icons.open_in_browser),
onPressed: _openTargetAgain,
),
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh),
onPressed: _reloadWebView,
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(3),
child: _progress > 0 && _progress < 100
? LinearProgressIndicator(value: _progress / 100)
: const SizedBox(height: 3),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isPreparing) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 56, color: Colors.red),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _prepareAndOpen,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
);
}
if (_useDesktopWebView) {
return desktop.InAppWebView(
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
supportZoom: false,
),
onWebViewCreated: (controller) async {
_desktopController = controller;
if (!_isPreparing) {
await _loadOrigin();
}
},
onProgressChanged: (_, progress) {
if (!mounted) return;
setState(() => _progress = progress);
},
onLoadStop: (_, url) => _injectSessionAndOpenIfNeeded(),
onReceivedError: (_, request, error) {
if (!mounted || request.isForMainFrame != true) return;
setState(() => _error = '${error.type}: ${error.description}');
},
);
}
return mobile.WebViewWidget(controller: _mobileController!);
}
}
@@ -23,45 +23,26 @@ class QuickFunctionsSection extends StatelessWidget {
const QuickFunctionsSection({super.key});
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.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) {
final nav = ctx.read<NavigationProvider>();
// 桌面端有同步 Tab(index 4),直接切换;移动端跳转同步设置页
final isDesktop = defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
if (isDesktop) {
nav.setIndex(4);
} else {
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
return;
}
ctx.read<NavigationProvider>().setIndex(3);
},
),
_QuickFunction(
icon: LucideIcons.settings,
label: '设置',
route: RouteNames.settings,
),
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
];
static const double _spacing = 12;
@@ -82,12 +63,9 @@ 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)),
],
),
),
@@ -101,8 +79,7 @@ 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,
@@ -155,7 +132,9 @@ 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),
@@ -930,7 +930,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
(Level.error, 'Error — 仅错误'),
(Level.warning, 'Warning — 错误 + 警告'),
(Level.info, 'Info — 常规信息'),
(Level.debug, 'Debug — 调试信息(含FFI交互'),
(Level.debug, 'Debug — 调试信息(含FFI'),
(Level.trace, 'Trace — 全量追踪'),
];
@@ -950,7 +950,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
),
const SizedBox(width: 8),
Text(e.$2),
Flexible(child: Text(e.$2)),
],
),
);
File diff suppressed because it is too large Load Diff
+98 -1
View File
@@ -4,11 +4,17 @@ import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.
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/announcement_dialog.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:cloudreve4_flutter/services/announcement_service.dart';
import 'package:cloudreve4_flutter/services/dialog_queue_service.dart';
import 'package:cloudreve4_flutter/services/share_link_service.dart';
import 'package:cloudreve4_flutter/presentation/pages/share/share_link_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../router/app_router.dart';
@@ -47,9 +53,10 @@ class AppShell extends StatefulWidget {
}
class _AppShellState extends State<AppShell>
with GestureHandlerMixin, TickerProviderStateMixin {
with GestureHandlerMixin, TickerProviderStateMixin, WidgetsBindingObserver {
final Set<int> _visitedPageIndexes = <int>{0};
late AnimationController _syncSpinController;
String? _lastClipboardShareId;
bool get _showSyncTab =>
defaultTargetPlatform != TargetPlatform.android &&
@@ -84,14 +91,104 @@ class _AppShellState extends State<AppShell>
vsync: this,
duration: const Duration(seconds: 2),
);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
_showPostLoginAnnouncement();
_checkClipboardShareLink();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_syncSpinController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkClipboardShareLink();
}
}
Future<void> _showPostLoginAnnouncement() async {
final authProvider = context.read<AuthProvider>();
if (!authProvider.isAuthenticated) return;
try {
final service = AnnouncementService.instance;
final announcement = await service.getChangedSiteNotice();
if (!mounted || announcement == null) return;
await DialogQueueService.instance.enqueue<void>(() async {
if (!mounted) return;
await AnnouncementDialog.show(
context,
title: announcement.title,
html: announcement.html,
baseUrl: announcement.baseUrl,
);
await service.markDismissed(announcement);
});
} catch (_) {
// Announcement checks should never block the shell.
}
}
Future<void> _checkClipboardShareLink() async {
await Future<void>.delayed(const Duration(milliseconds: 650));
if (!mounted) return;
try {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final candidate = ShareLinkService.instance.parseShareLink(data?.text);
if (candidate == null) return;
if (_lastClipboardShareId == candidate.id) return;
_lastClipboardShareId = candidate.id;
await DialogQueueService.instance.enqueue<void>(() async {
if (!mounted) return;
final open = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('检测到分享链接'),
content: Text(
'是否打开这个文件分享?\n\n${candidate.url}',
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('忽略'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('打开'),
),
],
),
);
if (open == true && mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ShareLinkPage(candidate: candidate),
),
);
}
});
} catch (_) {
// Clipboard access failures should not affect the shell.
}
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
@@ -226,7 +226,7 @@ class FileManagerProvider extends ChangeNotifier {
/// 移动文件(增量更新)
Future<String?> moveFiles(List<String> uris, String destination, {bool copy = false}) async {
try {
await FileService().moveFiles(uris: uris, dst: destination);
await FileService().moveFiles(uris: uris, dst: destination, copy: copy);
clearSelection();
if (!copy) {
@@ -0,0 +1,209 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
import 'package:webview_flutter/webview_flutter.dart' as mobile;
bool get _useDesktopWebView =>
!kIsWeb && (Platform.isWindows || Platform.isLinux);
class AnnouncementDialog extends StatefulWidget {
final String title;
final String html;
final String baseUrl;
const AnnouncementDialog({
super.key,
required this.title,
required this.html,
required this.baseUrl,
});
static Future<void> show(
BuildContext context, {
required String title,
required String html,
required String baseUrl,
}) async {
if (html.trim().isEmpty) return;
await showDialog<void>(
context: context,
barrierDismissible: true,
builder: (_) =>
AnnouncementDialog(title: title, html: html, baseUrl: baseUrl),
);
}
@override
State<AnnouncementDialog> createState() => _AnnouncementDialogState();
}
class _AnnouncementDialogState extends State<AnnouncementDialog> {
mobile.WebViewController? _mobileController;
desktop.InAppWebViewController? _desktopController;
late final String _html;
late final String _baseUrl;
bool _loading = true;
@override
void initState() {
super.initState();
final origin = Uri.parse(widget.baseUrl).origin;
_html = _wrapHtml(widget.html);
_baseUrl = '$origin/';
if (!_useDesktopWebView) {
_mobileController = mobile.WebViewController()
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
mobile.NavigationDelegate(
onPageFinished: (_) {
if (mounted) setState(() => _loading = false);
},
),
)
..loadHtmlString(_html, baseUrl: _baseUrl);
}
}
@override
void dispose() {
_desktopController?.dispose();
super.dispose();
}
String _wrapHtml(String body) {
final encodedTitle = const HtmlEscape().convert(widget.title);
return '''
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>$encodedTitle</title>
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
font-size: 14px;
line-height: 1.65;
overflow-wrap: anywhere;
}
body {
padding: 12px 14px 18px;
box-sizing: border-box;
}
img, video {
max-width: 100% !important;
height: auto !important;
border-radius: 12px;
}
a {
color: #2563eb;
text-decoration: none;
}
fieldset, section, div {
max-width: 100% !important;
box-sizing: border-box !important;
}
</style>
</head>
<body>
$body
</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height * 0.72,
child: Column(
children: [
Container(
padding: const EdgeInsets.fromLTRB(18, 14, 8, 10),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.45),
),
),
),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
IconButton(
tooltip: '关闭',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Expanded(
child: Stack(
children: [
_buildWebView(),
if (_loading)
const Center(child: CircularProgressIndicator()),
],
),
),
],
),
),
),
);
}
Widget _buildWebView() {
if (_useDesktopWebView) {
return desktop.InAppWebView(
initialData: desktop.InAppWebViewInitialData(
data: _html,
baseUrl: desktop.WebUri(_baseUrl),
),
initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
supportZoom: false,
),
onWebViewCreated: (controller) {
_desktopController = controller;
},
onLoadStop: (_, url) {
if (mounted) setState(() => _loading = false);
},
);
}
return mobile.WebViewWidget(controller: _mobileController!);
}
}
+73 -26
View File
@@ -17,6 +17,7 @@ class FileGridItem extends StatelessWidget {
final VoidCallback? onSelect;
final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser;
final VoidCallback? onOpenInCloudreveApp;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
@@ -38,6 +39,7 @@ class FileGridItem extends StatelessWidget {
this.onSelect,
this.onDownload,
this.onOpenInBrowser,
this.onOpenInCloudreveApp,
this.onRename,
this.onMove,
this.onCopy,
@@ -50,25 +52,27 @@ class FileGridItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Builder(
builder: (builderContext) => LayoutBuilder(
builder: (context, constraints) {
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
return RepaintBoundary(
child: Builder(
builder: (builderContext) => LayoutBuilder(
builder: (context, constraints) {
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
return _FileGridItemHover(
file: file,
isSelected: isSelected,
isHighlighted: isHighlighted,
showCheckbox: showCheckbox,
contextHint: contextHint,
fontSize: fontSize,
tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(builderContext),
onSelect: onSelect,
onMore: () => _showMenu(builderContext),
);
},
return _FileGridItemHover(
file: file,
isSelected: isSelected,
isHighlighted: isHighlighted,
showCheckbox: showCheckbox,
contextHint: contextHint,
fontSize: fontSize,
tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(builderContext),
onSelect: onSelect,
onMore: () => _showMenu(builderContext),
);
},
),
),
);
}
@@ -79,6 +83,7 @@ class FileGridItem extends StatelessWidget {
hasSelect: onSelect != null,
hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null,
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
hasRename: onRename != null,
hasMove: onMove != null,
hasCopy: onCopy != null,
@@ -95,6 +100,8 @@ class FileGridItem extends StatelessWidget {
onDownload?.call();
case FileMenuAction.openInBrowser:
onOpenInBrowser?.call();
case FileMenuAction.openInCloudreveApp:
onOpenInCloudreveApp?.call();
case FileMenuAction.rename:
onRename?.call();
case FileMenuAction.move:
@@ -318,10 +325,8 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
Widget _buildIconArea(BuildContext context) {
final file = widget.file;
final ext = FileUtils.getFileExtension(file.name);
final isThumbnailable = !file.isFolder
&& FileUtils.isImageFile(file.name)
&& ext != 'svg';
final isThumbnailable =
!file.isFolder && FileUtils.isThumbnailableFile(file.name);
if (!isThumbnailable) {
return Center(
@@ -335,10 +340,52 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
);
}
return ThumbnailImage(
file: file,
contextHint: widget.contextHint,
borderRadius: 10,
return Stack(
fit: StackFit.expand,
children: [
ThumbnailImage(
file: file,
contextHint: widget.contextHint,
borderRadius: 10,
),
if (FileUtils.isVideoFile(file.name))
Center(
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(17),
),
child: const Icon(
LucideIcons.play,
color: Colors.white,
size: 18,
),
),
),
if (FileUtils.isPsdFile(file.name))
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'PSD',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
),
],
);
}
}
+122 -177
View File
@@ -5,7 +5,6 @@ import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart' as date_utils;
import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_type_utils.dart';
import '../../core/utils/file_utils.dart';
import '../../services/file_service.dart';
import '../../router/app_router.dart';
import 'toast_helper.dart';
@@ -20,11 +19,58 @@ class FileInfoPanel extends StatefulWidget {
Scaffold.of(context).openEndDrawer();
}
/// 以 BottomSheet 方式展示文件详情
static void showAsBottomSheet(BuildContext context, FileModel file) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _FileInfoSheet(file: file),
);
}
@override
State<FileInfoPanel> createState() => _FileInfoPanelState();
}
class _FileInfoPanelState extends State<FileInfoPanel> {
@override
Widget build(BuildContext context) {
return Drawer(
child: FileInfoPanelContent(file: widget.file),
);
}
}
/// 以 BottomSheet 形式展示的文件详情
class _FileInfoSheet extends StatelessWidget {
final FileModel file;
const _FileInfoSheet({required this.file});
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return FileInfoPanelContent(file: file);
},
);
}
}
/// FileInfoPanel 的可复用内容区(不含 Drawer 壳)
class FileInfoPanelContent extends StatefulWidget {
final FileModel file;
const FileInfoPanelContent({super.key, required this.file});
@override
State<FileInfoPanelContent> createState() => _FileInfoPanelContentState();
}
class _FileInfoPanelContentState extends State<FileInfoPanelContent> {
FileInfoModel? _fileInfo;
bool _isLoading = true;
bool _isCalculatingFolder = false;
@@ -88,60 +134,53 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Drawer(
child: SafeArea(
right: false,
child: Column(
children: [
Container(
padding: const EdgeInsets.only(
left: 16,
right: 8,
top: 8,
bottom: 12,
return Column(
children: [
Container(
padding: const EdgeInsets.only(
left: 16,
right: 8,
top: 8,
bottom: 12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
),
),
child: Row(
children: [
FileIconUtils.buildIconWidget(
context: context,
file: widget.file,
size: 32,
iconSize: 18,
borderRadius: 8,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.2),
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.file.name,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
child: Row(
children: [
FileIconUtils.buildIconWidget(
context: context,
file: widget.file,
size: 32,
iconSize: 18,
borderRadius: 8,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.file.name,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
],
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
],
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildError(theme)
: _buildContent(theme, colorScheme),
),
],
),
),
],
);
}
@@ -150,21 +189,13 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.alertCircle,
size: 48,
color: theme.colorScheme.error,
),
Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 12),
Text('加载失败', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
_error ?? '',
style: TextStyle(color: theme.hintColor, fontSize: 12),
textAlign: TextAlign.center,
),
child: Text(_error ?? '', style: TextStyle(color: theme.hintColor, fontSize: 12), textAlign: TextAlign.center),
),
const SizedBox(height: 12),
FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')),
@@ -179,8 +210,10 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
? '文件夹'
: FileIconUtils.getFileTypeLabel(file.name);
final extendedInfo = _fileInfo?.extendedInfo;
final versionEntities =
extendedInfo?.entities?.where((e) => e.type == 0).toList() ?? [];
final versionEntities = extendedInfo?.entities
?.where((e) => e.type == 0)
.toList() ??
[];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
@@ -188,7 +221,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 类型标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
@@ -205,43 +237,23 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
),
),
const SizedBox(height: 16),
// 基本信息
_buildInfoRow(
LucideIcons.folderOpen,
'位置',
FileUtils.safeDecodePathSegment(file.relativePath),
),
_buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)),
if (file.isFile)
_buildInfoRow(
LucideIcons.hardDrive,
'大小',
date_utils.DateUtils.formatFileSize(file.size),
),
_buildInfoRow(
LucideIcons.calendarPlus,
'创建时间',
date_utils.DateUtils.formatDateTime(file.createdAt),
),
_buildInfoRow(
LucideIcons.calendar,
'修改时间',
date_utils.DateUtils.formatDateTime(file.updatedAt),
),
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(file.createdAt)),
_buildInfoRow(LucideIcons.calendar, '修改时间', date_utils.DateUtils.formatDateTime(file.updatedAt)),
if (file.owned != null)
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '' : ''),
// 文件扩展信息
if (file.isFile && extendedInfo != null) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8),
Text(
'扩展信息',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text('扩展信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
_buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id),
if (file.primaryEntity != null)
@@ -260,7 +272,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
),
],
// 版本历史
if (file.isFile && versionEntities.isNotEmpty) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
@@ -268,17 +279,11 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
_buildVersionSection(theme, colorScheme, versionEntities),
],
// 文件夹信息
if (file.isFolder) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8),
Text(
'文件夹信息',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text('文件夹信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
_buildFolderSummary(theme, colorScheme),
],
@@ -301,12 +306,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
children: [
Row(
children: [
Text(
'版本历史',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text('版本历史', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
@@ -354,9 +354,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
required bool isPreviewable,
required FileModel file,
}) {
final shortId = entity.id.length > 6
? entity.id.substring(0, 6)
: entity.id;
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
final createdBy = entity.createdBy?.nickname ?? '未知';
return Container(
@@ -378,32 +376,24 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
_buildVersionActionButton(
icon: LucideIcons.externalLink,
tooltip: '打开',
onPressed: _isVersionLoading
? null
: () => _openVersion(entity),
onPressed: _isVersionLoading ? null : () => _openVersion(entity),
),
_buildVersionActionButton(
icon: LucideIcons.download,
tooltip: '下载',
onPressed: _isVersionLoading
? null
: () => _downloadVersion(entity),
onPressed: _isVersionLoading ? null : () => _downloadVersion(entity),
),
if (!isCurrent) ...[
_buildVersionActionButton(
icon: LucideIcons.pin,
tooltip: '设为当前版本',
onPressed: _isVersionLoading
? null
: () => _setCurrentVersion(entity),
onPressed: _isVersionLoading ? null : () => _setCurrentVersion(entity),
),
_buildVersionActionButton(
icon: LucideIcons.trash2,
tooltip: '删除',
color: colorScheme.error,
onPressed: _isVersionLoading
? null
: () => _deleteVersion(entity),
onPressed: _isVersionLoading ? null : () => _deleteVersion(entity),
),
],
];
@@ -449,10 +439,8 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(height: 1),
Text(
'${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy',
style: const TextStyle(
fontSize: 11,
color: null,
).copyWith(color: theme.hintColor),
style: const TextStyle(fontSize: 11, color: null)
.copyWith(color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -497,26 +485,14 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(LucideIcons.hash, 'ID', entity.id),
_buildInfoRow(
LucideIcons.hardDrive,
'大小',
date_utils.DateUtils.formatFileSize(entity.size),
),
_buildInfoRow(
LucideIcons.calendarPlus,
'创建时间',
date_utils.DateUtils.formatDateTime(entity.createdAt),
),
_buildInfoRow(LucideIcons.hardDrive, '大小', date_utils.DateUtils.formatFileSize(entity.size)),
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(entity.createdAt)),
if (createdBy != null) ...[
_buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname),
_buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id),
],
if (entity.storagePolicy != null)
_buildInfoRow(
LucideIcons.server,
'存储策略',
'${entity.storagePolicy!.name} (${entity.storagePolicy!.type})',
),
_buildInfoRow(LucideIcons.server, '存储策略', '${entity.storagePolicy!.name} (${entity.storagePolicy!.type})'),
if (entity.encryptedWith != null)
_buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!),
],
@@ -548,8 +524,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
);
}
// ─── 版本操作 ───
void _openVersion(EntityModel entity) {
final file = _fileInfo!.file;
if (!FileTypeUtils.isPreviewable(file.name)) {
@@ -568,13 +542,9 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: args);
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: args);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: args);
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: args);
}
}
@@ -625,16 +595,12 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
Future<void> _deleteVersion(EntityModel entity) async {
final colorScheme = Theme.of(context).colorScheme;
final shortId = entity.id.length > 6
? entity.id.substring(0, 6)
: entity.id;
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除版本'),
content: Text(
'确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?',
),
content: Text('确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
@@ -669,8 +635,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
}
}
// ─── 文件夹摘要 ───
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
final summary = _fileInfo?.folderSummary;
@@ -679,29 +643,15 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
children: [
_buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'),
_buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'),
_buildInfoRow(
LucideIcons.hardDrive,
'总大小',
date_utils.DateUtils.formatFileSize(summary.size),
),
_buildInfoRow(LucideIcons.hardDrive, '总大小', date_utils.DateUtils.formatFileSize(summary.size)),
if (!summary.completed)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(
LucideIcons.alertCircle,
size: 14,
color: theme.colorScheme.error,
),
Icon(LucideIcons.alertCircle, size: 14, color: theme.colorScheme.error),
const SizedBox(width: 6),
Text(
'计算未完成,结果可能不完整',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.error,
),
),
Text('计算未完成,结果可能不完整', style: TextStyle(fontSize: 12, color: theme.colorScheme.error)),
],
),
),
@@ -715,12 +665,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
}
return _isCalculatingFolder
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
)
? const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
@@ -742,13 +687,13 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
const SizedBox(width: 8),
SizedBox(
width: 72,
child: Text(
label,
style: TextStyle(fontSize: 13, color: theme.hintColor),
),
child: Text(label, style: TextStyle(fontSize: 13, color: theme.hintColor)),
),
Expanded(
child: SelectableText(value, style: const TextStyle(fontSize: 13)),
child: SelectableText(
value,
style: const TextStyle(fontSize: 13),
),
),
],
),
+18 -11
View File
@@ -18,6 +18,7 @@ class FileListItem extends StatelessWidget {
final VoidCallback? onSelect;
final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser;
final VoidCallback? onOpenInCloudreveApp;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
@@ -40,6 +41,7 @@ class FileListItem extends StatelessWidget {
this.onSelect,
this.onDownload,
this.onOpenInBrowser,
this.onOpenInCloudreveApp,
this.onRename,
this.onMove,
this.onCopy,
@@ -51,17 +53,19 @@ class FileListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _FileListItemHover(
file: file,
isSelected: isSelected,
isHighlighted: isHighlighted,
index: index,
isDesktop: isDesktop,
showCheckbox: showCheckbox,
tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(context),
onSelect: onSelect,
return RepaintBoundary(
child: _FileListItemHover(
file: file,
isSelected: isSelected,
isHighlighted: isHighlighted,
index: index,
isDesktop: isDesktop,
showCheckbox: showCheckbox,
tapToShowMenu: tapToShowMenu,
onTap: tapToShowMenu ? null : onTap,
onLongPress: () => _showMenu(context),
onSelect: onSelect,
),
);
}
@@ -71,6 +75,7 @@ class FileListItem extends StatelessWidget {
hasSelect: onSelect != null,
hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null,
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
hasRename: onRename != null,
hasMove: onMove != null,
hasCopy: onCopy != null,
@@ -87,6 +92,8 @@ class FileListItem extends StatelessWidget {
onDownload?.call();
case FileMenuAction.openInBrowser:
onOpenInBrowser?.call();
case FileMenuAction.openInCloudreveApp:
onOpenInCloudreveApp?.call();
case FileMenuAction.rename:
onRename?.call();
case FileMenuAction.move:
@@ -7,6 +7,7 @@ enum FileMenuAction {
select,
download,
openInBrowser,
openInCloudreveApp,
rename,
move,
copy,
@@ -22,6 +23,7 @@ Future<FileMenuAction?> showFileMenu({
required bool hasSelect,
required bool hasDownload,
required bool hasOpenInBrowser,
bool hasOpenInCloudreveApp = false,
required bool hasRename,
required bool hasMove,
required bool hasCopy,
@@ -87,6 +89,17 @@ Future<FileMenuAction?> showFileMenu({
],
),
),
if (hasOpenInCloudreveApp)
const PopupMenuItem(
value: FileMenuAction.openInCloudreveApp,
child: Row(
children: [
Icon(Icons.web_asset, size: 20),
SizedBox(width: 12),
Text('在 Cloudreve 中打开'),
],
),
),
if (hasRename)
const PopupMenuItem(
value: FileMenuAction.rename,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -132,7 +132,7 @@ class _FolderPickerState extends State<FolderPicker> {
return InkWell(
onTap: () => _enterFolder(folder),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
Container(
@@ -163,7 +163,7 @@ class _FolderPickerState extends State<FolderPicker> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
Expanded(
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
///
class SelectionToolbar extends StatelessWidget {
final int selectionCount;
final int totalCount;
final VoidCallback onCancel;
final VoidCallback? onRename;
final VoidCallback? onSelectAll;
final VoidCallback? onMore;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback onDelete;
@@ -12,8 +14,10 @@ class SelectionToolbar extends StatelessWidget {
const SelectionToolbar({
super.key,
required this.selectionCount,
this.totalCount = 0,
required this.onCancel,
this.onRename,
this.onSelectAll,
this.onMore,
this.onMove,
this.onCopy,
required this.onDelete,
@@ -45,11 +49,19 @@ class SelectionToolbar extends StatelessWidget {
onPressed: onCancel,
tooltip: '取消选择',
),
if (selectionCount == 1 && onRename != null)
if (onSelectAll != null && selectionCount < totalCount)
IconButton(
icon: const Icon(Icons.edit),
onPressed: onRename,
tooltip: '重命名',
icon: const Icon(Icons.select_all),
onPressed: onSelectAll,
tooltip: '全选',
),
if (selectionCount == 1 && onMore != null)
IconButton(
icon: Icon(Theme.of(context).platform == TargetPlatform.iOS
? Icons.more_horiz
: Icons.more_vert),
onPressed: onMore,
tooltip: '更多',
),
if (onMove != null)
IconButton(
+34
View File
@@ -15,6 +15,9 @@ import '../presentation/pages/preview/audio_preview_page.dart';
import '../presentation/pages/preview/document_preview_page.dart';
import '../presentation/pages/preview/markdown_preview_page.dart';
import '../presentation/pages/files/category_files_page.dart';
import '../presentation/pages/share/share_link_page.dart';
import '../presentation/pages/preview/cloudreve_file_app_page.dart';
import '../services/share_link_service.dart';
import '../data/models/file_model.dart';
///
@@ -37,6 +40,8 @@ class RouteNames {
static const String markdownPreview = '/markdown-preview';
static const String categoryFiles = '/category-files';
static const String syncSettings = '/sync-settings';
static const String shareLink = '/share-link';
static const String cloudreveFileApp = '/cloudreve-file-app';
}
///
@@ -226,6 +231,35 @@ class AppRouter {
builder: (context) => const SyncSettingsPage(),
);
case RouteNames.shareLink:
final args = settings.arguments;
if (args is ShareLinkCandidate) {
return MaterialPageRoute(
settings: settings,
builder: (context) => ShareLinkPage(candidate: args),
);
}
return MaterialPageRoute(
settings: settings,
builder: (context) => const SplashPage(),
);
case RouteNames.cloudreveFileApp:
final args = settings.arguments;
if (args is Map<String, dynamic>) {
return MaterialPageRoute(
settings: settings,
builder: (context) => CloudreveFileAppPage(
file: args['file'] as FileModel,
preferredAction: args['preferredAction'] as String?,
),
);
}
return MaterialPageRoute(
settings: settings,
builder: (context) => const SplashPage(),
);
default:
return MaterialPageRoute(
settings: settings,
+108
View File
@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'api_service.dart';
import 'storage_service.dart';
import '../core/constants/storage_keys.dart';
class SiteAnnouncement {
final String title;
final String html;
final String baseUrl;
final String fingerprint;
const SiteAnnouncement({
required this.title,
required this.html,
required this.baseUrl,
required this.fingerprint,
});
}
///
///
/// Cloudreve V4 SiteConfig site_notice
/// custom_html.headless_bottom / sidebar_bottom HTML
///
///
/// -
/// -
/// -
class AnnouncementService {
AnnouncementService._();
static final AnnouncementService instance = AnnouncementService._();
bool _shownInSession = false;
/// fingerprint
bool get hasShown => _shownInSession;
/// 使 [markDismissed]
void markShown() {
_shownInSession = true;
}
Future<SiteAnnouncement?> getSiteNotice() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/site/config/basic',
);
final data = _asMap(response['data']) ?? response;
final notice = data['site_notice']?.toString().trim();
if (notice == null || notice.isEmpty) {
return null;
}
final baseUrl = ApiService.instance.dio.options.baseUrl;
return SiteAnnouncement(
title: '公告',
html: notice,
baseUrl: baseUrl,
fingerprint: _fingerprint(baseUrl: baseUrl, html: notice),
);
}
///
///
///
Future<SiteAnnouncement?> getChangedSiteNotice() async {
final notice = await getSiteNotice();
if (notice == null) return null;
if (_shownInSession) return null;
final dismissedFingerprint = await StorageService.instance.getString(
StorageKeys.siteAnnouncementDismissedFingerprint,
);
if (dismissedFingerprint == notice.fingerprint) {
_shownInSession = true;
return null;
}
return notice;
}
///
Future<void> markDismissed(SiteAnnouncement announcement) async {
_shownInSession = true;
await StorageService.instance.setString(
StorageKeys.siteAnnouncementDismissedFingerprint,
announcement.fingerprint,
);
}
String _fingerprint({required String baseUrl, required String html}) {
final normalized = '${baseUrl.trim()}\n${html.trim()}';
return sha256.convert(utf8.encode(normalized)).toString();
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
+28 -8
View File
@@ -10,7 +10,7 @@ import '../core/utils/app_logger.dart';
class ApiResponse<T> {
final int code;
final String message;
final T? data;
final dynamic data;
final String? error;
final String? correlationId;
@@ -26,7 +26,7 @@ class ApiResponse<T> {
return ApiResponse<T>(
code: json['code'] as int? ?? 0,
message: json['msg'] as String? ?? '',
data: json['data'] as T?,
data: json['data'],
error: json['error'] as String?,
correlationId: json['correlation_id'] as String?,
);
@@ -198,6 +198,12 @@ class ApiService {
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
// 401 HTTP 401 JSON code: 401
final silent404 =
error.requestOptions.extra['silent404'] as bool? ?? false;
if (silent404 && error.response?.statusCode == 404) {
return handler.next(error);
}
bool is401Error = error.response?.statusCode == 401;
if (!is401Error && error.response?.data is Map<String, dynamic>) {
final data = error.response!.data as Map<String, dynamic>;
@@ -320,12 +326,16 @@ class ApiService {
Map<String, dynamic>? queryParameters,
bool noAuth = false,
bool isNoData = false,
bool silent404 = false,
Map<String, dynamic>? headers,
}) async {
final response = await _dio.get<T>(
path,
queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}, headers: headers),
options: Options(
extra: {'noAuth': noAuth, 'silent404': silent404},
headers: headers,
),
);
// , _parseResponse
if (isNoData) {
@@ -356,9 +366,12 @@ class ApiService {
AppLogger.d('Response Data: ${response.data}');
var isActivEmail = 0;
if (response.statusCode == 200) {
Map<String, dynamic>? tmp = response.data as Map<String, dynamic>?;
isActivEmail = tmp?['code'] as int;
if (response.statusCode == 200 && response.data is Map) {
final tmp = Map<String, dynamic>.from(response.data as Map);
final code = tmp['code'];
if (code is int) {
isActivEmail = code;
}
}
if (isNoData || isActivEmail == 203) {
@@ -447,11 +460,18 @@ class ApiService {
T _parseResponse<T>(Response response) {
final data = response.data;
if (data is Map<String, dynamic>) {
final apiResponse = ApiResponse<T>.fromJson(data);
final apiResponse = ApiResponse<dynamic>.fromJson(data);
if (!apiResponse.isSuccess && !apiResponse.isContinue) {
throw ServerException(apiResponse.message, code: apiResponse.code);
}
return apiResponse.data as T;
final payload = apiResponse.data;
if (payload is T) {
return payload;
}
if (data is T) {
return data as T;
}
return payload as T;
}
return data as T;
}
+144 -37
View File
@@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../presentation/pages/auth/captcha_challenge_page.dart';
@@ -30,6 +32,11 @@ class CaptchaService {
String? _captchaToken;
bool _isLoadingCaptcha = false;
// Windows
CaptchaProxyConfig? _proxyConfig;
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
bool get isLoadingCaptcha => _isLoadingCaptcha;
String? get captchaImage => _captchaImage;
String? get captchaTicket => _captchaTicket;
@@ -103,13 +110,15 @@ class CaptchaService {
Map<String, dynamic> config = <String, dynamic>{};
try {
config = await AuthService.instance.getBasicSiteConfig().timeout(
const Duration(seconds: 10),
);
config = await AuthService.instance
.getBasicSiteConfig()
.timeout(const Duration(seconds: 10));
} catch (_) {}
final captchaType = _normalizeCaptchaType(
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha'])
(config['captcha_type'] ??
config['captchaType'] ??
config['captcha'])
?.toString(),
);
@@ -151,8 +160,7 @@ class CaptchaService {
'capAssetServer',
]);
final isExternalCaptcha =
captchaType == 'turnstile' ||
final isExternalCaptcha = captchaType == 'turnstile' ||
captchaType == 'recaptcha' ||
captchaType == 'cap';
@@ -199,7 +207,7 @@ class CaptchaService {
}
/// Web
Future<void> openCaptchaChallenge(BuildContext context) async {
Future<void> openCaptchaChallenge(BuildContext context, {VoidCallback? onVerified}) async {
final server = ServerService.instance.currentServer;
final config = captchaWebConfig;
@@ -210,14 +218,18 @@ class CaptchaService {
final token = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) =>
CaptchaChallengePage(config: config, baseUrl: server.baseUrl),
builder: (_) => CaptchaChallengePage(
config: config,
baseUrl: server.baseUrl,
proxyConfig: _isDesktop ? _proxyConfig : null,
),
),
);
if (token != null && token.isNotEmpty) {
_captchaToken = token;
ToastHelper.success('人机验证完成');
onVerified?.call();
}
}
@@ -241,16 +253,21 @@ class CaptchaService {
Map<String, String> getCaptchaParams() {
if (isWebCaptcha) {
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
return {'captcha': _captchaToken!, 'ticket': _captchaToken!};
return {
'captcha': _captchaToken!,
'ticket': _captchaToken!,
};
}
final userInput = captchaController.text.trim();
if (userInput.isEmpty &&
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
if (userInput.isEmpty && (_captchaTicket == null || _captchaTicket!.isEmpty)) {
return {};
}
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''};
return {
'captcha': userInput,
'ticket': _captchaTicket ?? '',
};
}
/// Web
@@ -263,30 +280,44 @@ class CaptchaService {
final config = captchaWebConfig;
final displayName = config?.displayName ?? '人机验证';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _isLoadingCaptcha
? null
: () => openCaptchaChallenge(context),
icon: Icon(
_captchaToken == null
? Icons.verified_user_outlined
: Icons.verified,
),
label: Text(
_captchaToken == null
? '点击完成 $displayName'
: '$displayName 已完成,点击重新验证',
),
),
const SizedBox(height: 8),
Text(
'当前验证码类型:$displayName',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
return StatefulBuilder(
builder: (context, setState) {
final hasProxy = _proxyConfig != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _isLoadingCaptcha ? null : () async {
await openCaptchaChallenge(context);
setState(() {});
},
onLongPress: _isDesktop && Platform.isWindows
? () => _showProxyDialog(context, setState)
: null,
icon: Icon(
_captchaToken == null
? Icons.verified_user_outlined
: Icons.verified,
),
label: Text(
_captchaToken == null
? '点击完成 $displayName'
: '$displayName 已完成,点击重新验证',
),
),
const SizedBox(height: 8),
Text(
_isDesktop && Platform.isWindows
? '当前验证码类型:$displayName${hasProxy ? ' (代理: $_proxyConfig)' : ''}\n网络问题验证失败可长按上方按钮可以设置代理(仅windows)'
: '当前验证码类型:$displayName',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor,
),
),
],
);
},
);
}
@@ -392,4 +423,80 @@ class CaptchaService {
}
return null;
}
/// Windows
void _showProxyDialog(BuildContext context, StateSetter setState) {
final hostCtrl = TextEditingController(text: _proxyConfig?.host ?? '');
final portCtrl = TextEditingController(text: _proxyConfig?.port.toString() ?? '');
showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('WebView 代理设置'),
content: SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'仅支持无认证代理(HTTP/SOCKS5',
style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor),
),
const SizedBox(height: 16),
TextFormField(
controller: hostCtrl,
decoration: const InputDecoration(
labelText: '代理地址',
hintText: '127.0.0.1',
prefixIcon: Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 12),
TextFormField(
controller: portCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '端口',
hintText: '7890',
prefixIcon: Icon(Icons.numbers),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
_proxyConfig = null;
setState(() {});
Navigator.of(ctx).pop();
ToastHelper.success('已清除代理配置');
},
child: const Text('清除'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () {
final host = hostCtrl.text.trim();
final port = int.tryParse(portCtrl.text.trim()) ?? 0;
if (host.isEmpty || port <= 0) {
ToastHelper.failure('请输入有效的代理地址和端口');
return;
}
_proxyConfig = CaptchaProxyConfig(host: host, port: port);
setState(() {});
Navigator.of(ctx).pop();
ToastHelper.success('代理已设置: $host:$port');
},
child: const Text('确定'),
),
],
);
},
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:async';
///
///
///
/// /
class DialogQueueService {
DialogQueueService._();
static final DialogQueueService instance = DialogQueueService._();
Future<void> _tail = Future<void>.value();
Future<T?> enqueue<T>(Future<T?> Function() task) {
final completer = Completer<T?>();
_tail = _tail.catchError((_) {}).then((_) async {
try {
final result = await task();
if (!completer.isCompleted) {
completer.complete(result);
}
} catch (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
}
});
return completer.future;
}
}
+338
View File
@@ -0,0 +1,338 @@
import 'package:dio/dio.dart';
import 'api_service.dart';
import '../core/utils/file_type_utils.dart';
import '../core/utils/file_utils.dart';
import '../data/models/file_model.dart';
class FileAppViewer {
final String id;
final String type;
final String displayName;
final List<String> exts;
final String? icon;
final int maxSize;
final String? url;
const FileAppViewer({
required this.id,
required this.type,
required this.displayName,
required this.exts,
this.icon,
this.maxSize = 0,
this.url,
});
factory FileAppViewer.fromJson(Map<String, dynamic> json) {
return FileAppViewer(
id: json['id']?.toString() ?? '',
type: json['type']?.toString() ?? '',
displayName: json['display_name']?.toString() ??
json['displayName']?.toString() ??
json['name']?.toString() ??
'文件应用',
exts: _parseExts(json['exts'] ?? json['extensions'] ?? json['ext']),
icon: json['icon']?.toString(),
maxSize: (json['max_size'] as num?)?.toInt() ??
(json['maxSize'] as num?)?.toInt() ??
0,
url: json['url']?.toString(),
);
}
static List<String> _parseExts(dynamic raw) {
if (raw is List) {
return raw
.map((e) => e.toString().toLowerCase().replaceAll('.', '').trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (raw is String) {
return raw
.split(',')
.map((e) => e.toLowerCase().replaceAll('.', '').trim())
.where((e) => e.isNotEmpty)
.toList();
}
return const [];
}
bool supports(FileModel file) {
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
if (ext.isEmpty || !exts.contains(ext)) return false;
if (maxSize > 0 && file.size > maxSize) return false;
return id.isNotEmpty;
}
bool get isWopi => type.toLowerCase().contains('wopi');
}
class FileAppSession {
final FileAppViewer viewer;
final String wopiSrc;
final String? accessToken;
final int? expires;
const FileAppSession({
required this.viewer,
required this.wopiSrc,
this.accessToken,
this.expires,
});
bool get hasAccessToken => accessToken != null && accessToken!.isNotEmpty;
}
/// Cloudreve V4
///
/// Cloudreve V4 `/site/config` `/site/config/basic`
/// `/site/config/basic` file_viewers
/// `/site/config` 404 WebView
class FileAppService {
FileAppService._();
static final FileAppService instance = FileAppService._();
List<FileAppViewer>? _cachedViewers;
DateTime? _cachedAt;
Future<List<FileAppViewer>> getViewers({bool forceRefresh = false}) async {
final cached = _cachedViewers;
final cachedAt = _cachedAt;
if (!forceRefresh &&
cached != null &&
cachedAt != null &&
DateTime.now().difference(cachedAt).inMinutes < 10) {
return cached;
}
final viewers = await _loadViewersFromSiteConfig();
_cachedViewers = viewers;
_cachedAt = DateTime.now();
return viewers;
}
Future<List<FileAppViewer>> _loadViewersFromSiteConfig() async {
// /site/config/basic
// /site/config Cloudreve 404
const endpoints = <String>[
'/site/config/basic',
'/site/config',
];
for (final endpoint in endpoints) {
final root = await _tryGetSiteConfig(endpoint);
if (root == null) continue;
final viewers = _extractViewers(root);
if (viewers.isNotEmpty) {
return viewers;
}
}
return const [];
}
Future<Map<String, dynamic>?> _tryGetSiteConfig(String endpoint) async {
try {
final response = await ApiService.instance.dio.get<dynamic>(
endpoint,
options: Options(
extra: {'noAuth': true},
// 404
validateStatus: (status) => status != null && status >= 200 && status < 500,
),
);
if (response.statusCode == 404) {
return null;
}
final raw = response.data;
final map = _asMap(raw);
if (map == null) return null;
final code = map['code'];
if (code is int && code != 0 && code != 203) {
return null;
}
return _asMap(map['data']) ?? map;
} catch (_) {
return null;
}
}
Future<FileAppViewer?> findViewerForFile(
FileModel file, {
bool forceRefresh = false,
}) async {
final viewers = await getViewers(forceRefresh: forceRefresh);
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
final candidates = viewers.where((viewer) => viewer.supports(file)).toList();
if (candidates.isEmpty) return null;
candidates.sort((a, b) {
final aw = a.isWopi ? 0 : 1;
final bw = b.isWopi ? 0 : 1;
if (aw != bw) return aw.compareTo(bw);
final aExact = a.exts.contains(ext) ? 0 : 1;
final bExact = b.exts.contains(ext) ? 0 : 1;
return aExact.compareTo(bExact);
});
return candidates.first;
}
Future<FileAppSession> createViewerSession({
required FileModel file,
required FileAppViewer viewer,
String preferredAction = 'view',
}) async {
final uri = FileUtils.toCloudreveUri(file.relativePath);
final data = <String, dynamic>{
'uri': uri,
'viewer_id': viewer.id,
'preferred_action': preferredAction,
'parent_uri': _parentUri(uri),
};
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/file/viewerSession',
data: data,
);
final root = _asMap(response['data']) ?? response;
final session = _asMap(root['session']);
final wopiSrc = root['wopi_src']?.toString() ??
root['wopiSrc']?.toString() ??
root['url']?.toString() ??
'';
if (wopiSrc.isEmpty) {
throw Exception('文件应用没有返回可打开的 URL');
}
return FileAppSession(
viewer: viewer,
wopiSrc: _absoluteUrl(wopiSrc),
accessToken: session?['access_token']?.toString() ??
session?['accessToken']?.toString(),
expires: (session?['expires'] as num?)?.toInt(),
);
}
Future<FileAppSession> openFile({
required FileModel file,
String preferredAction = 'view',
}) async {
final viewer = await findViewerForFile(file);
if (viewer == null) {
throw Exception('没有找到支持 ${FileTypeUtils.getExtension(file.name)} 的 Cloudreve 文件应用');
}
try {
return await createViewerSession(
file: file,
viewer: viewer,
preferredAction: preferredAction,
);
} catch (_) {
if (preferredAction != 'view') {
return createViewerSession(
file: file,
viewer: viewer,
preferredAction: 'view',
);
}
rethrow;
}
}
List<FileAppViewer> _extractViewers(Map<String, dynamic> root) {
final raw = _findValueByKey(root, const [
'file_viewers',
'fileViewers',
'file_viewer',
'viewers',
]);
dynamic viewerList = raw;
final rawMap = _asMap(raw);
if (rawMap != null) {
viewerList = rawMap['viewers'] ?? rawMap['items'] ?? rawMap['data'];
}
if (viewerList is! List) return const [];
return viewerList
.whereType<Map>()
.map((item) => FileAppViewer.fromJson(Map<String, dynamic>.from(item)))
.where((viewer) => viewer.id.isNotEmpty && viewer.exts.isNotEmpty)
.toList();
}
dynamic _findValueByKey(dynamic value, List<String> keys) {
if (value is Map) {
for (final key in keys) {
if (value.containsKey(key)) {
return value[key];
}
}
for (final child in value.values) {
final found = _findValueByKey(child, keys);
if (found != null) return found;
}
} else if (value is List) {
for (final child in value) {
final found = _findValueByKey(child, keys);
if (found != null) return found;
}
}
return null;
}
Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
String _parentUri(String uri) {
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
final index = normalized.lastIndexOf('/');
if (index <= 'cloudreve://my'.length) {
return 'cloudreve://my';
}
return normalized.substring(0, index);
}
String _absoluteUrl(String url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
final origin = Uri(
scheme: base.scheme,
host: base.host,
port: base.hasPort ? base.port : null,
).toString();
if (url.startsWith('/')) {
return '$origin$url';
}
return '$origin/$url';
}
}
+906
View File
@@ -0,0 +1,906 @@
import 'package:dio/dio.dart';
import 'api_service.dart';
import '../core/utils/file_utils.dart';
class ShareLinkCandidate {
final String id;
final String url;
final String? password;
const ShareLinkCandidate({
required this.id,
required this.url,
this.password,
});
}
class ShareLinkInfo {
final String id;
final String name;
final int visited;
final bool expired;
final bool unlocked;
final bool isPrivate;
final int sourceType;
final String? sourceUri;
final int size;
final String? url;
final String? ownerName;
final String? ownerId;
final String? ownerAvatar;
final String? contextHint;
final DateTime? createdAt;
final DateTime? expires;
const ShareLinkInfo({
required this.id,
required this.name,
required this.visited,
required this.expired,
required this.unlocked,
required this.isPrivate,
required this.sourceType,
this.sourceUri,
this.size = 0,
this.url,
this.ownerName,
this.ownerId,
this.ownerAvatar,
this.contextHint,
this.createdAt,
this.expires,
});
bool get isFolder => sourceType == 1;
bool get isFile => !isFolder;
factory ShareLinkInfo.fromJson(Map<String, dynamic> json) {
final owner = _asMap(json['owner']);
final source = _asMap(json['source']);
final file = _asMap(json['file']);
final object = _asMap(json['object']);
final entity = _asMap(json['entity']);
final rawSource = json['source'];
final ownerId = (owner?['id'] ??
json['owner_id'] ??
json['ownerId'] ??
source?['owner_id'] ??
file?['owner_id'])
?.toString();
final name = (json['name'] ??
file?['name'] ??
source?['name'] ??
object?['name'] ??
'分享文件')
.toString();
final parsedSourceUri = (json['source_uri'] ??
json['sourceUri'] ??
json['uri'] ??
json['path'] ??
(rawSource is String ? rawSource : null) ??
source?['uri'] ??
source?['source_uri'] ??
source?['path'] ??
source?['url'] ??
file?['uri'] ??
file?['source_uri'] ??
file?['path'] ??
object?['uri'] ??
object?['source_uri'] ??
object?['path'] ??
entity?['uri'] ??
entity?['source_uri'] ??
entity?['path'])
?.toString();
final resolvedSourceUri = _looksLikeCloudreveUri(parsedSourceUri)
? parsedSourceUri
: _fallbackSourceUri(ownerId: ownerId, name: name);
final size = _asInt(json['size'] ??
json['source_size'] ??
json['sourceSize'] ??
source?['size'] ??
file?['size'] ??
object?['size'] ??
entity?['size']);
return ShareLinkInfo(
id: json['id']?.toString() ?? '',
name: name,
size: size,
visited: _asInt(json['visited']),
expired: json['expired'] as bool? ?? false,
unlocked: json['unlocked'] as bool? ?? false,
isPrivate: json['is_private'] as bool? ?? false,
sourceType: _asInt(json['source_type'] ??
source?['type'] ??
file?['type'] ??
object?['type']),
sourceUri: resolvedSourceUri,
url: json['url']?.toString(),
ownerName: owner?['nickname']?.toString() ??
owner?['name']?.toString() ??
json['owner_name']?.toString(),
ownerId: ownerId,
ownerAvatar: owner?['avatar']?.toString(),
contextHint: json['context_hint']?.toString() ??
json['contextHint']?.toString() ??
json['context']?.toString(),
createdAt: _parseDate(json['created_at']),
expires: _parseDate(json['expires']),
);
}
static bool _looksLikeCloudreveUri(String? value) {
if (value == null || value.trim().isEmpty) return false;
return value.trim().startsWith('cloudreve://');
}
static String? _fallbackSourceUri({
required String? ownerId,
required String name,
}) {
// owner@my my
// owner@my /file/url 40081 urls
// ShareLinkService.getShareInfo ID
// cloudreve://<shareId>@share/
return null;
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
static int _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
static DateTime? _parseDate(dynamic value) {
final text = value?.toString();
if (text == null || text.isEmpty) return null;
return DateTime.tryParse(text);
}
}
class ShareLinkFile {
final int type;
final String id;
final String name;
final int size;
final String path;
final DateTime? createdAt;
final DateTime? updatedAt;
final String? primaryEntity;
final String? capability;
final String? contextHint;
final Map<String, dynamic>? metadata;
const ShareLinkFile({
required this.type,
required this.id,
required this.name,
required this.size,
required this.path,
this.createdAt,
this.updatedAt,
this.primaryEntity,
this.capability,
this.contextHint,
this.metadata,
});
bool get isFolder => type == 1;
bool get isFile => !isFolder;
factory ShareLinkFile.fromJson(Map<String, dynamic> json) {
return ShareLinkFile(
type: _asInt(json['type']),
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '未命名文件',
size: _asInt(json['size']),
path: json['path']?.toString() ?? '',
createdAt: _parseDate(json['created_at']),
updatedAt: _parseDate(json['updated_at']),
primaryEntity: json['primary_entity']?.toString() ??
json['entity']?.toString(),
capability: json['capability']?.toString(),
contextHint: json['context_hint']?.toString(),
metadata: _asMap(json['metadata']),
);
}
static int _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
static DateTime? _parseDate(dynamic value) {
final text = value?.toString();
if (text == null || text.isEmpty) return null;
return DateTime.tryParse(text);
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
class ShareLinkFileListResult {
final List<ShareLinkFile> files;
final ShareLinkFile? parent;
final String? contextHint;
final bool hasMore;
final String? nextPageToken;
const ShareLinkFileListResult({
required this.files,
this.parent,
this.contextHint,
this.hasMore = false,
this.nextPageToken,
});
factory ShareLinkFileListResult.fromJson(Map<String, dynamic> json) {
final rawFiles = json['files'];
final rawParent = json['parent'];
final pagination = ShareLinkService._asMap(json['pagination']);
return ShareLinkFileListResult(
files: rawFiles is List
? rawFiles
.whereType<Map>()
.map((item) => ShareLinkFile.fromJson(Map<String, dynamic>.from(item)))
.toList()
: const [],
parent: rawParent is Map
? ShareLinkFile.fromJson(Map<String, dynamic>.from(rawParent))
: null,
contextHint: json['context_hint']?.toString(),
hasMore: pagination?['next_token'] != null,
nextPageToken: pagination?['next_token']?.toString(),
);
}
}
class ShareDownloadUrlResult {
final String url;
final DateTime? expires;
const ShareDownloadUrlResult({
required this.url,
this.expires,
});
}
class ShareLinkService {
ShareLinkService._();
static final ShareLinkService instance = ShareLinkService._();
/// Cloudreve /s/{id}/{password?}
static final RegExp _shareLinkRegExp = RegExp(
r'https?://[^\s]+?/s/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?',
caseSensitive: false,
);
ShareLinkCandidate? parseShareLink(String? text) {
if (text == null || text.trim().isEmpty) return null;
final match = _shareLinkRegExp.firstMatch(text.trim());
if (match == null) return null;
final id = match.group(1);
if (id == null || id.isEmpty) return null;
final password = match.group(2);
final url = match.group(0)!;
return ShareLinkCandidate(
id: id,
url: url,
password: password == null || password.isEmpty ? null : password,
);
}
Future<ShareLinkInfo> getShareInfo(
ShareLinkCandidate candidate, {
String? password,
}) async {
final resolvedPassword = password ?? candidate.password;
final query = <String, dynamic>{
'count_views': true,
'owner_extended': true,
if (resolvedPassword != null && resolvedPassword.isNotEmpty)
'password': resolvedPassword,
};
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
'/share/info/${candidate.id}',
queryParameters: query,
options: Options(
extra: const {'noAuth': true},
headers: const {
'X-Cr-Context-Hint': 'share',
},
),
);
final body = response.data ?? <String, dynamic>{};
final data = _asMap(body['data']) ?? body;
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
final sourceType = ShareLinkInfo._asInt(data['source_type']);
// 访使 Cloudreve share URI
// cloudreve://{shareId}[:password]@share[/...]
// /share/info source_uri my
// /noAuth /file/url
// 40081 Entity not exist
final accessUri = shareRootUri(
shareId: candidate.id,
password: resolvedPassword,
trailingSlash: sourceType != 0,
);
return ShareLinkInfo.fromJson(<String, dynamic>{
...data,
'source_uri': accessUri,
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
headerContext != null &&
headerContext.isNotEmpty)
'context_hint': headerContext,
});
}
String? ownerAvatarUrl(ShareLinkInfo info) {
final ownerId = info.ownerId;
if (ownerId == null || ownerId.isEmpty) return null;
final avatar = info.ownerAvatar;
if (avatar != null && avatar.startsWith(RegExp(r'https?://'))) {
return avatar;
}
final base = ApiService.instance.dio.options.baseUrl.replaceFirst(RegExp(r'/+$'), '');
return '$base/user/avatar/${Uri.encodeComponent(ownerId)}';
}
/// source_uri
///
/// Cloudreve V4 /file JWT Optional X-Cr-Context-Hint
/// `share` context_hint
/// context_hint
Future<ShareLinkFileListResult> listSharedFiles({
required String uri,
String? contextHint,
int page = 0,
int pageSize = 100,
String? nextPageToken,
}) async {
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
'/file',
queryParameters: <String, dynamic>{
'uri': uri,
'page': page,
'page_size': pageSize,
if (nextPageToken != null && nextPageToken.isNotEmpty)
'next_page_token': nextPageToken,
},
options: Options(
extra: const {'noAuth': true},
headers: <String, dynamic>{
'X-Cr-Context-Hint': _firstContextHint(contextHint),
},
),
);
final body = response.data ?? <String, dynamic>{};
final data = _asMap(body['data']) ?? body;
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
return ShareLinkFileListResult.fromJson(<String, dynamic>{
...data,
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
headerContext != null &&
headerContext.isNotEmpty)
'context_hint': headerContext,
});
}
/// primary_entity
Future<ShareLinkFile> getSharedFileInfo({
required String uri,
String? contextHint,
String? shareId,
String? password,
}) async {
Object? lastError;
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
try {
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
'/file/info',
queryParameters: <String, dynamic>{
'uri': uri,
'extended': true,
},
options: Options(
extra: const {'noAuth': true},
headers: <String, dynamic>{
'X-Cr-Context-Hint': hint,
},
),
);
final body = response.data ?? <String, dynamic>{};
final data = _asMap(body['data']) ?? body;
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
return ShareLinkFile.fromJson(<String, dynamic>{
...data,
'context_hint': data['context_hint'] ?? headerContext ?? hint,
});
} catch (e) {
lastError = e;
}
}
throw lastError ?? Exception('文件详情读取失败');
}
ShareLinkFile fileFromShareInfo(ShareLinkInfo info) {
final sourceUri = info.sourceUri ??
ShareLinkInfo._fallbackSourceUri(
ownerId: info.ownerId,
name: info.name,
) ??
'';
return ShareLinkFile(
type: info.sourceType,
id: info.id,
name: info.name,
size: info.size,
path: sourceUri,
createdAt: info.createdAt,
updatedAt: info.createdAt,
contextHint: info.contextHint,
);
}
/// URL
///
/// Cloudreve V4 `/file/url`
/// body `uris` `archive: true`
/// 访 JWT Optional使 noAuth
/// Authorization
Future<ShareDownloadUrlResult> createShareDownloadUrl({
required String uri,
String? contextHint,
String? shareId,
String? password,
String? entity,
String? fileName,
bool archive = false,
}) async {
Object? lastError;
// Cloudreve V4 POST /file/url
// Header: X-Cr-Context-Hint
// Body: { "uris": [...], "archive": true? }
//
// token URI
// Entity not exist (40081)
for (final candidateUri in _uriCandidates(
uri,
shareId: shareId,
password: password,
fileName: fileName,
)) {
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
try {
return await _createShareDownloadUrlOnce(
uri: candidateUri,
contextHint: hint,
archive: archive,
);
} catch (e) {
lastError = e;
}
}
}
throw lastError ?? Exception('服务端没有返回可用的下载链接');
}
Future<ShareDownloadUrlResult> _createShareDownloadUrlOnce({
required String uri,
required String contextHint,
bool archive = false,
}) async {
final body = <String, dynamic>{
'uris': <String>[uri],
'download': true,
if (archive) 'archive': true,
};
final response = await ApiService.instance.dio.post<Map<String, dynamic>>(
'/file/url',
data: body,
options: Options(
extra: const {'noAuth': true},
headers: <String, dynamic>{
'Content-Type': 'application/json',
'X-Cr-Context-Hint': contextHint,
},
),
);
final raw = response.data ?? <String, dynamic>{};
final data = _asMap(raw['data']) ?? raw;
final url = _extractDownloadUrl(data) ?? _extractDownloadUrl(raw);
if (url != null && url.isNotEmpty) {
return ShareDownloadUrlResult(
url: url,
expires: ShareLinkInfo._parseDate(data['expires'] ?? raw['expires']),
);
}
throw Exception('服务端没有返回可用的下载链接');
}
///
///
/// Cloudreve V4 使 /file/move/ copy=true
/// /
Future<void> saveSharedFiles({
required List<String> uris,
required String destination,
String? contextHint,
String? shareId,
String? password,
String? fileName,
}) async {
final dst = FileUtils.toCloudreveUri(destination);
Object? lastError;
final uriCandidateSets = uris
.map((uri) => _uriCandidates(
uri,
shareId: shareId,
password: password,
// source_uri cloudreve://{id}@share
// /file/move share
// cloudreve://{id}@share/{fileName} URI
fileName: uris.length == 1 ? fileName : null,
includeRootFallback: false,
)
.where((candidate) => !isShareRootUri(candidate, shareId: shareId))
.toList())
.where((candidates) => candidates.isNotEmpty)
.toList();
if (uriCandidateSets.isEmpty) {
throw Exception('不能直接转存分享根目录,请选择具体文件或进入目录后再转存');
}
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
for (final candidateUris in _cartesianUriCandidates(uriCandidateSets)) {
if (candidateUris.isEmpty) continue;
try {
await ApiService.instance.post<void>(
'/file/move',
data: <String, dynamic>{
'uris': candidateUris,
'dst': dst,
'copy': true,
},
headers: <String, dynamic>{
'Content-Type': 'application/json',
'X-Cr-Context-Hint': hint,
},
);
return;
} catch (e) {
lastError = e;
}
}
}
throw lastError ?? Exception('转存失败');
}
static List<String> _uriCandidates(
String uri, {
String? shareId,
String? password,
String? fileName,
bool includeRootFallback = true,
}) {
final values = <String>[];
void add(String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty && !values.contains(text)) {
values.add(text);
}
}
final shareUriCandidates = _shareScopedUriCandidates(
uri,
shareId: shareId,
password: password,
);
// cloudreve://{shareId}@share
// /file cloudreve://my/... owner@my/...
// / share
for (final item in shareUriCandidates) {
add(item);
}
final name = fileName?.trim();
final id = shareId?.trim();
if (id != null && id.isNotEmpty && name != null && name.isNotEmpty) {
final root = shareRootUri(
shareId: id,
password: password,
trailingSlash: false,
);
add('$root/${Uri.encodeComponent(name)}');
add('$root/${_encodeCloudrevePath(name)}');
}
add(_withSharePassword(uri, shareId: shareId, password: password));
add(uri);
if (uri.startsWith('cloudreve://')) {
final withPassword = _withSharePassword(uri, shareId: shareId, password: password);
if (withPassword != uri) add(withPassword);
final slash = uri.indexOf('/', 'cloudreve://'.length);
if (slash > 0) {
final prefix = uri.substring(0, slash + 1);
final path = slash < uri.length - 1 ? uri.substring(slash + 1) : '';
if (path.isNotEmpty) {
final encodedPath = _encodeCloudrevePath(path);
add(_withSharePassword('$prefix$encodedPath', shareId: shareId, password: password));
add('$prefix$encodedPath');
}
if (!uri.endsWith('/')) {
add(_withSharePassword('$uri/', shareId: shareId, password: password));
add('$uri/');
}
}
}
if (includeRootFallback && shareId != null && shareId.trim().isNotEmpty) {
add(shareRootUri(shareId: shareId, password: password, trailingSlash: true));
add(shareRootUri(shareId: shareId, password: password, trailingSlash: false));
}
return values;
}
static List<String> _shareScopedUriCandidates(
String uri, {
String? shareId,
String? password,
}) {
final id = shareId?.trim();
if (id == null || id.isEmpty || !uri.startsWith('cloudreve://')) {
return const <String>[];
}
final values = <String>[];
void add(String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty && !values.contains(text)) {
values.add(text);
}
}
final authorityStart = 'cloudreve://'.length;
final slash = uri.indexOf('/', authorityStart);
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
final rawPath = slash >= 0 && slash < uri.length - 1 ? uri.substring(slash + 1) : '';
final decodedPath = rawPath
.split('/')
.where((segment) => segment.isNotEmpty)
.map((segment) => Uri.decodeComponent(segment))
.join('/');
final isShareAuthority = authority.endsWith('@share');
if (isShareAuthority) {
add(_withSharePassword(uri, shareId: id, password: password));
add(uri);
return values;
}
if (decodedPath.isEmpty) return values;
final pathParts = decodedPath.split('/').where((e) => e.isNotEmpty).toList();
final root = shareRootUri(shareId: id, password: password, trailingSlash: false);
// cloudreve://id@share/folder/file.ext
add('$root/${_encodeCloudrevePath(decodedPath)}');
// owner my share
// basename cloudreve://id@share/file.ext
if (pathParts.isNotEmpty) {
add('$root/${Uri.encodeComponent(pathParts.last)}');
}
// path Folder/Sub/File Folder share Sub/File
if (pathParts.length > 1) {
final withoutFirst = pathParts.skip(1).join('/');
add('$root/${_encodeCloudrevePath(withoutFirst)}');
}
return values;
}
static String _encodeCloudrevePath(String path) {
return path
.split('/')
.where((segment) => segment.isNotEmpty)
.map((segment) => Uri.encodeComponent(Uri.decodeComponent(segment)))
.join('/');
}
static String _withSharePassword(String uri, {String? shareId, String? password}) {
final pw = password?.trim();
final id = shareId?.trim();
if (pw == null || pw.isEmpty || id == null || id.isEmpty) return uri;
if (!uri.startsWith('cloudreve://')) return uri;
final authorityStart = 'cloudreve://'.length;
final slash = uri.indexOf('/', authorityStart);
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
final rest = slash >= 0 ? uri.substring(slash) : '';
if (!authority.endsWith('@share')) return uri;
if (authority.contains(':')) return uri;
final encodedId = Uri.encodeComponent(id);
final encodedPw = Uri.encodeComponent(pw);
if (authority != '$encodedId@share' && authority != '$id@share') return uri;
return 'cloudreve://$encodedId:$encodedPw@share$rest';
}
static String shareRootUri({
required String shareId,
String? password,
bool trailingSlash = true,
}) {
final id = Uri.encodeComponent(shareId.trim());
final pw = password?.trim();
final userInfo = pw == null || pw.isEmpty
? id
: '$id:${Uri.encodeComponent(pw)}';
return 'cloudreve://$userInfo@share${trailingSlash ? '/' : ''}';
}
static bool isShareRootUri(String uri, {String? shareId}) {
final text = uri.trim();
const prefix = 'cloudreve://';
if (!text.startsWith(prefix)) return false;
final authorityStart = prefix.length;
final slash = text.indexOf('/', authorityStart);
final authority = slash >= 0
? text.substring(authorityStart, slash)
: text.substring(authorityStart);
if (!authority.endsWith('@share')) return false;
final path = slash >= 0 ? text.substring(slash) : '';
if (path.isNotEmpty && path != '/') return false;
final expectedId = shareId?.trim();
if (expectedId == null || expectedId.isEmpty) return true;
final userInfo = authority.substring(0, authority.length - '@share'.length);
final rawId = userInfo.split(':').first;
return Uri.decodeComponent(rawId) == expectedId || rawId == expectedId;
}
static String? _extractDownloadUrl(Map<String, dynamic> data) {
final direct = data['url'] ??
data['download_url'] ??
data['downloadUrl'] ??
data['src'] ??
data['href'];
if (direct is String && direct.trim().isNotEmpty) return direct.trim();
final rawUrls = data['urls'];
if (rawUrls is String && rawUrls.trim().isNotEmpty) return rawUrls.trim();
if (rawUrls is List) {
for (final item in rawUrls) {
if (item is String && item.trim().isNotEmpty) return item.trim();
final map = _asMap(item);
if (map == null) continue;
final url = _extractDownloadUrl(map);
if (url != null && url.isNotEmpty) return url;
}
}
if (rawUrls is Map) {
for (final item in rawUrls.values) {
if (item is String && item.trim().isNotEmpty) return item.trim();
final map = _asMap(item);
if (map == null) continue;
final url = _extractDownloadUrl(map);
if (url != null && url.isNotEmpty) return url;
}
}
return null;
}
static List<List<String>> _cartesianUriCandidates(List<List<String>> sets) {
if (sets.isEmpty) return const [[]];
var result = <List<String>>[const []];
for (final set in sets) {
final next = <List<String>>[];
for (final prefix in result) {
for (final item in set) {
next.add(<String>[...prefix, item]);
}
}
result = next;
}
return result;
}
static String _firstContextHint(String? contextHint) {
final value = contextHint?.trim();
return value == null || value.isEmpty ? 'share' : value;
}
static List<String> _contextHintCandidates(String? contextHint, {String? shareId}) {
final values = <String>[];
void add(String? value) {
final text = value?.trim();
if (text != null && text.isNotEmpty && !values.contains(text)) {
values.add(text);
}
}
add(contextHint);
add(shareId);
if (shareId != null && shareId.trim().isNotEmpty) {
add('share:${shareId.trim()}');
add('share_${shareId.trim()}');
}
add('share');
return values;
}
static String? _headerValue(Headers headers, String name) {
final direct = headers.value(name) ?? headers.value(name.toLowerCase());
if (direct != null && direct.isNotEmpty) return direct;
final lower = name.toLowerCase();
for (final entry in headers.map.entries) {
if (entry.key.toLowerCase() == lower && entry.value.isNotEmpty) {
return entry.value.first;
}
}
return null;
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
+139 -22
View File
@@ -1,7 +1,54 @@
import 'package:dio/dio.dart';
import 'api_service.dart';
import '../core/exceptions/app_exception.dart';
import '../data/models/share_model.dart';
import '../core/utils/app_logger.dart';
///
enum SharePrincipalType { user, group }
/// /
class SharePrincipal {
final String id;
final String name;
final String? email;
final String? groupName;
final SharePrincipalType type;
const SharePrincipal({
required this.id,
required this.name,
required this.type,
this.email,
this.groupName,
});
factory SharePrincipal.userFromJson(Map<String, dynamic> json) {
final group = _asMap(json['group']);
return SharePrincipal(
id: json['id']?.toString() ?? '',
name: (json['nickname'] ?? json['email'] ?? json['id'] ?? '用户').toString(),
email: json['email']?.toString(),
groupName: group?['name']?.toString(),
type: SharePrincipalType.user,
);
}
factory SharePrincipal.groupFromJson(Map<String, dynamic> json) {
return SharePrincipal(
id: json['id']?.toString() ?? '',
name: (json['name'] ?? json['id'] ?? '用户组').toString(),
type: SharePrincipalType.group,
);
}
static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
}
///
class ShareService {
/// cloudreve URI
@@ -17,33 +64,98 @@ class ShareService {
return 'cloudreve://my/$cleanPath';
}
///
///
///
/// Cloudreve V4 PUT /share permissionsuri
/// is_privateshare_viewexpirepricepasswordshow_readme
Future<String> createShare({
required String uri,
Map<String, dynamic>? permissions,
bool? isPrivate,
bool? shareView,
int? expire,
int? downloads,
int? price,
String? password,
bool? showReadme,
}) async {
final data = <String, dynamic>{
'permissions': {'anonymous': 'BQ==', 'everyone': 'AQ=='},
'permissions': permissions ?? {'anonymous': 'BQ==', 'everyone': 'AQ=='},
'uri': _toCloudreveUri(uri),
'is_private': ?isPrivate,
'share_view': ?shareView,
'expire': ?expire,
'price': ?price,
'password': ?password,
'show_readme': ?showReadme,
};
// , _parseResponse -> ApiResponse.fromJson
if (isPrivate != null) data['is_private'] = isPrivate;
if (shareView != null) data['share_view'] = shareView;
if (expire != null) data['expire'] = expire;
if (downloads != null) data['downloads'] = downloads;
if (price != null) data['price'] = price;
if (password != null && password.isNotEmpty) data['password'] = password;
if (showReadme != null) data['show_readme'] = showReadme;
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/share',
data: data,
isNoData: true,
);
return response['data'] as String;
final raw = response['data'];
return raw?.toString() ?? '';
}
///
Future<List<SharePrincipal>> searchUsers(String keyword) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) return const [];
final response = await ApiService.instance.get<dynamic>(
'/user/search',
queryParameters: {'keyword': trimmed},
);
return _extractList(response)
.whereType<Map>()
.map((e) => SharePrincipal.userFromJson(Map<String, dynamic>.from(e)))
.where((e) => e.id.isNotEmpty)
.toList();
}
///
///
/// Cloudreve Pro /group/list Pro null
///
Future<List<SharePrincipal>?> listGroups() async {
try {
final response = await ApiService.instance.get<dynamic>(
'/group/list',
silent404: true,
);
return _extractList(response)
.whereType<Map>()
.map((e) => SharePrincipal.groupFromJson(Map<String, dynamic>.from(e)))
.where((e) => e.id.isNotEmpty)
.toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
rethrow;
} on AppException catch (e) {
if (e.code == 404) return null;
rethrow;
}
}
List<dynamic> _extractList(dynamic value) {
if (value is List) return value;
if (value is Map) {
final data = value['data'];
if (data is List) return data;
final items = value['items'];
if (items is List) return items;
final groups = value['groups'];
if (groups is List) return groups;
final users = value['users'];
if (users is List) return users;
}
return const [];
}
///
@@ -55,11 +167,11 @@ class ShareService {
}) async {
final queryParams = <String, dynamic>{
'page_size': pageSize,
'order_by': ?orderBy,
'order_direction': ?orderDirection,
'next_page_token': ?nextPageToken,
};
// get, claude post, fixed
if (orderBy != null) queryParams['order_by'] = orderBy;
if (orderDirection != null) queryParams['order_direction'] = orderDirection;
if (nextPageToken != null) queryParams['next_page_token'] = nextPageToken;
return await ApiService.instance.get<Map<String, dynamic>>(
'/share',
queryParameters: queryParams,
@@ -79,13 +191,10 @@ class ShareService {
if (ownerExtended != null) {
queryParams['owner_extended'] = ownerExtended.toString();
}
// GET
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/share/info/$id',
queryParameters: queryParams,
);
// response _parseResponse -> ApiResponse.fromJson , ['data']
// return ShareModel.fromJson(response['data'] as Map<String, dynamic>);
return ShareModel.fromJson(response);
}
@@ -93,19 +202,26 @@ class ShareService {
Future<String> editShare({
required String id,
required String uri,
Map<String, dynamic>? permissions,
bool? isPrivate,
String? password,
bool? shareView,
int? downloads,
int? expire,
int? price,
bool? showReadme,
}) async {
final data = <String, dynamic>{
'uri': uri,
'is_private': ?isPrivate,
'share_view': ?shareView,
'downloads': ?downloads,
'expire': ?expire,
};
if (permissions != null) data['permissions'] = permissions;
if (isPrivate != null) data['is_private'] = isPrivate;
if (shareView != null) data['share_view'] = shareView;
if (downloads != null) data['downloads'] = downloads;
if (expire != null) data['expire'] = expire;
if (price != null) data['price'] = price;
if (showReadme != null) data['show_readme'] = showReadme;
if (password != null && password.isNotEmpty) {
data['password'] = password;
}
@@ -116,7 +232,8 @@ class ShareService {
data: data,
isNoData: true,
);
return response['data'] as String;
final raw = response['data'];
return raw?.toString() ?? '';
}
///
@@ -9,6 +9,7 @@
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h>
#include <flutter_inappwebview_linux/flutter_inappwebview_linux_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h>
@@ -27,6 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin");
flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar);
g_autoptr(FlPluginRegistrar) flutter_inappwebview_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterInappwebviewLinuxPlugin");
flutter_inappwebview_linux_plugin_register_with_registrar(flutter_inappwebview_linux_registrar);
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
file_selector_linux
flutter_acrylic
flutter_inappwebview_linux
media_kit_libs_linux
media_kit_video
open_file_linux
+73 -1
View File
@@ -298,7 +298,7 @@ packages:
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
@@ -390,6 +390,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "3952d116ee93bad2946401377e7ade87b5ef200e95ecb5ba1affa1b6329a6867"
url: "https://pub.dev"
source: hosted
version: "6.2.0-beta.3"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "8dfb76bd4e507112c3942c2272eeb01fab2e42be11374e5eb226f58698e7a04b"
url: "https://pub.dev"
source: hosted
version: "1.2.0-beta.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
url: "https://pub.dev"
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: ae8a78829398771be863aa3c8804a9d40728e1815e66c9c966f86d2cc3ae4fd9
url: "https://pub.dev"
source: hosted
version: "1.2.0-beta.3"
flutter_inappwebview_linux:
dependency: transitive
description:
name: flutter_inappwebview_linux
sha256: "2e1a3b09bb911fb5a8bb155cb7f1eb1428a19b6e20363b9db48beef428b8cef5"
url: "https://pub.dev"
source: hosted
version: "0.1.0-beta.1"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: "545148cb5c46475ce669ab21621e9f2ad66e05f8e80b2cf49d4018879ab52393"
url: "https://pub.dev"
source: hosted
version: "1.2.0-beta.3"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: e3522c76e6760d1c0a9ff690e30e1503f226783d3277fa4d26675911977e9766
url: "https://pub.dev"
source: hosted
version: "1.4.0-beta.3"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: e98b8875ccb6a3fd255873318db45c18ab135ed0ed22d20169abad9f5c810eb9
url: "https://pub.dev"
source: hosted
version: "1.2.0-beta.3"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "902edd6f6326952af822e21aa928f7426d723d45c94c15e6ce3c2d5640d28ad7"
url: "https://pub.dev"
source: hosted
version: "0.7.0-beta.3"
flutter_launcher_icons:
dependency: "direct dev"
description:
+3 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.2+3
version: 1.3.3+4
environment:
sdk: ^3.11.4
@@ -94,6 +94,8 @@ dependencies:
qr_flutter: ^4.1.0
webview_flutter: ^4.13.0
xml: ^6.6.1
flutter_inappwebview: ^6.2.0-beta.3
ffi: ^2.1.0
# 桌面端
window_manager: ^0.5.0
+62
View File
@@ -32,6 +32,56 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
# Use Unicode for all projects.
add_definitions(-DUNICODE -D_UNICODE)
# flutter_inappwebview_windows requires NuGet. The plugin's own CMake only
# calls find_program(NUGET nuget), so seed the cache with an absolute path.
if(DEFINED NUGET AND NOT NUGET STREQUAL "" AND NOT NUGET STREQUAL "NUGET-NOTFOUND")
if(NOT EXISTS "${NUGET}")
unset(NUGET CACHE)
unset(NUGET)
endif()
endif()
if(NOT DEFINED NUGET OR NUGET STREQUAL "" OR NUGET STREQUAL "NUGET-NOTFOUND")
set(_NUGET_CANDIDATES
"$ENV{SystemRoot}/System32/nuget.exe"
"$ENV{windir}/System32/nuget.exe"
"C:/Windows/System32/nuget.exe"
"$ENV{SystemRoot}/Sysnative/nuget.exe"
"$ENV{windir}/Sysnative/nuget.exe"
"C:/Windows/Sysnative/nuget.exe"
"$ENV{USERPROFILE}/.local/bin/nuget.exe"
"$ENV{LOCALAPPDATA}/Microsoft/WinGet/Links/nuget.exe"
"C:/ProgramData/chocolatey/bin/nuget.exe"
)
foreach(_NUGET_CANDIDATE IN LISTS _NUGET_CANDIDATES)
if(EXISTS "${_NUGET_CANDIDATE}")
set(NUGET "${_NUGET_CANDIDATE}")
break()
endif()
endforeach()
endif()
if(NOT DEFINED NUGET OR NUGET STREQUAL "" OR NUGET STREQUAL "NUGET-NOTFOUND")
find_program(NUGET NAMES nuget.exe nuget)
endif()
if(NUGET AND NOT NUGET STREQUAL "NUGET-NOTFOUND")
set(_PROJECT_NUGET "${CMAKE_BINARY_DIR}/nuget.exe")
execute_process(
COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${NUGET}" "${_PROJECT_NUGET}"
RESULT_VARIABLE _NUGET_COPY_RESULT
)
if(_NUGET_COPY_RESULT EQUAL 0 AND EXISTS "${_PROJECT_NUGET}")
set(NUGET "${_PROJECT_NUGET}")
else()
message(WARNING "Failed to copy NuGet to ${_PROJECT_NUGET}; using ${NUGET}")
endif()
message(STATUS "Found NuGet: ${NUGET}")
set(NUGET "${NUGET}" CACHE FILEPATH "nuget executable" FORCE)
else()
message(WARNING "NuGet executable was not found. Windows WebView builds may fail.")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
@@ -110,8 +160,20 @@ ENSURE_WINDOWS_ARCHIVE(
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
if(POLICY CMP0175)
# flutter_inappwebview_windows 0.7.0-beta.3 passes DEPENDS to the TARGET
# form of add_custom_command. Keep older CMake behavior for plugins that do
# not set this policy themselves.
set(CMAKE_POLICY_DEFAULT_CMP0175 OLD)
endif()
include(flutter/generated_plugins.cmake)
if(MSVC)
if(TARGET flutter_inappwebview_windows_plugin)
# flutter_inappwebview MSVC ,
target_compile_options(flutter_inappwebview_windows_plugin PRIVATE /W0)
endif()
endif()
# === Installation ===
# Support files are copied into place next to the executable, so that it can
@@ -9,6 +9,7 @@
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterAcrylicPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterAcrylicPlugin"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
file_selector_windows
flutter_acrylic
flutter_inappwebview_windows
media_kit_libs_windows_video
media_kit_video
permission_handler_windows