Files
2026-05-27 19:19:11 +08:00

703 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
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 '../../services/file_service.dart';
import '../../router/app_router.dart';
import 'toast_helper.dart';
/// 文件/文件夹详情(右侧抽屉)
class FileInfoPanel extends StatefulWidget {
final FileModel file;
const FileInfoPanel({super.key, required this.file});
/// 在指定 context 的 Scaffold 上打开右侧抽屉
static void show(BuildContext context, FileModel file) {
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;
bool _isVersionLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadFileInfo();
}
Future<void> _loadFileInfo() async {
try {
final response = await FileService().getFileInfo(
uri: widget.file.relativePath,
extended: widget.file.isFile,
folderSummary: false,
);
if (mounted) {
setState(() {
_fileInfo = FileInfoModel.fromJson(response);
_isLoading = false;
_isVersionLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Future<void> _calculateFolderSize() async {
setState(() => _isCalculatingFolder = true);
try {
final response = await FileService().getFileInfo(
uri: widget.file.relativePath,
extended: true,
folderSummary: true,
);
if (mounted) {
setState(() {
_fileInfo = FileInfoModel.fromJson(response);
_isCalculatingFolder = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isCalculatingFolder = false);
ToastHelper.failure('计算文件夹大小失败: $e');
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
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,
),
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(),
),
],
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildError(theme)
: _buildContent(theme, colorScheme),
),
],
);
}
Widget _buildError(ThemeData theme) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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),
),
const SizedBox(height: 12),
FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')),
],
),
);
}
Widget _buildContent(ThemeData theme, ColorScheme colorScheme) {
final file = _fileInfo?.file ?? widget.file;
final typeLabel = file.isFolder
? '文件夹'
: FileIconUtils.getFileTypeLabel(file.name);
final extendedInfo = _fileInfo?.extendedInfo;
final versionEntities = extendedInfo?.entities
?.where((e) => e.type == 0)
.toList() ??
[];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
typeLabel,
style: TextStyle(
fontSize: 12,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 16),
_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)),
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)),
const SizedBox(height: 8),
_buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id),
if (file.primaryEntity != null)
_buildInfoRow(LucideIcons.hash, '主版本', file.primaryEntity!),
if (extendedInfo.storagePolicy != null)
_buildInfoRow(
LucideIcons.server,
'存储策略',
'${extendedInfo.storagePolicy!.name} (${extendedInfo.storagePolicy!.type})',
),
if (extendedInfo.storageUsed != null)
_buildInfoRow(
LucideIcons.database,
'总占用',
date_utils.DateUtils.formatFileSize(extendedInfo.storageUsed!),
),
],
if (file.isFile && versionEntities.isNotEmpty) ...[
const SizedBox(height: 12),
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
const SizedBox(height: 8),
_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)),
const SizedBox(height: 8),
_buildFolderSummary(theme, colorScheme),
],
],
),
);
}
Widget _buildVersionSection(
ThemeData theme,
ColorScheme colorScheme,
List<EntityModel> entities,
) {
final file = _fileInfo!.file;
final primaryEntity = file.primaryEntity;
final isPreviewable = FileTypeUtils.isPreviewable(file.name);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('版本历史', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${entities.length}',
style: TextStyle(
fontSize: 11,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
...entities.asMap().entries.map((entry) {
final index = entry.key;
final entity = entry.value;
final isCurrent = entity.id == primaryEntity;
final versionNumber = entities.length - index;
return _buildVersionItem(
theme,
colorScheme,
entity: entity,
versionNumber: versionNumber,
isCurrent: isCurrent,
isPreviewable: isPreviewable,
file: file,
);
}),
],
);
}
Widget _buildVersionItem(
ThemeData theme,
ColorScheme colorScheme, {
required EntityModel entity,
required int versionNumber,
required bool isCurrent,
required bool isPreviewable,
required FileModel file,
}) {
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
final createdBy = entity.createdBy?.nickname ?? '未知';
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
decoration: BoxDecoration(
color: isCurrent
? colorScheme.primaryContainer.withValues(alpha: 0.15)
: null,
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.15)),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 380;
final actionButtons = <Widget>[
if (isPreviewable)
_buildVersionActionButton(
icon: LucideIcons.externalLink,
tooltip: '打开',
onPressed: _isVersionLoading ? null : () => _openVersion(entity),
),
_buildVersionActionButton(
icon: LucideIcons.download,
tooltip: '下载',
onPressed: _isVersionLoading ? null : () => _downloadVersion(entity),
),
if (!isCurrent) ...[
_buildVersionActionButton(
icon: LucideIcons.pin,
tooltip: '设为当前版本',
onPressed: _isVersionLoading ? null : () => _setCurrentVersion(entity),
),
_buildVersionActionButton(
icon: LucideIcons.trash2,
tooltip: '删除',
color: colorScheme.error,
onPressed: _isVersionLoading ? null : () => _deleteVersion(entity),
),
],
];
final versionBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isCurrent
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
isCurrent ? '当前' : 'v$versionNumber',
style: TextStyle(
fontSize: 11,
color: isCurrent
? colorScheme.onPrimaryContainer
: theme.hintColor,
fontWeight: FontWeight.w600,
),
),
);
final versionInfo = Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => _showVersionDetail(entity),
onLongPress: () => _showVersionDetail(entity),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$shortId · ${date_utils.DateUtils.formatFileSize(entity.size)}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 1),
Text(
'${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy',
style: const TextStyle(fontSize: 11, color: null)
.copyWith(color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (isNarrow) ...[
const SizedBox(height: 4),
Row(children: actionButtons),
],
],
),
);
if (isNarrow) {
return Row(
children: [versionBadge, const SizedBox(width: 8), versionInfo],
);
}
return Row(
children: [
versionBadge,
const SizedBox(width: 8),
versionInfo,
...actionButtons,
],
);
},
),
);
}
void _showVersionDetail(EntityModel entity) {
final createdBy = entity.createdBy;
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('版本详情'),
content: Column(
mainAxisSize: MainAxisSize.min,
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)),
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})'),
if (entity.encryptedWith != null)
_buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('关闭'),
),
],
),
);
}
Widget _buildVersionActionButton({
required IconData icon,
required String tooltip,
Color? color,
required VoidCallback? onPressed,
}) {
return IconButton(
icon: Icon(icon, size: 16, color: color),
onPressed: onPressed,
tooltip: tooltip,
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(28, 28),
),
);
}
void _openVersion(EntityModel entity) {
final file = _fileInfo!.file;
if (!FileTypeUtils.isPreviewable(file.name)) {
ToastHelper.info('暂不支持预览此文件类型');
return;
}
final args = {'file': file, 'entityId': entity.id};
if (FileTypeUtils.isImage(file.name)) {
Navigator.of(context).pushNamed(RouteNames.imagePreview, arguments: args);
} else if (FileTypeUtils.isPdf(file.name)) {
Navigator.of(context).pushNamed(RouteNames.pdfPreview, arguments: args);
} else if (FileTypeUtils.isVideo(file.name)) {
Navigator.of(context).pushNamed(RouteNames.videoPreview, arguments: args);
} 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);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: args);
}
}
Future<void> _downloadVersion(EntityModel entity) async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
entity: entity.id,
download: true,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
} else {
if (mounted) ToastHelper.error('无法打开下载链接');
}
} else {
if (mounted) ToastHelper.error('获取下载链接失败');
}
} catch (e) {
if (mounted) ToastHelper.failure('获取下载链接失败: $e');
}
}
Future<void> _setCurrentVersion(EntityModel entity) async {
setState(() => _isVersionLoading = true);
try {
await FileService().setFileVersion(
uri: widget.file.relativePath,
version: entity.id,
);
if (mounted) {
ToastHelper.success('已设为当前版本');
_loadFileInfo();
}
} catch (e) {
if (mounted) {
setState(() => _isVersionLoading = false);
ToastHelper.failure('操作失败: $e');
}
}
}
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 confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除版本'),
content: Text('确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
setState(() => _isVersionLoading = true);
try {
await FileService().deleteFileVersion(
uri: widget.file.relativePath,
version: entity.id,
);
if (mounted) {
ToastHelper.success('版本已删除');
_loadFileInfo();
}
} catch (e) {
if (mounted) {
setState(() => _isVersionLoading = false);
ToastHelper.failure('删除失败: $e');
}
}
}
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
final summary = _fileInfo?.folderSummary;
if (summary != null) {
return Column(
children: [
_buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'),
_buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'),
_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),
const SizedBox(width: 6),
Text('计算未完成,结果可能不完整', style: TextStyle(fontSize: 12, color: theme.colorScheme.error)),
],
),
),
const SizedBox(height: 8),
Text(
'计算于 ${date_utils.DateUtils.formatDateTime(summary.calculatedAt)}',
style: TextStyle(fontSize: 11, color: theme.hintColor),
),
],
);
}
return _isCalculatingFolder
? const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _calculateFolderSize,
icon: const Icon(LucideIcons.calculator, size: 16),
label: const Text('计算文件夹大小'),
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: theme.hintColor),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: Text(label, style: TextStyle(fontSize: 13, color: theme.hintColor)),
),
Expanded(
child: SelectableText(
value,
style: const TextStyle(fontSize: 13),
),
),
],
),
);
}
}