1736 lines
52 KiB
Dart
1736 lines
52 KiB
Dart
import 'dart:async';
|
||
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:lucide_icons/lucide_icons.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:webview_flutter/webview_flutter.dart' as mobile;
|
||
|
||
import '../../../core/utils/app_logger.dart';
|
||
import '../../../services/api_service.dart';
|
||
import '../../../services/share_link_service.dart';
|
||
import '../../providers/download_manager_provider.dart';
|
||
import '../../widgets/folder_picker.dart';
|
||
import '../../widgets/user_avatar.dart';
|
||
|
||
bool get _useDesktopWebView =>
|
||
!kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||
|
||
class ShareLinkPage extends StatefulWidget {
|
||
final ShareLinkCandidate candidate;
|
||
|
||
const ShareLinkPage({super.key, required this.candidate});
|
||
|
||
@override
|
||
State<ShareLinkPage> createState() => _ShareLinkPageState();
|
||
}
|
||
|
||
class _ShareLinkPageState extends State<ShareLinkPage> {
|
||
final TextEditingController _passwordController = TextEditingController();
|
||
final List<_ShareBreadcrumb> _breadcrumbs = <_ShareBreadcrumb>[];
|
||
|
||
ShareLinkInfo? _info;
|
||
ShareLinkFile? _singleFile;
|
||
List<ShareLinkFile> _files = const [];
|
||
Object? _error;
|
||
Object? _fileError;
|
||
String? _contextHint;
|
||
String? _currentUri;
|
||
bool _loadingInfo = true;
|
||
bool _loadingFiles = false;
|
||
bool _openingDownload = false;
|
||
bool _saving = false;
|
||
|
||
String get _displayUrl => _info?.url ?? widget.candidate.url;
|
||
|
||
bool get _isSameOrigin {
|
||
final shareUri = Uri.tryParse(widget.candidate.url);
|
||
final baseUri = Uri.tryParse(ApiService.instance.dio.options.baseUrl);
|
||
if (shareUri == null || baseUri == null) return false;
|
||
final same = shareUri.host == baseUri.host;
|
||
AppLogger.d(
|
||
'ShareLinkPage _isSameOrigin: shareHost=${shareUri.host}, baseHost=${baseUri.host}, result=$same',
|
||
);
|
||
return same;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_passwordController.text = widget.candidate.password ?? '';
|
||
_loadShare(password: widget.candidate.password);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_passwordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadShare({String? password}) async {
|
||
setState(() {
|
||
_loadingInfo = true;
|
||
_error = null;
|
||
_fileError = null;
|
||
_files = const [];
|
||
_singleFile = null;
|
||
_contextHint = null;
|
||
_currentUri = null;
|
||
_breadcrumbs.clear();
|
||
});
|
||
|
||
try {
|
||
final info = await ShareLinkService.instance.getShareInfo(
|
||
widget.candidate,
|
||
password: password?.trim().isEmpty == true ? null : password,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
|
||
setState(() {
|
||
_info = info;
|
||
_contextHint = info.contextHint ?? _contextHint;
|
||
_loadingInfo = false;
|
||
});
|
||
|
||
final sourceUri = info.sourceUri;
|
||
if (info.expired || !info.unlocked || sourceUri == null) return;
|
||
|
||
if (info.isFolder) {
|
||
await _loadFolder(sourceUri, resetBreadcrumbs: true, title: info.name);
|
||
} else {
|
||
await _loadSingleFile(sourceUri);
|
||
}
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_error = e;
|
||
_loadingInfo = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _loadSingleFile(String uri) async {
|
||
setState(() {
|
||
_loadingFiles = true;
|
||
_fileError = null;
|
||
_currentUri = uri;
|
||
});
|
||
|
||
// Cloudreve 官方分享页会把 /s/{id} 重定向到
|
||
// /home?path=cloudreve://{id}@share,然后按普通文件列表读取 share 文件系统。
|
||
// 单文件分享也要先读取 share 根目录,拿到服务端返回的真实文件 path/context_hint,
|
||
// 再用这个 path 调 /file/url。直接拿 share 根 URI 下载会得到 40081。
|
||
try {
|
||
final list = await ShareLinkService.instance.listSharedFiles(
|
||
uri: uri,
|
||
contextHint: _contextHint,
|
||
);
|
||
if (list.files.isNotEmpty) {
|
||
final file = list.files.first;
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_singleFile = file;
|
||
_contextHint = file.contextHint ?? list.contextHint ?? _contextHint;
|
||
_currentUri = file.path.isNotEmpty ? file.path : uri;
|
||
_loadingFiles = false;
|
||
});
|
||
return;
|
||
}
|
||
if (list.contextHint != null && list.contextHint!.isNotEmpty) {
|
||
_contextHint = list.contextHint;
|
||
}
|
||
} catch (_) {
|
||
// 某些服务端不允许对单文件分享根目录执行 /file,继续走 /file/info 降级。
|
||
}
|
||
|
||
try {
|
||
final file = await ShareLinkService.instance.getSharedFileInfo(
|
||
uri: uri,
|
||
contextHint: _contextHint,
|
||
shareId: widget.candidate.id,
|
||
password: widget.candidate.password,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_singleFile = file;
|
||
_contextHint = file.contextHint ?? _contextHint;
|
||
_currentUri = file.path.isNotEmpty ? file.path : uri;
|
||
_loadingFiles = false;
|
||
});
|
||
} catch (e) {
|
||
// 部分服务端在公开分享上下文中不允许读取 /file/info,
|
||
// 但仍允许通过 /file/url 下载或 /file/move 转存。这里降级为分享信息卡片,
|
||
// 避免页面出现大块错误提示。
|
||
final info = _info;
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_singleFile = info == null
|
||
? null
|
||
: ShareLinkService.instance.fileFromShareInfo(info);
|
||
_contextHint =
|
||
_singleFile?.contextHint ?? _contextHint ?? info?.contextHint;
|
||
_fileError = null;
|
||
_loadingFiles = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _loadFolder(
|
||
String uri, {
|
||
bool resetBreadcrumbs = false,
|
||
String? title,
|
||
}) async {
|
||
setState(() {
|
||
_loadingFiles = true;
|
||
_fileError = null;
|
||
_currentUri = uri;
|
||
if (resetBreadcrumbs) {
|
||
_breadcrumbs
|
||
..clear()
|
||
..add(_ShareBreadcrumb(title: title ?? '分享目录', uri: uri));
|
||
}
|
||
});
|
||
|
||
try {
|
||
final result = await ShareLinkService.instance.listSharedFiles(
|
||
uri: uri,
|
||
contextHint: _contextHint,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_files = result.files;
|
||
_contextHint = result.contextHint ?? _contextHint;
|
||
_loadingFiles = false;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_fileError = e;
|
||
_loadingFiles = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
final info = _info;
|
||
if (info == null) {
|
||
await _loadShare(password: _passwordController.text.trim());
|
||
return;
|
||
}
|
||
|
||
final uri = _currentUri ?? info.sourceUri;
|
||
if (uri == null || !info.unlocked || info.expired) {
|
||
await _loadShare(password: _passwordController.text.trim());
|
||
return;
|
||
}
|
||
|
||
if (info.isFolder) {
|
||
await _loadFolder(uri);
|
||
} else {
|
||
await _loadSingleFile(uri);
|
||
}
|
||
}
|
||
|
||
Future<void> _openExternal() async {
|
||
final uri = Uri.tryParse(_displayUrl);
|
||
if (uri == null) return;
|
||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
}
|
||
|
||
Future<void> _openDownloadUrl(
|
||
String uri, {
|
||
required String fileName,
|
||
required int fileSize,
|
||
String? entity,
|
||
bool archive = false,
|
||
}) async {
|
||
if (_openingDownload) return;
|
||
|
||
setState(() => _openingDownload = true);
|
||
try {
|
||
final result = await ShareLinkService.instance.createShareDownloadUrl(
|
||
uri: uri,
|
||
contextHint: _contextHint,
|
||
shareId: widget.candidate.id,
|
||
password: widget.candidate.password,
|
||
entity: entity,
|
||
fileName: fileName,
|
||
archive: archive,
|
||
);
|
||
|
||
await _enqueueInAppDownload(
|
||
result.url,
|
||
fileName: fileName,
|
||
fileSize: fileSize,
|
||
fileUri: uri,
|
||
);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
|
||
final fallbackUrl = await _obtainDownloadUrlFromOfficialPage(
|
||
fileName: fileName,
|
||
);
|
||
if (fallbackUrl != null && fallbackUrl.isNotEmpty) {
|
||
try {
|
||
await _enqueueInAppDownload(
|
||
fallbackUrl,
|
||
fileName: fileName,
|
||
fileSize: fileSize,
|
||
fileUri: uri,
|
||
);
|
||
return;
|
||
} catch (downloadError) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('创建下载任务失败:$downloadError')));
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('获取下载链接失败:$e'),
|
||
action: SnackBarAction(label: '浏览器打开', onPressed: _openExternal),
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _openingDownload = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _enqueueInAppDownload(
|
||
String rawUrl, {
|
||
required String fileName,
|
||
required int fileSize,
|
||
required String fileUri,
|
||
}) async {
|
||
final downloadUri = Uri.tryParse(rawUrl);
|
||
if (downloadUri == null || !downloadUri.hasScheme) {
|
||
throw Exception('下载链接格式错误');
|
||
}
|
||
|
||
final task = await context.read<DownloadManagerProvider>().addDownloadTask(
|
||
fileName: fileName,
|
||
fileUri: fileUri,
|
||
fileSize: fileSize,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(task == null ? '该文件已在下载队列中' : '已添加到下载队列,正在下载')),
|
||
);
|
||
}
|
||
|
||
Future<String?> _obtainDownloadUrlFromOfficialPage({
|
||
required String fileName,
|
||
}) async {
|
||
final shareUri = Uri.tryParse(widget.candidate.url);
|
||
if (shareUri == null) return null;
|
||
|
||
final completer = Completer<String?>();
|
||
mobile.WebViewController? mobileController;
|
||
BuildContext? dialogContext;
|
||
Timer? timeoutTimer;
|
||
|
||
void complete(String? url) {
|
||
if (completer.isCompleted) return;
|
||
completer.complete(url);
|
||
}
|
||
|
||
if (!_useDesktopWebView) {
|
||
mobileController = mobile.WebViewController()
|
||
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||
..addJavaScriptChannel(
|
||
'CloudreveDownloadBridge',
|
||
onMessageReceived: (message) {
|
||
final url = _extractOfficialDownloadUrl(
|
||
message.message,
|
||
shareUri: shareUri,
|
||
);
|
||
if (url != null && url.isNotEmpty) {
|
||
complete(url);
|
||
}
|
||
},
|
||
)
|
||
..setNavigationDelegate(
|
||
mobile.NavigationDelegate(
|
||
onPageFinished: (_) async {
|
||
await _installOfficialDownloadHookForMobile(mobileController!);
|
||
},
|
||
onNavigationRequest: (request) {
|
||
final url = request.url;
|
||
if (_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) {
|
||
complete(url);
|
||
return mobile.NavigationDecision.prevent;
|
||
}
|
||
return mobile.NavigationDecision.navigate;
|
||
},
|
||
onUrlChange: (change) {
|
||
final url = change.url;
|
||
if (url != null &&
|
||
_looksLikeDirectDownloadUrl(url, shareUri: shareUri)) {
|
||
complete(url);
|
||
}
|
||
},
|
||
),
|
||
)
|
||
..loadRequest(shareUri);
|
||
}
|
||
|
||
timeoutTimer = Timer(const Duration(seconds: 24), () => complete(null));
|
||
|
||
completer.future.then((url) {
|
||
final ctx = dialogContext;
|
||
if (!mounted && !context.mounted) return;
|
||
if (ctx != null && Navigator.of(ctx, rootNavigator: true).canPop()) {
|
||
Navigator.of(ctx, rootNavigator: true).pop(url);
|
||
}
|
||
});
|
||
|
||
final result = await showDialog<String?>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) {
|
||
dialogContext = context;
|
||
final theme = Theme.of(context);
|
||
return AlertDialog(
|
||
title: const Text('正在获取下载链接'),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'正在按 Cloudreve 官方分享页面流程获取「$fileName」的真实下载地址,获取成功后会加入应用内下载任务。',
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
const SizedBox(height: 14),
|
||
const LinearProgressIndicator(),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
'不会自动跳转浏览器;浏览器入口只作为手动备用方案。',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.hintColor,
|
||
),
|
||
),
|
||
const SizedBox(height: 1),
|
||
SizedBox(
|
||
width: 1,
|
||
height: 1,
|
||
child: Opacity(
|
||
opacity: 0.01,
|
||
child: _useDesktopWebView
|
||
? _OfficialDownloadDesktopWebView(
|
||
shareUri: shareUri,
|
||
onBridgeMessage: (message) {
|
||
final url = _extractOfficialDownloadUrl(
|
||
message,
|
||
shareUri: shareUri,
|
||
);
|
||
if (url != null && url.isNotEmpty) {
|
||
complete(url);
|
||
}
|
||
},
|
||
onNavigationUrl: (url) {
|
||
if (_looksLikeDirectDownloadUrl(
|
||
url,
|
||
shareUri: shareUri,
|
||
)) {
|
||
complete(url);
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
buildHookScript: _officialDownloadHookScript,
|
||
buildClickScript: _officialDownloadClickScript,
|
||
)
|
||
: mobile.WebViewWidget(controller: mobileController!),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => complete(null),
|
||
child: const Text('取消'),
|
||
),
|
||
TextButton(onPressed: _openExternal, child: const Text('浏览器打开')),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
|
||
timeoutTimer.cancel();
|
||
return result;
|
||
}
|
||
|
||
Future<void> _installOfficialDownloadHookForMobile(
|
||
mobile.WebViewController controller,
|
||
) async {
|
||
try {
|
||
await controller.runJavaScript(_officialDownloadHookScript);
|
||
await controller.runJavaScript(_officialDownloadClickScript);
|
||
} catch (_) {
|
||
// The outer flow times out and leaves the browser fallback available.
|
||
}
|
||
}
|
||
|
||
static const String _officialDownloadHookScript = r"""
|
||
(function () {
|
||
if (window.__cloudreveAppDownloadHookInstalled) return;
|
||
window.__cloudreveAppDownloadHookInstalled = true;
|
||
|
||
function post(value) {
|
||
try {
|
||
if (typeof value !== 'string') value = JSON.stringify(value);
|
||
if (typeof CloudreveDownloadBridge !== 'undefined' && CloudreveDownloadBridge.postMessage) {
|
||
CloudreveDownloadBridge.postMessage(value);
|
||
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
|
||
window.flutter_inappwebview.callHandler('CloudreveDownloadBridge', value);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function inspectText(text) {
|
||
if (!text) return;
|
||
try {
|
||
post(text);
|
||
} catch (e) {}
|
||
}
|
||
|
||
var rawFetch = window.fetch;
|
||
if (rawFetch) {
|
||
window.fetch = function () {
|
||
var req = arguments[0];
|
||
var reqUrl = '';
|
||
try { reqUrl = String((req && req.url) || req || ''); } catch (e) {}
|
||
return rawFetch.apply(this, arguments).then(function (resp) {
|
||
try {
|
||
var respUrl = String(resp && resp.url ? resp.url : reqUrl);
|
||
if (respUrl.indexOf('/api/v4/file/url') >= 0 || reqUrl.indexOf('/api/v4/file/url') >= 0) {
|
||
resp.clone().text().then(inspectText).catch(function () {});
|
||
}
|
||
} catch (e) {}
|
||
return resp;
|
||
});
|
||
};
|
||
}
|
||
|
||
var RawXHR = window.XMLHttpRequest;
|
||
if (RawXHR) {
|
||
var rawXHROpen = RawXHR.prototype.open;
|
||
var rawXHRSend = RawXHR.prototype.send;
|
||
RawXHR.prototype.open = function (method, url) {
|
||
this.__cloudreveRequestUrl = String(url || '');
|
||
return rawXHROpen.apply(this, arguments);
|
||
};
|
||
RawXHR.prototype.send = function () {
|
||
try {
|
||
this.addEventListener('load', function () {
|
||
try {
|
||
var url = String(this.responseURL || this.__cloudreveRequestUrl || '');
|
||
if (url.indexOf('/api/v4/file/url') >= 0) {
|
||
inspectText(String(this.responseText || ''));
|
||
}
|
||
} catch (e) {}
|
||
});
|
||
} catch (e) {}
|
||
return rawXHRSend.apply(this, arguments);
|
||
};
|
||
}
|
||
|
||
var rawOpen = window.open;
|
||
window.open = function (url) {
|
||
try { post({ __navigation_url: String(url || '') }); } catch (e) {}
|
||
return rawOpen ? rawOpen.apply(this, arguments) : null;
|
||
};
|
||
|
||
document.addEventListener('click', function (event) {
|
||
try {
|
||
var target = event.target;
|
||
var href = '';
|
||
while (target && !href) {
|
||
href = target.href || target.getAttribute && target.getAttribute('href') || '';
|
||
target = target.parentElement;
|
||
}
|
||
if (href) post({ __navigation_url: String(href) });
|
||
} catch (e) {}
|
||
}, true);
|
||
})();
|
||
""";
|
||
|
||
static const String _officialDownloadClickScript = r"""
|
||
(function () {
|
||
function visible(el) {
|
||
if (!el) return false;
|
||
var style = window.getComputedStyle(el);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
var rect = el.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
|
||
function clickDownload() {
|
||
var nodes = Array.prototype.slice.call(document.querySelectorAll('button,a,[role="button"],div,span'));
|
||
var best = null;
|
||
for (var i = 0; i < nodes.length; i++) {
|
||
var el = nodes[i];
|
||
var text = [
|
||
el.innerText || '',
|
||
el.textContent || '',
|
||
el.getAttribute && el.getAttribute('aria-label') || '',
|
||
el.getAttribute && el.getAttribute('title') || '',
|
||
el.className || ''
|
||
].join(' ').trim();
|
||
if (!text || (text.indexOf('下载') < 0 && text.toLowerCase().indexOf('download') < 0)) continue;
|
||
if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue;
|
||
best = el;
|
||
break;
|
||
}
|
||
if (best) {
|
||
try {
|
||
best.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||
best.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
|
||
best.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||
} catch (e) {
|
||
best.click();
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
var tries = 0;
|
||
var timer = setInterval(function () {
|
||
tries++;
|
||
if (clickDownload() || tries > 30) {
|
||
clearInterval(timer);
|
||
}
|
||
}, 500);
|
||
})();
|
||
""";
|
||
|
||
String? _extractOfficialDownloadUrl(String message, {required Uri shareUri}) {
|
||
final text = message.trim();
|
||
if (text.isEmpty) return null;
|
||
|
||
final direct = _normalizeOfficialUrl(text, shareUri: shareUri);
|
||
if (direct != null &&
|
||
_looksLikeDirectDownloadUrl(direct, shareUri: shareUri)) {
|
||
return direct;
|
||
}
|
||
|
||
try {
|
||
final decoded = jsonDecode(text);
|
||
return _walkOfficialPayloadForUrl(decoded, shareUri: shareUri);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
String? _walkOfficialPayloadForUrl(dynamic value, {required Uri shareUri}) {
|
||
if (value == null) return null;
|
||
|
||
if (value is String) {
|
||
final normalized = _normalizeOfficialUrl(value, shareUri: shareUri);
|
||
if (normalized != null &&
|
||
_looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) {
|
||
return normalized;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (value is List) {
|
||
for (final item in value) {
|
||
final url = _walkOfficialPayloadForUrl(item, shareUri: shareUri);
|
||
if (url != null) return url;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (value is Map) {
|
||
final navigationUrl = value['__navigation_url'];
|
||
if (navigationUrl is String) {
|
||
final normalized = _normalizeOfficialUrl(
|
||
navigationUrl,
|
||
shareUri: shareUri,
|
||
);
|
||
if (normalized != null &&
|
||
_looksLikeDirectDownloadUrl(normalized, shareUri: shareUri)) {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
const priorityKeys = [
|
||
'url',
|
||
'download_url',
|
||
'downloadUrl',
|
||
'href',
|
||
'src',
|
||
'signed_url',
|
||
'signedUrl',
|
||
'link',
|
||
'urls',
|
||
'data',
|
||
];
|
||
|
||
for (final key in priorityKeys) {
|
||
if (!value.containsKey(key)) continue;
|
||
final url = _walkOfficialPayloadForUrl(value[key], shareUri: shareUri);
|
||
if (url != null) return url;
|
||
}
|
||
|
||
for (final item in value.values) {
|
||
final url = _walkOfficialPayloadForUrl(item, shareUri: shareUri);
|
||
if (url != null) return url;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
String? _normalizeOfficialUrl(String value, {required Uri shareUri}) {
|
||
final text = value.trim();
|
||
if (text.isEmpty) return null;
|
||
final uri = Uri.tryParse(text);
|
||
if (uri == null) return null;
|
||
|
||
if (uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https')) {
|
||
return uri.toString();
|
||
}
|
||
|
||
if (text.startsWith('/')) {
|
||
return shareUri.replace(path: text, query: '', fragment: '').toString();
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
bool _looksLikeDirectDownloadUrl(String value, {required Uri shareUri}) {
|
||
final uri = Uri.tryParse(value);
|
||
if (uri == null || !uri.hasScheme) return false;
|
||
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
|
||
|
||
final sameHost = uri.host == shareUri.host;
|
||
final path = uri.path.toLowerCase();
|
||
|
||
if (sameHost) {
|
||
if (path.startsWith('/s/') ||
|
||
path == '/home' ||
|
||
path.startsWith('/home/')) {
|
||
return false;
|
||
}
|
||
if (path.contains('/api/v4/file/url')) return false;
|
||
if (path.contains('/api/v4/share/info')) return false;
|
||
}
|
||
|
||
final url = uri.toString().toLowerCase();
|
||
return !sameHost ||
|
||
path.contains('/download') ||
|
||
path.contains('/api/v4/file/download') ||
|
||
path.contains('/api/v4/file/source') ||
|
||
url.contains('response-content-disposition') ||
|
||
url.contains('x-amz-signature') ||
|
||
url.contains('x-oss-signature') ||
|
||
url.contains('signature=') ||
|
||
url.contains('sign=') ||
|
||
url.contains('token=');
|
||
}
|
||
|
||
Future<void> _saveSharedUri(String uri, {required String name}) async {
|
||
if (_saving) return;
|
||
|
||
final destination = await _pickDestination();
|
||
if (destination == null) return;
|
||
|
||
setState(() => _saving = true);
|
||
try {
|
||
final isRootShareFolder =
|
||
ShareLinkService.isShareRootUri(uri, shareId: widget.candidate.id) &&
|
||
_files.isNotEmpty;
|
||
final urisToSave = isRootShareFolder
|
||
? _files
|
||
.map((file) => file.path)
|
||
.where((path) => path.trim().isNotEmpty)
|
||
.toList()
|
||
: <String>[uri];
|
||
|
||
await ShareLinkService.instance.saveSharedFiles(
|
||
uris: urisToSave,
|
||
destination: destination,
|
||
contextHint: _contextHint,
|
||
shareId: widget.candidate.id,
|
||
password: widget.candidate.password,
|
||
fileName: name,
|
||
);
|
||
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('已转存「$name」到 $destination')));
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('转存失败:$e')));
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
Future<String?> _pickDestination() {
|
||
return showModalBottomSheet<String>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
showDragHandle: true,
|
||
builder: (context) {
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 16,
|
||
right: 16,
|
||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'选择转存位置',
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
|
||
),
|
||
const SizedBox(height: 12),
|
||
FolderPicker(
|
||
currentPath: '/',
|
||
maxVisibleItems: 7,
|
||
onFolderSelected: (path) => Navigator.of(context).pop(path),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _enterFolder(ShareLinkFile file) async {
|
||
if (!file.isFolder || file.path.isEmpty) return;
|
||
_breadcrumbs.add(_ShareBreadcrumb(title: file.name, uri: file.path));
|
||
await _loadFolder(file.path);
|
||
}
|
||
|
||
Future<void> _jumpToBreadcrumb(int index) async {
|
||
if (index < 0 || index >= _breadcrumbs.length) return;
|
||
final crumb = _breadcrumbs[index];
|
||
_breadcrumbs.removeRange(index + 1, _breadcrumbs.length);
|
||
await _loadFolder(crumb.uri);
|
||
}
|
||
|
||
Future<bool> _handleBack() async {
|
||
if (_breadcrumbs.length > 1) {
|
||
final target = _breadcrumbs[_breadcrumbs.length - 2];
|
||
_breadcrumbs.removeLast();
|
||
await _loadFolder(target.uri);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return PopScope(
|
||
canPop: _breadcrumbs.length <= 1,
|
||
onPopInvokedWithResult: (didPop, result) async {
|
||
if (!didPop) {
|
||
await _handleBack();
|
||
}
|
||
},
|
||
child: Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('文件分享'),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: '浏览器打开',
|
||
icon: const Icon(LucideIcons.externalLink),
|
||
onPressed: _openExternal,
|
||
),
|
||
IconButton(
|
||
tooltip: '刷新',
|
||
icon: const Icon(Icons.refresh),
|
||
onPressed: _refresh,
|
||
),
|
||
],
|
||
),
|
||
body: RefreshIndicator(
|
||
onRefresh: _refresh,
|
||
child: ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 24),
|
||
children: [
|
||
_buildHeader(context),
|
||
const SizedBox(height: 14),
|
||
_buildContent(context),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final info = _info;
|
||
|
||
return Card(
|
||
elevation: 0,
|
||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (_loadingInfo && info == null)
|
||
const LinearProgressIndicator()
|
||
else if (info != null)
|
||
_buildOwnerBlock(context, info)
|
||
else
|
||
_buildLoadingOwnerFallback(context),
|
||
if (info != null) ...[
|
||
const SizedBox(height: 14),
|
||
_buildShareInfoBlock(context, info),
|
||
],
|
||
if (_error != null) ...[
|
||
const SizedBox(height: 12),
|
||
_ErrorBox(text: '分享信息读取失败:$_error'),
|
||
],
|
||
if (_shouldShowPasswordInput(info)) ...[
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _passwordController,
|
||
decoration: const InputDecoration(
|
||
labelText: '分享密码',
|
||
isDense: true,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
onSubmitted: (_) =>
|
||
_loadShare(password: _passwordController.text.trim()),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
FilledButton(
|
||
onPressed: _loadingInfo
|
||
? null
|
||
: () => _loadShare(
|
||
password: _passwordController.text.trim(),
|
||
),
|
||
child: const Text('解锁'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLoadingOwnerFallback(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Row(
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 28,
|
||
backgroundColor: theme.colorScheme.primaryContainer,
|
||
child: Icon(
|
||
Icons.ios_share,
|
||
color: theme.colorScheme.onPrimaryContainer,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
'分享链接',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildOwnerBlock(BuildContext context, ShareLinkInfo info) {
|
||
final theme = Theme.of(context);
|
||
final ownerName = info.ownerName?.trim().isNotEmpty == true
|
||
? info.ownerName!.trim()
|
||
: '匿名用户';
|
||
final ownerId = info.ownerId ?? '';
|
||
|
||
return Row(
|
||
children: [
|
||
_isSameOrigin
|
||
? UserAvatar(userId: ownerId, displayName: ownerName, radius: 29)
|
||
: CircleAvatar(
|
||
radius: 29,
|
||
backgroundColor: theme.colorScheme.primaryContainer,
|
||
child: Text(
|
||
'匿',
|
||
style: TextStyle(
|
||
color: theme.colorScheme.onPrimaryContainer,
|
||
fontWeight: FontWeight.w900,
|
||
fontSize: 20,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 13),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
ownerName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'向您分享了 ${info.isFolder ? '一个文件夹' : '一个文件'}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.hintColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_StatusBadge(
|
||
text: info.expired ? '已过期' : '有效',
|
||
color: info.expired
|
||
? theme.colorScheme.error
|
||
: theme.colorScheme.primary,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildShareInfoBlock(BuildContext context, ShareLinkInfo info) {
|
||
final theme = Theme.of(context);
|
||
final file = _singleFile;
|
||
final sizeText = file != null && file.isFile && file.size > 0
|
||
? _ShareFileTile.formatSize(file.size)
|
||
: null;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(13),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface.withValues(alpha: 0.72),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
info.isFolder ? LucideIcons.folder : LucideIcons.file,
|
||
size: 22,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
info.name,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
_MetaChip(icon: LucideIcons.eye, text: '${info.visited} 次访问'),
|
||
if (sizeText != null)
|
||
_MetaChip(icon: Icons.sd_storage_outlined, text: sizeText),
|
||
if (info.createdAt != null)
|
||
_MetaChip(
|
||
icon: LucideIcons.calendar,
|
||
text: '${_formatDate(info.createdAt!)} 创建',
|
||
),
|
||
if (info.expires != null)
|
||
_MetaChip(
|
||
icon: LucideIcons.clock,
|
||
text: '${_formatDate(info.expires!)} 过期',
|
||
),
|
||
if (info.isPrivate)
|
||
const _MetaChip(icon: LucideIcons.lock, text: '私密分享'),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _shouldShowPasswordInput(ShareLinkInfo? info) {
|
||
if (info == null) return _error != null;
|
||
return info.isPrivate && !info.unlocked;
|
||
}
|
||
|
||
Widget _buildContent(BuildContext context) {
|
||
final info = _info;
|
||
|
||
if (_loadingInfo && info == null) {
|
||
return const Padding(
|
||
padding: EdgeInsets.only(top: 80),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
|
||
if (info == null) {
|
||
return _EmptyState(
|
||
icon: LucideIcons.link,
|
||
title: '无法打开分享',
|
||
subtitle: '请检查链接是否正确,或输入分享密码后重试。',
|
||
actionText: '浏览器打开',
|
||
onAction: _openExternal,
|
||
);
|
||
}
|
||
|
||
if (info.expired) {
|
||
return const _EmptyState(
|
||
icon: LucideIcons.clock,
|
||
title: '分享已过期',
|
||
subtitle: '这个分享链接已经失效。',
|
||
);
|
||
}
|
||
|
||
if (info.isPrivate && !info.unlocked) {
|
||
return const _EmptyState(
|
||
icon: LucideIcons.lock,
|
||
title: '需要分享密码',
|
||
subtitle: '输入正确的分享密码后即可查看文件。',
|
||
);
|
||
}
|
||
|
||
if (info.isFile) {
|
||
return _buildSingleFileCard(context, info);
|
||
}
|
||
|
||
return _buildFolderList(context, info);
|
||
}
|
||
|
||
Widget _buildSingleFileCard(BuildContext context, ShareLinkInfo info) {
|
||
final file =
|
||
_singleFile ?? ShareLinkService.instance.fileFromShareInfo(info);
|
||
final sourceUri = (info.sourceUri?.trim().isNotEmpty == true)
|
||
? info.sourceUri
|
||
: (file.path.trim().isNotEmpty ? file.path : null);
|
||
final primaryEntity = file.primaryEntity;
|
||
|
||
return Card(
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(18),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(LucideIcons.file, size: 34),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
info.name,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.titleMedium
|
||
?.copyWith(fontWeight: FontWeight.w900),
|
||
),
|
||
...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'${file.size > 0 ? _ShareFileTile.formatSize(file.size) : '分享文件'}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}',
|
||
style: Theme.of(context).textTheme.bodySmall
|
||
?.copyWith(color: Theme.of(context).hintColor),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_loadingFiles) ...[
|
||
const SizedBox(height: 14),
|
||
const LinearProgressIndicator(),
|
||
],
|
||
if (_fileError != null) ...[
|
||
const SizedBox(height: 14),
|
||
_ErrorBox(
|
||
text: '文件详情读取失败,仍可尝试下载或转存:$_fileError',
|
||
actionText: sourceUri == null ? null : '重试',
|
||
onAction: sourceUri == null
|
||
? null
|
||
: () => _loadSingleFile(sourceUri),
|
||
),
|
||
],
|
||
const SizedBox(height: 18),
|
||
_buildActionButtons(
|
||
uri: sourceUri,
|
||
name: info.name,
|
||
fileSize: file.size,
|
||
entity: primaryEntity,
|
||
archive: false,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildActionButtons({
|
||
required String? uri,
|
||
required String name,
|
||
required int fileSize,
|
||
String? entity,
|
||
bool archive = false,
|
||
}) {
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
child: FilledButton.tonalIcon(
|
||
onPressed: uri == null || uri.isEmpty || _saving || !_isSameOrigin
|
||
? null
|
||
: () => _saveSharedUri(uri, name: name),
|
||
icon: _saving
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.drive_folder_upload_outlined, size: 18),
|
||
label: const Text('转存'),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: FilledButton.icon(
|
||
onPressed: uri == null || uri.isEmpty || _openingDownload
|
||
? null
|
||
: () => _openDownloadUrl(
|
||
uri,
|
||
fileName: name,
|
||
fileSize: fileSize,
|
||
entity: entity,
|
||
archive: archive,
|
||
),
|
||
icon: _openingDownload
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(LucideIcons.download, size: 18),
|
||
label: const Text('下载'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildFolderList(BuildContext context, ShareLinkInfo info) {
|
||
final theme = Theme.of(context);
|
||
final sourceUri = _currentUri ?? info.sourceUri;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (_breadcrumbs.isNotEmpty) _buildBreadcrumbs(context),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'文件列表',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildActionButtons(
|
||
uri: sourceUri,
|
||
name: info.name,
|
||
fileSize: 0,
|
||
archive: true,
|
||
),
|
||
const SizedBox(height: 12),
|
||
if (_loadingFiles)
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 32),
|
||
child: Center(child: CircularProgressIndicator()),
|
||
)
|
||
else if (_fileError != null)
|
||
_ErrorBox(
|
||
text: '文件列表读取失败:$_fileError',
|
||
actionText: '重试',
|
||
onAction: sourceUri == null ? null : () => _loadFolder(sourceUri),
|
||
)
|
||
else if (_files.isEmpty)
|
||
const _EmptyState(
|
||
icon: LucideIcons.folderOpen,
|
||
title: '文件夹为空',
|
||
subtitle: '这个分享目录下没有文件。',
|
||
)
|
||
else
|
||
..._files.map(
|
||
(file) => _ShareFileTile(
|
||
file: file,
|
||
onTap: file.isFolder ? () => _enterFolder(file) : null,
|
||
onDownload: file.isFile
|
||
? () => _openDownloadUrl(
|
||
file.path,
|
||
fileName: file.name,
|
||
fileSize: file.size,
|
||
entity: file.primaryEntity,
|
||
)
|
||
: null,
|
||
onSave: _isSameOrigin
|
||
? () => _saveSharedUri(file.path, name: file.name)
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildBreadcrumbs(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||
alpha: 0.55,
|
||
),
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: List.generate(_breadcrumbs.length, (index) {
|
||
final item = _breadcrumbs[index];
|
||
final isLast = index == _breadcrumbs.length - 1;
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
InkWell(
|
||
onTap: isLast ? null : () => _jumpToBreadcrumb(index),
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 5,
|
||
),
|
||
child: Text(
|
||
item.title,
|
||
style: TextStyle(
|
||
fontWeight: isLast ? FontWeight.w800 : FontWeight.w500,
|
||
color: isLast
|
||
? theme.colorScheme.primary
|
||
: theme.hintColor,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (!isLast)
|
||
Icon(Icons.chevron_right, size: 18, color: theme.hintColor),
|
||
],
|
||
);
|
||
}),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static String _formatDate(DateTime value) {
|
||
final local = value.toLocal();
|
||
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
|
||
}
|
||
}
|
||
|
||
class _ShareBreadcrumb {
|
||
final String title;
|
||
final String uri;
|
||
|
||
const _ShareBreadcrumb({required this.title, required this.uri});
|
||
}
|
||
|
||
class _OfficialDownloadDesktopWebView extends StatefulWidget {
|
||
final Uri shareUri;
|
||
final ValueChanged<String> onBridgeMessage;
|
||
final bool Function(String url) onNavigationUrl;
|
||
final String buildHookScript;
|
||
final String buildClickScript;
|
||
|
||
const _OfficialDownloadDesktopWebView({
|
||
required this.shareUri,
|
||
required this.onBridgeMessage,
|
||
required this.onNavigationUrl,
|
||
required this.buildHookScript,
|
||
required this.buildClickScript,
|
||
});
|
||
|
||
@override
|
||
State<_OfficialDownloadDesktopWebView> createState() =>
|
||
_OfficialDownloadDesktopWebViewState();
|
||
}
|
||
|
||
class _OfficialDownloadDesktopWebViewState
|
||
extends State<_OfficialDownloadDesktopWebView> {
|
||
desktop.InAppWebViewController? _controller;
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _installHook() async {
|
||
final controller = _controller;
|
||
if (controller == null) return;
|
||
try {
|
||
await controller.evaluateJavascript(source: widget.buildHookScript);
|
||
await controller.evaluateJavascript(source: widget.buildClickScript);
|
||
} catch (_) {
|
||
// The parent dialog timeout keeps the manual browser fallback available.
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return desktop.InAppWebView(
|
||
initialUrlRequest: desktop.URLRequest(
|
||
url: desktop.WebUri.uri(widget.shareUri),
|
||
),
|
||
initialSettings: desktop.InAppWebViewSettings(
|
||
javaScriptEnabled: true,
|
||
domStorageEnabled: true,
|
||
supportZoom: false,
|
||
transparentBackground: true,
|
||
useShouldOverrideUrlLoading: true,
|
||
useOnDownloadStart: true,
|
||
),
|
||
onWebViewCreated: (controller) {
|
||
_controller = controller;
|
||
controller.addJavaScriptHandler(
|
||
handlerName: 'CloudreveDownloadBridge',
|
||
callback: (args) {
|
||
if (args.isNotEmpty) {
|
||
widget.onBridgeMessage(args.first.toString());
|
||
}
|
||
},
|
||
);
|
||
},
|
||
onLoadStop: (_, url) => _installHook(),
|
||
shouldOverrideUrlLoading: (_, action) {
|
||
final url = action.request.url?.toString();
|
||
if (url != null && widget.onNavigationUrl(url)) {
|
||
return desktop.NavigationActionPolicy.CANCEL;
|
||
}
|
||
return desktop.NavigationActionPolicy.ALLOW;
|
||
},
|
||
onUpdateVisitedHistory: (_, url, isReload) {
|
||
if (url != null) {
|
||
widget.onNavigationUrl(url.toString());
|
||
}
|
||
},
|
||
onDownloadStarting: (_, request) {
|
||
widget.onNavigationUrl(request.url.toString());
|
||
return null;
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ShareFileTile extends StatelessWidget {
|
||
final ShareLinkFile file;
|
||
final VoidCallback? onTap;
|
||
final VoidCallback? onDownload;
|
||
final VoidCallback? onSave;
|
||
|
||
const _ShareFileTile({
|
||
required this.file,
|
||
this.onTap,
|
||
this.onDownload,
|
||
this.onSave,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final icon = file.isFolder ? LucideIcons.folder : _iconForFile(file.name);
|
||
|
||
return Card(
|
||
elevation: 0,
|
||
margin: const EdgeInsets.only(bottom: 10),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
child: ListTile(
|
||
onTap: onTap,
|
||
leading: Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: file.isFolder
|
||
? theme.colorScheme.primaryContainer
|
||
: theme.colorScheme.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(13),
|
||
),
|
||
child: Icon(
|
||
icon,
|
||
color: file.isFolder
|
||
? theme.colorScheme.onPrimaryContainer
|
||
: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
title: Text(
|
||
file.name,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||
),
|
||
subtitle: Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
file.isFolder
|
||
? '文件夹'
|
||
: '${formatSize(file.size)}${file.updatedAt == null ? '' : ' · ${_formatDate(file.updatedAt!)}'}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
trailing: file.isFolder
|
||
? const Icon(Icons.chevron_right)
|
||
: Wrap(
|
||
spacing: 2,
|
||
children: [
|
||
IconButton(
|
||
tooltip: '转存',
|
||
icon: const Icon(Icons.drive_folder_upload_outlined),
|
||
onPressed: onSave,
|
||
),
|
||
IconButton(
|
||
tooltip: '下载',
|
||
icon: const Icon(LucideIcons.download),
|
||
onPressed: onDownload,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static IconData _iconForFile(String name) {
|
||
final lower = name.toLowerCase();
|
||
if (RegExp(r'\.(png|jpg|jpeg|gif|webp|bmp|heic)$').hasMatch(lower)) {
|
||
return LucideIcons.image;
|
||
}
|
||
if (RegExp(r'\.(mp4|mkv|mov|avi|webm|flv)$').hasMatch(lower)) {
|
||
return LucideIcons.video;
|
||
}
|
||
if (RegExp(r'\.(mp3|wav|flac|aac|ogg|m4a)$').hasMatch(lower)) {
|
||
return LucideIcons.music;
|
||
}
|
||
if (RegExp(r'\.(zip|rar|7z|tar|gz)$').hasMatch(lower)) {
|
||
return LucideIcons.archive;
|
||
}
|
||
if (RegExp(r'\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md)$').hasMatch(lower)) {
|
||
return LucideIcons.fileText;
|
||
}
|
||
return LucideIcons.file;
|
||
}
|
||
|
||
static String formatSize(int bytes) {
|
||
if (bytes <= 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
var size = bytes.toDouble();
|
||
var unitIndex = 0;
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024;
|
||
unitIndex++;
|
||
}
|
||
final text = unitIndex == 0
|
||
? size.toStringAsFixed(0)
|
||
: size.toStringAsFixed(1);
|
||
return '$text ${units[unitIndex]}';
|
||
}
|
||
|
||
static String _formatDate(DateTime value) {
|
||
final local = value.toLocal();
|
||
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}';
|
||
}
|
||
}
|
||
|
||
class _MetaChip extends StatelessWidget {
|
||
final IconData icon;
|
||
final String text;
|
||
|
||
const _MetaChip({required this.icon, required this.text});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 13, color: theme.hintColor),
|
||
const SizedBox(width: 5),
|
||
Text(text, style: theme.textTheme.labelSmall),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusBadge extends StatelessWidget {
|
||
final String text;
|
||
final Color color;
|
||
|
||
const _StatusBadge({required this.text, required this.color});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
color: color,
|
||
fontWeight: FontWeight.w800,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ErrorBox extends StatelessWidget {
|
||
final String text;
|
||
final String? actionText;
|
||
final VoidCallback? onAction;
|
||
|
||
const _ErrorBox({required this.text, this.actionText, this.onAction});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.errorContainer,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
text,
|
||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||
),
|
||
if (actionText != null && onAction != null) ...[
|
||
const SizedBox(height: 8),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: TextButton(onPressed: onAction, child: Text(actionText!)),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EmptyState extends StatelessWidget {
|
||
final IconData icon;
|
||
final String title;
|
||
final String subtitle;
|
||
final String? actionText;
|
||
final VoidCallback? onAction;
|
||
|
||
const _EmptyState({
|
||
required this.icon,
|
||
required this.title,
|
||
required this.subtitle,
|
||
this.actionText,
|
||
this.onAction,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 56),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 46, color: theme.hintColor),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
title,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
subtitle,
|
||
textAlign: TextAlign.center,
|
||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||
),
|
||
if (actionText != null && onAction != null) ...[
|
||
const SizedBox(height: 14),
|
||
OutlinedButton(onPressed: onAction, child: Text(actionText!)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|