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( context: context, isScrollControlled: true, showDragHandle: true, builder: (_) => _FileInfoSheet(file: file), ); } @override State createState() => _FileInfoPanelState(); } class _FileInfoPanelState extends State { @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 createState() => _FileInfoPanelContentState(); } class _FileInfoPanelContentState extends State { FileInfoModel? _fileInfo; bool _isLoading = true; bool _isCalculatingFolder = false; bool _isVersionLoading = false; String? _error; @override void initState() { super.initState(); _loadFileInfo(); } Future _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 _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 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 = [ 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 _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? ?? []; if (urls.isNotEmpty) { final urlData = urls[0] as Map; 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 _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 _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( 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), ), ), ], ), ); } }