二次开发源码提交

This commit is contained in:
2026-05-15 08:52:48 +08:00
parent 4131f7321a
commit eb35a1d067
191 changed files with 34566 additions and 0 deletions
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CodeWrapperWidget extends StatefulWidget {
final Widget child;
final String text;
final String language;
const CodeWrapperWidget(this.child, this.text, this.language, {super.key});
@override
State<CodeWrapperWidget> createState() => _PreWrapperState();
}
class _PreWrapperState extends State<CodeWrapperWidget> {
late Widget _switchWidget;
bool hasCopied = false;
@override
void initState() {
super.initState();
_switchWidget = Icon(Icons.copy_rounded, key: UniqueKey());
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Stack(
children: [
widget.child,
Align(
alignment: Alignment.topRight,
child: Container(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.language.isNotEmpty)
SelectionContainer.disabled(
child: Container(
margin: EdgeInsets.only(right: 2),
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
width: 0.5,
color: isDark ? Colors.white : Colors.black)),
child: Text(widget.language),
)),
InkWell(
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: _switchWidget,
),
onTap: () async {
if (hasCopied) return;
await Clipboard.setData(ClipboardData(text: widget.text));
_switchWidget = Icon(Icons.check, key: UniqueKey());
refresh();
Future.delayed(Duration(seconds: 2), () {
hasCopied = false;
_switchWidget =
Icon(Icons.copy_rounded, key: UniqueKey());
refresh();
});
},
),
],
),
),
)
],
);
}
void refresh() {
if (mounted) setState(() {});
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// 桌面端内容宽度约束
/// 屏幕宽度 >= 1000px 时,将子组件内容限制在 maxContentWidth 内居中显示
class DesktopConstrained extends StatelessWidget {
final Widget child;
final double maxContentWidth;
const DesktopConstrained({
super.key,
required this.child,
this.maxContentWidth = 800,
});
static const double desktopBreakpoint = 1000;
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= desktopBreakpoint) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxContentWidth),
child: child,
),
);
}
return child;
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../../config/app_config.dart';
class DesktopTitleBar extends StatelessWidget implements PreferredSizeWidget {
const DesktopTitleBar({super.key});
@override
Widget build(BuildContext context) {
return WindowCaption(
brightness: Theme.of(context).brightness,
backgroundColor: Colors.transparent, // 透明背景,露出下面的组件颜色
title: Row(
children: [
// 可以在这里放一个小 Logo
Image.asset(
'assets/icons/tray_icon.png',
width: 20,
height: 20,
),
const SizedBox(width: 10),
const Text(
AppConfig.appName,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'NotoSansSC',
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(32); // Windows 标准标题栏高度
}
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/download_task_model.dart';
import '../providers/download_manager_provider.dart';
import 'download_progress_item.dart';
/// 下载进度对话框
class DownloadProgressDialog extends StatelessWidget {
const DownloadProgressDialog({super.key});
@override
Widget build(BuildContext context) {
return Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
final tasks = downloadManager.tasks;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 180),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300,
// 当没有任务时,最小高度约为2个任务的大小(约160px)
minHeight: tasks.isEmpty ? 160 : 0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题栏
_buildHeader(context),
// 任务列表
Flexible(
child: tasks.isEmpty
? _buildEmptyState(context)
: _buildTaskList(context, downloadManager, tasks),
),
// 底部操作栏
_buildFooter(context, tasks),
],
),
),
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_done,
size: 48,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'暂无下载任务',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
);
}
Widget _buildTaskList(
BuildContext context,
DownloadManagerProvider downloadManager,
List<DownloadTaskModel> tasks,
) {
return ListView.separated(
shrinkWrap: true,
itemCount: tasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = tasks[index];
return DownloadProgressItem(
key: ValueKey(task.id),
task: task,
onPause: () => downloadManager.pauseDownload(task.id),
onResume: () => downloadManager.resumeDownload(task.id),
onCancel: () => downloadManager.cancelDownload(task.id),
onDelete: () async {
await downloadManager.deleteDownloadTask(task.id);
},
onRetry: () => downloadManager.retryDownload(task.id),
);
},
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
const Icon(Icons.download, color: Colors.blue),
const SizedBox(width: 12),
const Text(
'下载任务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
final downloadingCount = downloadManager.downloadingCount;
if (downloadingCount > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'下载中: $downloadingCount',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
);
}
return const SizedBox.shrink();
},
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
tooltip: '关闭',
),
],
),
);
}
Widget _buildFooter(BuildContext context, List<DownloadTaskModel> tasks) {
final hasCompleted = tasks.any((t) => t.status == DownloadStatus.completed);
final hasFailed = tasks.any((t) => t.status == DownloadStatus.failed);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
if (hasCompleted)
Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
return TextButton.icon(
icon: const Icon(Icons.delete_sweep, size: 18),
label: const Text('清除已完成'),
onPressed: () async {
await downloadManager.clearCompletedTasks();
},
);
},
),
if (hasFailed)
Consumer<DownloadManagerProvider>(
builder: (context, downloadManager, child) {
return TextButton.icon(
icon: const Icon(Icons.clear_all, size: 18),
label: const Text('清除失败'),
onPressed: () {
downloadManager.clearFailedTasks();
},
);
},
),
const Spacer(),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('关闭'),
),
],
),
);
}
}
/// 显示下载对话框
void showDownloadDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => const DownloadProgressDialog(),
barrierDismissible: false,
);
}
@@ -0,0 +1,418 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import '../../data/models/download_task_model.dart';
import '../../services/download_service.dart';
import '../providers/download_manager_provider.dart';
import 'toast_helper.dart';
import '../../core/utils/app_logger.dart';
/// 下载任务列表项
class DownloadProgressItem extends StatelessWidget {
final DownloadTaskModel task;
final VoidCallback? onPause;
final VoidCallback? onResume;
final VoidCallback? onCancel;
final VoidCallback? onDelete;
final VoidCallback? onRetry;
const DownloadProgressItem({
super.key,
required this.task,
this.onPause,
this.onResume,
this.onCancel,
this.onDelete,
this.onRetry,
});
@override
Widget build(BuildContext context) {
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: true,
);
final latestTask = downloadManager.getTask(task.id) ?? task;
final isDownloading = latestTask.status == DownloadStatus.downloading;
final isPaused = latestTask.status == DownloadStatus.paused;
final isFailed = latestTask.status == DownloadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: _getCardColor(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getBorderColor(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi),
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildStatusIcon(context, latestTask.status, waitingForWifi: latestTask.waitingForWifi),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.fileName,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
_buildStatusRow(context, latestTask),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: _buildActionButtons(context, latestTask),
),
],
),
if (isDownloading || isPaused) ...[
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: isPaused ? null : latestTask.progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
isPaused ? '已暂停' : latestTask.progressText,
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${DownloadService.getReadableFileSize(latestTask.downloadedBytes)} / '
'${DownloadService.getReadableFileSize(latestTask.fileSize)}',
style: const TextStyle(fontSize: 12),
),
if (latestTask.speedText.isNotEmpty) ...[
const SizedBox(width: 12),
Text(
latestTask.speedText,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
),
],
],
),
] else if (isFailed && latestTask.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
latestTask.errorMessage!,
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
);
}
Widget _buildStatusIcon(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) {
final color = _getStatusColor(status, waitingForWifi: waitingForWifi);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getStatusIcon(status, waitingForWifi: waitingForWifi),
size: 18,
color: color,
),
);
}
Widget _buildStatusRow(BuildContext context, DownloadTaskModel task) {
final color = _getStatusColor(task.status, waitingForWifi: task.waitingForWifi);
final isCompleted = task.status == DownloadStatus.completed;
return Row(
children: [
Text(
task.statusText,
style: TextStyle(fontSize: 12, color: color),
),
if (isCompleted) ...[
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
DownloadService.getReadableFileSize(task.fileSize),
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
_formatDateTime(task.completedAt!),
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
],
);
}
List<Widget> _buildActionButtons(
BuildContext context,
DownloadTaskModel task,
) {
final errorColor = Theme.of(context).colorScheme.error;
switch (task.status) {
case DownloadStatus.waiting:
if (task.waitingForWifi) {
return [
IconButton(
icon: Icon(Icons.cancel, size: 20, color: errorColor),
onPressed: onCancel,
tooltip: '取消',
),
];
}
return [];
case DownloadStatus.downloading:
return [
IconButton(
icon: const Icon(Icons.pause, size: 20),
onPressed: onPause,
tooltip: '暂停',
),
];
case DownloadStatus.paused:
return [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: onResume,
tooltip: '继续',
),
IconButton(
icon: Icon(Icons.cancel, size: 20, color: errorColor),
onPressed: onCancel,
tooltip: '取消',
),
];
case DownloadStatus.failed:
return [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: onRetry,
tooltip: '重试',
),
IconButton(
icon: Icon(Icons.delete, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case DownloadStatus.completed:
final showOpenFolder = !Platform.isAndroid;
return [
IconButton(
icon: const Icon(Icons.open_in_new, size: 20),
onPressed: () => _openDownloadedFile(context, task),
tooltip: '打开',
),
if (showOpenFolder)
IconButton(
icon: const Icon(Icons.folder_open, size: 20),
onPressed: () => _openFileFolder(context, task),
tooltip: '打开文件夹',
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case DownloadStatus.cancelled:
return [];
}
}
Future<void> checkInstallPermission() async {
if (await Permission.requestInstallPackages.isDenied) {
await Permission.requestInstallPackages.request();
}
}
Future<void> _openDownloadedFile(
BuildContext context,
DownloadTaskModel task,
) async {
final file = File(task.savePath);
if (!await file.exists()) {
if (context.mounted) {
ToastHelper.error('文件不存在:${task.fileName}');
}
return;
}
try {
final ext = task.savePath.toString().split('.').last.toLowerCase();
if (ext == 'apk') {
await checkInstallPermission();
}
OpenResult openResult = await OpenFile.open(task.savePath);
AppLogger.d('下载对话框打开文件结果:${openResult.type}');
if (openResult.type == ResultType.done) {
AppLogger.d('成功打开文件:${task.fileName}');
} else {
if (context.mounted) {
ToastHelper.error(
'无法打开文件:${task.fileName} 错误信息: ${openResult.message}',
);
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.error('打开文件失败:$e');
}
}
}
Future<void> _openFileFolder(
BuildContext context,
DownloadTaskModel task,
) async {
try {
final dir = File(task.savePath).parent.path;
final result = await OpenFile.open(dir);
if (result.type != ResultType.done && context.mounted) {
ToastHelper.error('无法打开文件夹:${result.message}');
}
} catch (e) {
if (context.mounted) {
ToastHelper.error('打开文件夹失败:$e');
}
}
}
IconData _getStatusIcon(DownloadStatus status, {bool waitingForWifi = false}) {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? LucideIcons.wifi : LucideIcons.clock;
case DownloadStatus.downloading:
return LucideIcons.download;
case DownloadStatus.completed:
return LucideIcons.checkCircle2;
case DownloadStatus.paused:
return LucideIcons.pause;
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return LucideIcons.xCircle;
}
}
Color _getStatusColor(DownloadStatus status, {bool waitingForWifi = false}) {
switch (status) {
case DownloadStatus.waiting:
return waitingForWifi ? Colors.blue : Colors.grey;
case DownloadStatus.downloading:
return Colors.blue;
case DownloadStatus.completed:
return Colors.green;
case DownloadStatus.paused:
return Colors.orange;
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return Colors.red;
}
}
Color _getCardColor(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case DownloadStatus.waiting:
if (waitingForWifi) {
return isDark ? Colors.blue.withValues(alpha: 0.08) : Colors.blue.withValues(alpha: 0.05);
}
return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6);
case DownloadStatus.completed:
return isDark ? Colors.green.withValues(alpha: 0.08) : Colors.green.withValues(alpha: 0.05);
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return isDark ? Colors.red.withValues(alpha: 0.08) : Colors.red.withValues(alpha: 0.05);
default:
return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6);
}
}
Color _getBorderColor(BuildContext context, DownloadStatus status, {bool waitingForWifi = false}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case DownloadStatus.waiting:
if (waitingForWifi) {
return Colors.blue.withValues(alpha: isDark ? 0.2 : 0.15);
}
return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3);
case DownloadStatus.completed:
return Colors.green.withValues(alpha: isDark ? 0.2 : 0.15);
case DownloadStatus.failed:
case DownloadStatus.cancelled:
return Colors.red.withValues(alpha: isDark ? 0.2 : 0.15);
default:
return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3);
}
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// 空文件夹状态组件
class EmptyFolderView extends StatelessWidget {
final String currentPath;
const EmptyFolderView({
super.key,
required this.currentPath,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
currentPath == '/' ? '文件夹为空' : '暂无文件',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
if (currentPath == '/') const SizedBox(height: 8),
if (currentPath == '/')
Text(
'点击 + 按钮创建新文件夹',
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
),
],
),
);
}
}
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// 退出提示覆盖层
class ExitHintOverlay {
OverlayEntry? _overlay;
AnimationController? _animationController;
/// 显示退出提示
void show(BuildContext context) {
remove();
_overlay = OverlayEntry(
builder: (overlayContext) => Positioned.fill(
child: Center(
child: Material(
color: Colors.transparent,
child: _ExitHintWidget(
onDismiss: remove,
onAnimationReady: (controller, fadeAnimation) {
_animationController = controller;
controller.forward();
},
),
),
),
),
);
Overlay.of(context).insert(_overlay!);
}
/// 移除退出提示
void remove() {
// 立即移除 overlay
_overlay?.remove();
_overlay = null;
// 停止动画
_animationController?.stop();
// 清理控制器引用
_animationController = null;
}
}
class _ExitHintWidget extends StatefulWidget {
final VoidCallback onDismiss;
final Function(AnimationController controller, Animation<double> fadeAnimation) onAnimationReady;
const _ExitHintWidget({
required this.onDismiss,
required this.onAnimationReady,
});
@override
State<_ExitHintWidget> createState() => _ExitHintWidgetState();
}
class _ExitHintWidgetState extends State<_ExitHintWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
widget.onAnimationReady(_controller, _fadeAnimation);
}
@override
void dispose() {
_controller.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: child,
);
},
child: LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
final cardWidth = screenWidth * 0.65;
return GestureDetector(
onTap: widget.onDismiss,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
constraints: BoxConstraints(maxWidth: cardWidth),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.touch_app_rounded,
color: theme.colorScheme.error,
size: 24,
),
),
const SizedBox(width: 14),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'再次左滑退出应用',
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'点击屏幕任意位置取消',
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
],
),
),
),
),
);
},
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
/// 面包屑导航组件
class FileBreadcrumb extends StatelessWidget {
final String currentPath;
final void Function(String path) onPathTap;
const FileBreadcrumb({
super.key,
required this.currentPath,
required this.onPathTap,
});
@override
Widget build(BuildContext context) {
final pathParts = currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
border: Border(
top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5), width: 1),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildBreadcrumbItem(
context,
name: '首页',
path: '/',
icon: LucideIcons.home,
primaryColor: colorScheme.primary,
onTap: () => onPathTap('/'),
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(LucideIcons.chevronRight, size: 16, color: theme.hintColor.withValues(alpha: 0.5)),
),
_buildBreadcrumbItem(
context,
name: pathParts[i],
path: '/${pathParts.sublist(0, i + 1).join('/')}',
icon: null,
primaryColor: colorScheme.primary,
onTap: () => onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
),
],
],
),
),
);
}
Widget _buildBreadcrumbItem(
BuildContext context, {
required String name,
required String path,
required IconData? icon,
required Color primaryColor,
required VoidCallback onTap,
}) {
return Container(
height: 36,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Row(
children: [
if (icon != null)
Icon(icon, size: 16, color: primaryColor),
if (icon != null) const SizedBox(width: 5),
Text(
name,
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart' hide DateUtils;
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart';
import '../../core/utils/file_icon_utils.dart';
import '../../core/utils/file_utils.dart';
import 'file_menu_helper.dart';
import 'thumbnail_image.dart';
/// 文件网格项
class FileGridItem extends StatelessWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final bool showCheckbox;
final VoidCallback? onTap;
final VoidCallback? onSelect;
final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback? onShare;
final VoidCallback? onDelete;
final VoidCallback? onRestore;
final VoidCallback? onInfo;
final bool tapToShowMenu;
final String? contextHint;
const FileGridItem({
super.key,
required this.file,
this.isSelected = false,
this.isHighlighted = false,
this.showCheckbox = false,
this.tapToShowMenu = false,
this.onTap,
this.onSelect,
this.onDownload,
this.onOpenInBrowser,
this.onRename,
this.onMove,
this.onCopy,
this.onShare,
this.onDelete,
this.onRestore,
this.onInfo,
this.contextHint,
});
@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 _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),
);
},
),
);
}
Future<void> _showMenu(BuildContext context) async {
final result = await showFileMenu(
context: context,
hasSelect: onSelect != null,
hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null,
hasRename: onRename != null,
hasMove: onMove != null,
hasCopy: onCopy != null,
hasShare: onShare != null,
hasDelete: onDelete != null,
hasRestore: onRestore != null,
hasInfo: onInfo != null,
);
switch (result) {
case FileMenuAction.select:
onSelect?.call();
case FileMenuAction.download:
onDownload?.call();
case FileMenuAction.openInBrowser:
onOpenInBrowser?.call();
case FileMenuAction.rename:
onRename?.call();
case FileMenuAction.move:
onMove?.call();
case FileMenuAction.copy:
onCopy?.call();
case FileMenuAction.share:
onShare?.call();
case FileMenuAction.delete:
onDelete?.call();
case FileMenuAction.restore:
onRestore?.call();
case FileMenuAction.info:
onInfo?.call();
case null:
break;
}
}
}
class _FileGridItemHover extends StatefulWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final bool showCheckbox;
final String? contextHint;
final double fontSize;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onSelect;
final VoidCallback? onMore;
final bool tapToShowMenu;
const _FileGridItemHover({
required this.file,
required this.isSelected,
required this.isHighlighted,
required this.showCheckbox,
required this.contextHint,
required this.fontSize,
this.onTap,
this.onLongPress,
this.onSelect,
this.onMore,
this.tapToShowMenu = false,
});
@override
State<_FileGridItemHover> createState() => _FileGridItemHoverState();
}
class _FileGridItemHoverState extends State<_FileGridItemHover> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
// 卡片背景
Color cardBg;
BoxBorder? border;
List<BoxShadow>? shadows;
if (widget.isSelected) {
cardBg = colorScheme.primary.withValues(alpha: 0.06);
border = Border.all(color: colorScheme.primary, width: 2);
} else if (widget.isHighlighted) {
cardBg = colorScheme.primary.withValues(alpha: 0.06);
border = Border.all(color: colorScheme.primary.withValues(alpha: 0.3));
shadows = [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
];
} else if (_isHovered) {
cardBg = isDark ? const Color(0xFF263548) : const Color(0xFFF1F5F9);
border = Border.all(color: colorScheme.primary.withValues(alpha: 0.2));
shadows = [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
];
} else {
cardBg = colorScheme.surfaceContainerLow;
border = Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: theme.dividerColor.withValues(alpha: 0.15),
);
}
// 文字颜色
final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.tapToShowMenu ? widget.onLongPress : widget.onTap,
onLongPress: widget.onLongPress,
onSecondaryTap: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: cardBg,
borderRadius: BorderRadius.circular(8),
border: border,
boxShadow: shadows,
),
padding: const EdgeInsets.all(6),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 图标区
Expanded(
child: widget.showCheckbox
? Center(
child: Checkbox(
value: widget.isSelected,
onChanged: (_) => widget.onSelect?.call(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
)
: _buildIconArea(context),
),
const SizedBox(height: 6),
// 文字区:左对齐
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:文件名
Text(
_truncateFileName(widget.file.name),
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w500,
color: nameColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// 第二行:类型 | 大小
Text(
widget.file.isFolder
? FileIconUtils.getFileTypeLabel(widget.file.name, isFolder: true)
: '${FileIconUtils.getFileTypeLabel(widget.file.name)} | ${DateUtils.formatFileSize(widget.file.size)}',
style: TextStyle(
fontSize: widget.fontSize * 0.85,
color: theme.hintColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 1),
// 第三行:修改时间
Text(
DateUtils.formatDateTime(widget.file.updatedAt),
style: TextStyle(
fontSize: widget.fontSize * 0.8,
color: theme.hintColor.withValues(alpha: 0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
// Hover 操作按钮
if (_isHovered && !widget.showCheckbox)
Positioned(
top: 0,
right: 0,
child: Material(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: widget.onMore,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(LucideIcons.moreVertical, size: 14, color: colorScheme.primary),
),
),
),
),
],
),
),
),
);
}
String _truncateFileName(String name) {
const maxChars = 20;
if (name.length <= maxChars) return name;
final dotIndex = name.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < name.length - 1) {
final prefix = name.substring(0, 8);
final extension = name.substring(dotIndex);
final middleLength = maxChars - prefix.length - extension.length - 3;
if (middleLength > 0) return '$prefix...$extension';
}
final half = (maxChars - 3) ~/ 2;
return '${name.substring(0, half)}...${name.substring(name.length - half)}';
}
Widget _buildIconArea(BuildContext context) {
final file = widget.file;
final ext = FileUtils.getFileExtension(file.name);
final isThumbnailable = !file.isFolder
&& FileUtils.isImageFile(file.name)
&& ext != 'svg';
if (!isThumbnailable) {
return Center(
child: FileIconUtils.buildIconWidget(
context: context,
file: file,
size: 40,
iconSize: 22,
borderRadius: 10,
),
);
}
return ThumbnailImage(
file: file,
contextHint: widget.contextHint,
borderRadius: 10,
);
}
}
@@ -0,0 +1,670 @@
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();
}
@override
State<FileInfoPanel> createState() => _FileInfoPanelState();
}
class _FileInfoPanelState extends State<FileInfoPanel> {
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 Drawer(
child: SafeArea(
right: false,
child: 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),
),
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
/// 文件列表表头(桌面端)
class FileListHeader extends StatelessWidget {
final bool showCheckbox;
const FileListHeader({super.key, this.showCheckbox = false});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = TextStyle(
color: theme.hintColor,
fontSize: 12,
fontWeight: FontWeight.w500,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
),
),
child: Row(
children: [
if (showCheckbox) const SizedBox(width: 40),
// 图标占位
const SizedBox(width: 36 + 16),
Expanded(flex: 5, child: Text('名称', style: style)),
Expanded(flex: 2, child: Text('修改日期', style: style)),
Expanded(flex: 1, child: Text('大小', style: style)),
],
),
);
}
}
@@ -0,0 +1,385 @@
import 'package:flutter/material.dart' hide DateUtils;
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/file_model.dart';
import '../../core/utils/date_utils.dart';
import '../../core/utils/file_icon_utils.dart';
import '../../services/file_service.dart';
import 'file_menu_helper.dart';
/// 文件列表项
class FileListItem extends StatelessWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final bool showCheckbox;
final int index;
final bool isDesktop;
final VoidCallback? onTap;
final VoidCallback? onSelect;
final VoidCallback? onDownload;
final VoidCallback? onOpenInBrowser;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback? onShare;
final VoidCallback? onDelete;
final VoidCallback? onRestore;
final VoidCallback? onInfo;
final bool tapToShowMenu;
const FileListItem({
super.key,
required this.file,
this.isSelected = false,
this.isHighlighted = false,
this.showCheckbox = false,
this.index = 0,
this.isDesktop = true,
this.tapToShowMenu = false,
this.onTap,
this.onSelect,
this.onDownload,
this.onOpenInBrowser,
this.onRename,
this.onMove,
this.onCopy,
this.onShare,
this.onDelete,
this.onRestore,
this.onInfo,
});
@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,
);
}
Future<void> _showMenu(BuildContext context) async {
final result = await showFileMenu(
context: context,
hasSelect: onSelect != null,
hasDownload: onDownload != null,
hasOpenInBrowser: onOpenInBrowser != null,
hasRename: onRename != null,
hasMove: onMove != null,
hasCopy: onCopy != null,
hasShare: onShare != null,
hasDelete: onDelete != null,
hasRestore: onRestore != null,
hasInfo: onInfo != null,
);
switch (result) {
case FileMenuAction.select:
onSelect?.call();
case FileMenuAction.download:
onDownload?.call();
case FileMenuAction.openInBrowser:
onOpenInBrowser?.call();
case FileMenuAction.rename:
onRename?.call();
case FileMenuAction.move:
onMove?.call();
case FileMenuAction.copy:
onCopy?.call();
case FileMenuAction.share:
onShare?.call();
case FileMenuAction.delete:
onDelete?.call();
case FileMenuAction.restore:
onRestore?.call();
case FileMenuAction.info:
onInfo?.call();
case null:
break;
}
}
}
class _FileListItemHover extends StatefulWidget {
final FileModel file;
final bool isSelected;
final bool isHighlighted;
final int index;
final bool isDesktop;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool showCheckbox;
final VoidCallback? onSelect;
final bool tapToShowMenu;
const _FileListItemHover({
required this.file,
required this.isSelected,
required this.isHighlighted,
required this.index,
required this.isDesktop,
this.onTap,
this.onLongPress,
required this.showCheckbox,
this.onSelect,
this.tapToShowMenu = false,
});
@override
State<_FileListItemHover> createState() => _FileListItemHoverState();
}
class _FileListItemHoverState extends State<_FileListItemHover> {
bool _isHovered = false;
String? _folderSizeText;
bool _isCalculatingFolder = false;
Future<void> _calculateFolderSize() async {
setState(() => _isCalculatingFolder = true);
try {
final response = await FileService().getFileInfo(
uri: widget.file.relativePath,
folderSummary: true,
);
final summary = response['folder_summary'];
if (summary is Map<String, dynamic> && summary.containsKey('size')) {
if (mounted) {
setState(() {
_folderSizeText = DateUtils.formatFileSize(summary['size'] as int);
_isCalculatingFolder = false;
});
}
} else {
if (mounted) setState(() => _isCalculatingFolder = false);
}
} catch (_) {
if (mounted) setState(() => _isCalculatingFolder = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Color bgColor;
if (widget.isSelected) {
bgColor = colorScheme.primary.withValues(alpha: 0.08);
} else if (widget.isHighlighted) {
bgColor = colorScheme.primary.withValues(alpha: 0.06);
} else if (_isHovered) {
bgColor = colorScheme.primary.withValues(alpha: 0.05);
} else if (widget.index.isOdd) {
bgColor = colorScheme.surfaceContainerLow;
} else {
bgColor = colorScheme.surface;
}
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.tapToShowMenu ? widget.onLongPress : widget.onTap,
onLongPress: widget.onLongPress,
onSecondaryTap: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
),
margin: const EdgeInsets.symmetric(vertical: 1, horizontal: 4),
padding: EdgeInsets.symmetric(
horizontal: widget.isDesktop ? 24 : 16,
vertical: 8,
),
child: widget.isDesktop
? _buildDesktopRow(context)
: _buildMobileRow(context),
),
),
);
}
Widget _buildSizeCell(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (!widget.file.isFolder) {
return Text(
DateUtils.formatFileSize(widget.file.size),
style: TextStyle(fontSize: 13, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
// 文件夹:已计算 -> 显示大小,未计算 -> 小按钮
if (_folderSizeText != null) {
return Text(
_folderSizeText!,
style: TextStyle(fontSize: 13, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
return _buildCalcButton(context, colorScheme);
}
Widget _buildCalcButton(BuildContext context, ColorScheme colorScheme) {
if (_isCalculatingFolder) {
return SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.primary,
),
);
}
return InkWell(
onTap: _calculateFolderSize,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.calculator, size: 11, color: colorScheme.primary),
const SizedBox(width: 3),
Text(
'计算',
style: TextStyle(fontSize: 11, color: colorScheme.primary, fontWeight: FontWeight.w500),
),
],
),
),
);
}
/// 桌面端:三列对齐 Row
Widget _buildDesktopRow(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface;
return Row(
children: [
if (widget.showCheckbox)
SizedBox(
width: 40,
child: Checkbox(
value: widget.isSelected,
onChanged: (_) => widget.onSelect?.call(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
FileIconUtils.buildIconWidget(context: context, file: widget.file),
const SizedBox(width: 16),
Expanded(
flex: 5,
child: Text(
widget.file.name,
style: TextStyle(
fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.normal,
fontSize: 14,
color: nameColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 2,
child: Text(
DateUtils.formatDateTime(widget.file.updatedAt),
style: TextStyle(fontSize: 13, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 1,
child: _buildSizeCell(context),
),
],
);
}
/// 窄屏端:两行紧凑布局
Widget _buildMobileRow(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final nameColor = widget.isSelected ? colorScheme.primary : colorScheme.onSurface;
// 构建第二行内容
final typeLabel = FileIconUtils.getFileTypeLabel(widget.file.name, isFolder: widget.file.isFolder);
final dateStr = DateUtils.formatDateTime(widget.file.updatedAt);
return Row(
children: [
if (widget.showCheckbox)
SizedBox(
width: 40,
child: Checkbox(
value: widget.isSelected,
onChanged: (_) => widget.onSelect?.call(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
FileIconUtils.buildIconWidget(context: context, file: widget.file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.file.name,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
color: nameColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
// 第二行:文件夹显示 计算按钮,文件显示类型|大小|日期
if (widget.file.isFolder)
Row(
children: [
Text('$typeLabel | $dateStr', style: TextStyle(fontSize: 12, color: theme.hintColor)),
const SizedBox(width: 6),
if (_folderSizeText != null)
Text('| $_folderSizeText', style: TextStyle(fontSize: 12, color: theme.hintColor))
else
_buildCalcButton(context, colorScheme),
],
)
else
Text(
'$typeLabel | ${DateUtils.formatFileSize(widget.file.size)} | $dateStr',
style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/app_logger.dart';
/// 文件菜单选项
enum FileMenuAction {
select,
download,
openInBrowser,
rename,
move,
copy,
share,
info,
delete,
restore,
}
/// 显示文件菜单
Future<FileMenuAction?> showFileMenu({
required BuildContext context,
required bool hasSelect,
required bool hasDownload,
required bool hasOpenInBrowser,
required bool hasRename,
required bool hasMove,
required bool hasCopy,
required bool hasShare,
required bool hasDelete,
required bool hasRestore,
bool hasInfo = false,
}) async {
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) {
AppLogger.d('showFileMenu: renderBox is null');
return null;
}
final offset = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
// 计算菜单位置,居中显示
final centerX = offset.dx + size.width / 2;
final position = RelativeRect.fromLTRB(
centerX - 120, // 菜单宽度约240,居中显示
offset.dy + size.height / 2,
centerX + 120,
offset.dy + size.height / 2,
);
AppLogger.d('showFileMenu: widget offset: $offset, size: $size, center: $centerX');
final result = await showMenu<FileMenuAction>(
context: context,
position: position,
items: <PopupMenuEntry<FileMenuAction>>[
if (hasSelect)
const PopupMenuItem(
value: FileMenuAction.select,
child: Row(
children: [
Icon(Icons.check_circle_outline, size: 20),
SizedBox(width: 12),
Text('选择'),
],
),
),
if (hasDownload)
const PopupMenuItem(
value: FileMenuAction.download,
child: Row(
children: [
Icon(Icons.download, size: 20),
SizedBox(width: 12),
Text('下载'),
],
),
),
if (hasOpenInBrowser)
const PopupMenuItem(
value: FileMenuAction.openInBrowser,
child: Row(
children: [
Icon(Icons.open_in_browser, size: 20),
SizedBox(width: 12),
Text('在浏览器中打开'),
],
),
),
if (hasRename)
const PopupMenuItem(
value: FileMenuAction.rename,
child: Row(
children: [
Icon(Icons.edit, size: 20),
SizedBox(width: 12),
Text('重命名'),
],
),
),
if (hasMove)
const PopupMenuItem(
value: FileMenuAction.move,
child: Row(
children: [
Icon(Icons.drive_file_move, size: 20),
SizedBox(width: 12),
Text('移动'),
],
),
),
if (hasCopy)
const PopupMenuItem(
value: FileMenuAction.copy,
child: Row(
children: [
Icon(Icons.copy, size: 20),
SizedBox(width: 12),
Text('复制'),
],
),
),
if (hasShare)
const PopupMenuItem(
value: FileMenuAction.share,
child: Row(
children: [
Icon(Icons.share, size: 20),
SizedBox(width: 12),
Text('分享'),
],
),
),
if (hasInfo)
const PopupMenuItem(
value: FileMenuAction.info,
child: Row(
children: [
Icon(LucideIcons.info, size: 20),
SizedBox(width: 12),
Text('详情'),
],
),
),
if (hasDelete)
const PopupMenuItem(
value: FileMenuAction.delete,
child: Row(
children: [
Icon(Icons.delete, size: 20, color: Colors.red),
SizedBox(width: 12),
Text('删除', style: TextStyle(color: Colors.red)),
],
),
),
if (hasRestore)
const PopupMenuItem(
value: FileMenuAction.restore,
child: Row(
children: [
Icon(Icons.restore, size: 20),
SizedBox(width: 12),
Text('恢复'),
],
),
),
],
);
AppLogger.d('showFileMenu: selected value: $result');
return result;
}
@@ -0,0 +1,598 @@
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/share_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/file_manager_provider.dart';
import 'folder_picker.dart';
import 'glassmorphism_container.dart';
import 'toast_helper.dart';
/// 文件操作对话框工具类
class FileOperationDialogs {
/// 显示创建文件夹对话框(毛玻璃风格)
static Future<void> showCreateDialog(
BuildContext context,
FileManagerProvider fileManager,
) async {
final controller = TextEditingController();
final confirmed = await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: '创建文件夹',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, LucideIcons.folderPlus, '创建文件夹'),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '文件夹名称',
prefixIcon: const Icon(LucideIcons.folder, size: 20),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(context).pop(true),
),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: '创建',
),
],
),
),
),
),
),
);
},
);
if (confirmed == true && controller.text.isNotEmpty) {
final error = await fileManager.createFolder(controller.text);
if (error != null && context.mounted) {
ToastHelper.failure('创建文件夹失败: $error');
} else if (context.mounted) {
ToastHelper.success('文件夹创建成功');
}
}
}
/// 显示重命名对话框(毛玻璃风格)
static Future<void> showRenameDialog(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final controller = TextEditingController(text: file.name);
final confirmed = await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: '重命名',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, LucideIcons.pencil, '重命名'),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '新名称',
prefixIcon: const Icon(LucideIcons.edit3, size: 20),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
autofocus: true,
onSubmitted: (_) => Navigator.of(context).pop(true),
),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: '确定',
),
],
),
),
),
),
),
);
},
);
if (confirmed == true && controller.text.isNotEmpty) {
await fileManager.renameFile(file.path, controller.text);
}
}
/// 显示删除确认对话框(多个文件,毛玻璃风格)
static Future<void> showDeleteConfirmation(
BuildContext context,
FileManagerProvider fileManager,
List<String> filePaths,
) async {
final confirmed = await _showConfirmDialog(
context,
icon: LucideIcons.trash2,
title: '删除确认',
message: '确定删除这 ${filePaths.length} 个文件吗?',
confirmLabel: '删除',
isDestructive: true,
);
if (confirmed == true) {
final error = await fileManager.deleteSelectedFiles();
if (error != null && context.mounted) {
ToastHelper.failure('删除失败: $error');
} else if (context.mounted) {
ToastHelper.success('删除成功');
}
}
}
/// 显示删除确认对话框(单个文件,毛玻璃风格)
static Future<void> showDeleteSingleConfirmation(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
) async {
final confirmed = await _showConfirmDialog(
context,
icon: LucideIcons.trash2,
title: '删除确认',
message: '确定删除文件 "${file.name}" 吗?',
confirmLabel: '删除',
isDestructive: true,
);
if (confirmed == true) {
final error = await fileManager.deleteFile(file.path);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('删除失败: $error');
} else {
ToastHelper.success('删除成功');
}
}
}
}
/// 显示移动/复制文件对话框
static Future<void> showMoveDialog(
BuildContext context,
FileManagerProvider fileManager,
FileModel file,
bool copy,
) async {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(copy ? '复制文件' : '移动文件'),
content: SizedBox(
width: 300,
height: 400,
child: FolderPicker(
currentPath: fileManager.currentPath,
onFolderSelected: (selectedPath) async {
Navigator.of(dialogContext).pop();
final error = await fileManager.moveFiles(
[file.path],
selectedPath,
copy: copy,
);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error');
} else {
ToastHelper.success(copy ? '复制成功' : '移动成功');
}
}
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
],
),
);
}
/// 显示多选移动/复制文件对话框
static Future<void> showBatchMoveDialog(
BuildContext context,
FileManagerProvider fileManager,
List<String> uris,
bool copy,
) async {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(copy ? '复制 ${uris.length} 个文件' : '移动 ${uris.length} 个文件'),
content: SizedBox(
width: 300,
height: 400,
child: FolderPicker(
currentPath: fileManager.currentPath,
onFolderSelected: (selectedPath) async {
Navigator.of(dialogContext).pop();
final error = await fileManager.moveFiles(
uris,
selectedPath,
copy: copy,
);
if (context.mounted) {
if (error != null) {
ToastHelper.failure('${copy ? '复制' : '移动'}失败: $error');
} else {
ToastHelper.success(copy ? '复制成功' : '移动成功');
}
}
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
],
),
);
}
/// 显示创建分享对话框
static Future<void> showShareDialog(
BuildContext context,
FileModel file,
) async {
final passwordController = TextEditingController();
final expireDaysController = TextEditingController(text: '7');
bool isPrivate = true;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setState) {
return AlertDialog(
title: const Text('创建分享'),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(
labelText: '文件名',
prefixIcon: Icon(Icons.description),
),
controller: TextEditingController(text: file.name),
enabled: false,
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('密码保护'),
subtitle: const Text('需要密码才能访问'),
value: isPrivate,
onChanged: (value) {
setState(() {
isPrivate = value;
});
},
),
if (isPrivate)
TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: '分享密码',
hintText: '留空则自动生成',
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 16),
TextField(
controller: expireDaysController,
decoration: const InputDecoration(
labelText: '有效期(天)',
hintText: '留空则永久有效',
prefixIcon: Icon(Icons.timer),
suffixText: '',
),
keyboardType: TextInputType.number,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('创建'),
),
],
);
},
),
);
if (confirmed == true) {
final expireDaysText = expireDaysController.text.trim();
final expireDays = expireDaysText.isEmpty
? null
: int.tryParse(expireDaysText);
final expireSeconds = expireDays != null
? expireDays * 24 * 60 * 60
: null;
try {
final shareUrl = await ShareService().createShare(
uri: file.path,
isPrivate: isPrivate,
password: isPrivate
? (passwordController.text.isEmpty ? null : passwordController.text)
: null,
expire: expireSeconds,
);
if (context.mounted) {
ToastHelper.success('分享创建成功');
if (context.mounted) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('分享链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(shareUrl, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.copy, size: 16),
label: const Text('复制到剪贴板'),
onPressed: () {
Clipboard.setData(ClipboardData(text: shareUrl));
Navigator.of(dialogContext).pop();
ToastHelper.success('已复制到剪贴板');
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('关闭'),
),
],
),
);
}
}
} catch (e) {
if (context.mounted) {
ToastHelper.failure('分享创建失败: $e');
}
}
}
}
// ─── 内部工具方法 ───
static Widget _buildDialogTitle(BuildContext context, IconData icon, String title) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
static Widget _buildDialogActions(
BuildContext context, {
required VoidCallback onCancel,
required VoidCallback onConfirm,
required String confirmLabel,
bool isDestructive = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: onCancel,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: isDestructive ? colorScheme.error : null,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: Text(confirmLabel),
),
],
),
);
}
static Future<bool?> _showConfirmDialog(
BuildContext context, {
required IconData icon,
required String title,
required String message,
required String confirmLabel,
bool isDestructive = false,
}) {
return showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: title,
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 400.0 : screenWidth - 48.0;
final theme = Theme.of(context);
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDialogTitle(context, icon, title),
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 20),
child: Text(message, style: theme.textTheme.bodyMedium),
),
_buildDialogActions(
context,
onCancel: () => Navigator.of(context).pop(false),
onConfirm: () => Navigator.of(context).pop(true),
confirmLabel: confirmLabel,
isDestructive: isDestructive,
),
],
),
),
),
),
),
);
},
);
}
}
+277
View File
@@ -0,0 +1,277 @@
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:flutter/material.dart';
import '../../core/utils/app_logger.dart';
/// 文件夹选择器
class FolderPicker extends StatefulWidget {
final String currentPath;
final void Function(String path) onFolderSelected;
final int? maxVisibleItems;
const FolderPicker({
super.key,
required this.currentPath,
required this.onFolderSelected,
this.maxVisibleItems,
});
@override
State<FolderPicker> createState() => _FolderPickerState();
}
class _FolderPickerState extends State<FolderPicker> {
String _currentPath = '/';
List<FileModel> _folders = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_currentPath = widget.currentPath;
_loadFolders();
}
Future<void> _loadFolders() async {
setState(() {
_isLoading = true;
});
try {
final response = await FileService().listFiles(
uri: _currentPath,
pageSize: 100,
);
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
setState(() {
_folders = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.where((f) => f.isFolder)
.toList();
});
} catch (e) {
AppLogger.d('加载文件夹失败: $e');
} finally {
setState(() {
_isLoading = false;
});
}
}
void _enterFolder(FileModel folder) {
setState(() {
_currentPath = folder.path;
});
_loadFolders();
}
@override
Widget build(BuildContext context) {
final primaryColor = Theme.of(context).colorScheme.primary;
final maxHeight = MediaQuery.of(context).size.height * 0.5;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildBreadcrumb(context, primaryColor),
const Divider(height: 1),
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: widget.maxVisibleItems != null
? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(80.0, maxHeight)
: maxHeight,
),
child: _buildListContent(context, primaryColor),
),
),
],
);
}
Widget _buildListContent(BuildContext context, Color primaryColor) {
if (_isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
);
}
if (_folders.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_off, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text('此文件夹为空', style: TextStyle(color: Colors.grey.shade600, fontSize: 14)),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < _folders.length; i++) ...[
if (i > 0) const Divider(height: 1, indent: 56, endIndent: 16),
_buildFolderItem(_folders[i], primaryColor),
],
],
),
);
}
Widget _buildFolderItem(FileModel folder, Color primaryColor) {
return InkWell(
onTap: () => _enterFolder(folder),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.folder, color: primaryColor, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Text(folder.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
),
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20),
],
),
),
);
}
Widget _buildBreadcrumb(BuildContext context, Color primaryColor) {
final pathParts = _currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildBreadcrumbItem(
context,
name: '首页',
path: '/',
isLast: pathParts.isEmpty,
primaryColor: primaryColor,
),
...pathParts.asMap().entries.expand((entry) {
final index = entry.key;
final part = entry.value;
final path = '/${pathParts.sublist(0, index + 1).join('/')}';
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.chevron_right,
size: 16,
color: Colors.grey.shade400,
),
),
_buildBreadcrumbItem(
context,
name: part,
path: path,
isLast: index == pathParts.length - 1,
primaryColor: primaryColor,
),
];
}),
],
),
),
),
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () {
final relative = _currentPath.startsWith('cloudreve://my')
? _currentPath.replaceFirst('cloudreve://my', '')
: _currentPath;
widget.onFolderSelected(relative.isEmpty ? '/' : relative);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('选择'),
),
],
),
),
const Divider(height: 1),
],
);
}
Widget _buildBreadcrumbItem(
BuildContext context, {
required String name,
required String path,
required bool isLast,
required Color primaryColor,
}) {
return InkWell(
onTap: isLast
? null
: () {
setState(() {
_currentPath = path;
});
_loadFolders();
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isLast
? primaryColor.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (name == '首页')
Icon(
Icons.home_filled,
size: 16,
color: isLast ? primaryColor : Colors.grey.shade600,
),
if (name == '首页') const SizedBox(width: 6),
Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: isLast ? FontWeight.w600 : FontWeight.w500,
color: isLast ? primaryColor : Colors.grey.shade700,
),
),
],
),
),
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import '../providers/file_manager_provider.dart';
import 'exit_hint_overlay.dart';
import '../../services/desktop_service.dart';
/// 手势处理 Mixin
mixin GestureHandlerMixin<T extends StatefulWidget> on State<T> {
DateTime? _lastSwipeTime;
final ExitHintOverlay _exitHintOverlay = ExitHintOverlay();
/// 处理滑动手势
void handleSwipe(
BuildContext context,
FileManagerProvider fileManager,
DragEndDetails details,
) {
if (details.primaryVelocity == null) return;
// 从右往左滑(velocity < 0):返回或退出
if (details.primaryVelocity! < 0) {
if (fileManager.currentPath == '/') {
checkExitApp(context);
} else {
navigateBack(context, fileManager);
}
}
}
/// 处理返回键
Future<void> handleBackPress(
BuildContext context,
FileManagerProvider fileManager,
) async {
if (fileManager.currentPath == '/') {
await checkExitApp(context);
} else {
await navigateBack(context, fileManager);
}
}
/// 返回上一级
Future<void> navigateBack(
BuildContext context,
FileManagerProvider fileManager,
) async {
await fileManager.goBack();
}
/// 检查退出应用
Future<void> checkExitApp(BuildContext context) async {
final now = DateTime.now();
if (_lastSwipeTime != null && now.difference(_lastSwipeTime!).inSeconds < 2) {
_exitHintOverlay.remove();
if (DesktopService.isDesktopPlatform) {
await windowManager.hide();
} else {
SystemNavigator.pop();
}
} else {
_lastSwipeTime = now;
_exitHintOverlay.show(context);
Future.delayed(const Duration(seconds: 2), () {
_exitHintOverlay.remove();
});
}
}
}
@@ -0,0 +1,46 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class GlassmorphismContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
final double sigmaX;
final double sigmaY;
final EdgeInsetsGeometry? padding;
const GlassmorphismContainer({
super.key,
required this.child,
this.borderRadius = 12,
this.sigmaX = 10,
this.sigmaY = 10,
this.padding,
});
@override
Widget build(BuildContext context) {
final isLight = Theme.of(context).brightness == Brightness.light;
final bgColor = isLight
? Colors.white.withValues(alpha: 0.7)
: Colors.black.withValues(alpha: 0.3);
final borderColor = isLight
? Colors.white.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.1);
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigmaX, sigmaY: sigmaY),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: borderColor),
),
child: child,
),
),
);
}
}
+445
View File
@@ -0,0 +1,445 @@
import 'dart:async';
import 'package:flutter/material.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_icon_utils.dart';
import '../../data/models/file_model.dart';
import '../../services/file_service.dart';
import '../../services/storage_service.dart';
import '../providers/file_manager_provider.dart';
import '../providers/navigation_provider.dart';
import 'glassmorphism_container.dart';
/// 搜索对话框
class SearchDialog extends StatefulWidget {
const SearchDialog({super.key});
static Future<void> show(BuildContext context) {
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: '搜索',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(),
);
}
@override
State<SearchDialog> createState() => _SearchDialogState();
}
class _SearchDialogState extends State<SearchDialog> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
List<FileModel> _files = [];
List<String> _searchHistory = [];
bool _isLoading = false;
String? _errorMessage;
bool _caseFolding = false;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_loadHistory();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _searchFocusNode.requestFocus();
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_searchFocusNode.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadHistory() async {
final history = await StorageService.instance.getSearchHistory();
if (mounted) setState(() => _searchHistory = history);
}
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
if (value.trim().isEmpty) {
setState(() {
_files = [];
_isLoading = false;
_errorMessage = null;
});
return;
}
setState(() => _isLoading = true);
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_performSearch(value.trim());
});
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await FileService().searchFiles(
name: query,
caseFolding: _caseFolding,
);
final filesData = response['files'] as List<dynamic>? ?? [];
final files = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList();
if (!mounted) return;
setState(() {
_files = files;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
}
void _toggleCaseFolding() {
setState(() => _caseFolding = !_caseFolding);
final query = _searchController.text.trim();
if (query.isNotEmpty) _performSearch(query);
}
Future<void> _clearHistory() async {
await StorageService.instance.clearSearchHistory();
if (mounted) setState(() => _searchHistory = []);
}
Future<void> _onResultTap(FileModel file) async {
final query = _searchController.text.trim();
// 在 async gap 之前获取所有引用
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager =
Provider.of<FileManagerProvider>(context, listen: false);
final parentPath = _extractParentPath(file.path);
final filePath = file.path;
if (query.isNotEmpty) {
await StorageService.instance.addToSearchHistory(query);
}
if (parentPath == null || !mounted) return;
Navigator.of(context).pop();
navProvider.setIndex(1);
fileManager.navigateAndHighlight(parentPath, filePath);
}
String? _extractParentPath(String filePath) {
const prefix = 'cloudreve://my';
if (!filePath.startsWith(prefix)) return null;
final relativePath = filePath.substring(prefix.length);
if (relativePath.isEmpty) return null;
final parts = relativePath.split('/');
if (parts.length > 1) parts.removeLast();
return parts.isEmpty ? '/' : parts.join('/');
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final isWide = screenWidth >= 600;
final dialogWidth = isWide ? 560.0 : screenWidth - 32.0;
return Center(
child: Container(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSearchInput(context),
const Divider(height: 1),
Flexible(child: _buildBody(context)),
],
),
),
),
),
),
);
}
Widget _buildSearchInput(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(LucideIcons.search, size: 20, color: theme.hintColor),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: '搜索文件...',
border: InputBorder.none,
hintStyle: TextStyle(color: theme.hintColor),
),
style: theme.textTheme.bodyLarge,
textInputAction: TextInputAction.search,
onChanged: _onSearchChanged,
onSubmitted: (value) {
_debounceTimer?.cancel();
_performSearch(value.trim());
},
),
),
IconButton(
icon: Icon(
LucideIcons.caseSensitive,
size: 18,
color: _caseFolding ? colorScheme.primary : theme.hintColor,
),
onPressed: _toggleCaseFolding,
tooltip: '忽略大小写',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
if (_searchController.text.isNotEmpty)
IconButton(
icon: Icon(LucideIcons.x, size: 18, color: theme.hintColor),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
Widget _buildBody(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (_isLoading && _files.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (_errorMessage != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.alertCircle,
size: 40, color: colorScheme.error.withValues(alpha: 0.7)),
const SizedBox(height: 8),
Text(_errorMessage!,
style: TextStyle(color: theme.hintColor),
textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () =>
_performSearch(_searchController.text.trim()),
child: const Text('重试'),
),
],
),
),
);
}
if (_files.isEmpty) {
final query = _searchController.text.trim();
if (query.isEmpty && _searchHistory.isNotEmpty) {
return _buildHistorySection(context);
}
if (query.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.searchX,
size: 40,
color: theme.hintColor.withValues(alpha: 0.5)),
const SizedBox(height: 8),
Text('未找到匹配文件',
style: TextStyle(color: theme.hintColor)),
],
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text('输入关键词搜索文件',
style: TextStyle(color: theme.hintColor)),
),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
controller: _scrollController,
shrinkWrap: true,
itemCount: _files.length,
separatorBuilder: (context, index) =>
Divider(height: 1, indent: 68, endIndent: 20),
itemBuilder: (context, index) =>
_buildResultItem(context, _files[index]),
),
);
}
Widget _buildHistorySection(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(LucideIcons.history, size: 16, color: theme.hintColor),
const SizedBox(width: 6),
Text('搜索历史',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.hintColor)),
const Spacer(),
GestureDetector(
onTap: _clearHistory,
child: Text('清除',
style:
TextStyle(fontSize: 12, color: colorScheme.primary)),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _searchHistory.map((query) {
return ActionChip(
label: Text(query, style: const TextStyle(fontSize: 13)),
onPressed: () {
_searchController.text = query;
_onSearchChanged(query);
},
avatar: Icon(LucideIcons.search, size: 14),
);
}).toList(),
),
],
),
);
}
Widget _buildResultItem(BuildContext context, FileModel file) {
final theme = Theme.of(context);
return InkWell(
onTap: () => _onResultTap(file),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
FileIconUtils.buildIconWidget(context: context, file: file),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
Uri.decodeComponent(file.relativePath),
style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Text(
file.isFolder
? '文件夹'
: date_utils.DateUtils.formatFileSize(file.size),
style: TextStyle(fontSize: 12, color: theme.hintColor),
),
],
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// 选择工具栏组件
class SelectionToolbar extends StatelessWidget {
final int selectionCount;
final VoidCallback onCancel;
final VoidCallback? onRename;
final VoidCallback? onMove;
final VoidCallback? onCopy;
final VoidCallback onDelete;
const SelectionToolbar({
super.key,
required this.selectionCount,
required this.onCancel,
this.onRename,
this.onMove,
this.onCopy,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Text(
'已选择 $selectionCount',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.cancel),
onPressed: onCancel,
tooltip: '取消选择',
),
if (selectionCount == 1 && onRename != null)
IconButton(
icon: const Icon(Icons.edit),
onPressed: onRename,
tooltip: '重命名',
),
if (onMove != null)
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
onPressed: onMove,
tooltip: '移动',
),
if (onCopy != null)
IconButton(
icon: const Icon(Icons.content_copy),
onPressed: onCopy,
tooltip: '复制',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: onDelete,
tooltip: '删除',
color: Colors.red,
),
],
),
);
}
}
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import '../../data/models/share_model.dart';
/// 截断文件名,智能截断避免多个小数点
String _truncateFileName(String name, int maxLength) {
if (name.length <= maxLength) {
return name;
}
// 查找最后一个点
final lastDotIndex = name.lastIndexOf('.');
// 如果没有点,或者点在开头或末尾,直接截断
if (lastDotIndex <= 0 || lastDotIndex >= name.length - 1) {
return '${name.substring(0, maxLength - 3)}...';
}
// 有扩展名的情况
final extension = name.substring(lastDotIndex);
final nameWithoutExt = name.substring(0, lastDotIndex);
// 扩展名太长,直接截断整个文件名
if (extension.length >= maxLength) {
return '${name.substring(0, maxLength - 3)}...';
}
// 计算主体部分能显示的长度
const ellipsis = '...';
final maxNameLength = maxLength - extension.length - ellipsis.length;
// 空间太小,主体部分至少保留3个字符
if (maxNameLength < 3) {
return '${name.substring(0, maxLength - 3)}...';
}
// 主体部分能完整显示
if (nameWithoutExt.length <= maxNameLength) {
return '$nameWithoutExt$ellipsis$extension';
}
// 主体部分需要截断
return '${nameWithoutExt.substring(0, maxNameLength)}$ellipsis$extension';
}
/// 文件名显示组件
class _FileNameDisplay extends StatelessWidget {
final String name;
final Color? textColor;
const _FileNameDisplay({
required this.name,
this.textColor,
});
/// 精确计算文本宽度
String _fitText(String text, double maxWidth, TextStyle style) {
final textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
);
textPainter.layout();
if (textPainter.width <= maxWidth) {
return text;
}
// 二分查找最佳长度
int min = 1;
int max = text.length;
int best = 0;
while (min <= max) {
final mid = ((min + max) / 2).floor();
final candidate = _truncateFileName(text, mid);
final painter = TextPainter(
text: TextSpan(text: candidate, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
);
painter.layout();
if (painter.width <= maxWidth) {
best = mid;
min = mid + 1;
} else {
max = mid - 1;
}
}
return _truncateFileName(text, best);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final style = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: textColor,
);
final fittedName = _fitText(name, maxWidth, style);
return Text(
fittedName,
style: style,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
);
}
}
/// 分享列表项
class ShareListItem extends StatelessWidget {
final ShareModel share;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const ShareListItem({
super.key,
required this.share,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final expired = share.expired;
final textColor = expired ? Colors.grey.shade600 : null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: onEdit,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
flex: 65,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
share.isFolder
? Icons.folder
: Icons.insert_drive_file_outlined,
color: textColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: _FileNameDisplay(
name: share.name,
textColor: textColor,
),
),
],
),
const SizedBox(height: 4),
Text(
share.url,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Expanded(
flex: 35,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (share.isPrivate ?? false) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock, size: 14, color: Colors.blue),
],
),
),
],
if (share.downloaded != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${share.downloaded}',
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
),
Icon(Icons.download, size: 14, color: Colors.green.shade700),
],
),
),
],
...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${share.visited}',
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
),
Icon(Icons.visibility, size: 14, color: Colors.yellow.shade700),
],
),
),
],
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onEdit != null) ...[
IconButton(
icon: const Icon(Icons.edit, size: 24),
onPressed: onEdit,
tooltip: '编辑',
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
if (onDelete != null) ...[
IconButton(
icon: const Icon(Icons.delete, size: 24),
onPressed: onDelete,
tooltip: '删除',
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
style: IconButton.styleFrom(
foregroundColor: Colors.red.shade700,
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
],
),
],
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,92 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../data/models/file_model.dart';
import '../../services/cache_manager_service.dart';
import '../../services/thumbnail_service.dart';
import '../../core/utils/file_icon_utils.dart';
/// 缩略图加载组件 — 异步获取 URL 并用 CachedNetworkImage 渲染
class ThumbnailImage extends StatefulWidget {
final FileModel file;
final String? contextHint;
final double borderRadius;
const ThumbnailImage({
super.key,
required this.file,
this.contextHint,
this.borderRadius = 10,
});
@override
State<ThumbnailImage> createState() => _ThumbnailImageState();
}
class _ThumbnailImageState extends State<ThumbnailImage> {
String? _imageUrl;
bool _isLoading = true;
bool _hasError = false;
@override
void initState() {
super.initState();
_loadThumbnail();
}
@override
void didUpdateWidget(ThumbnailImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.file.path != widget.file.path) {
_loadThumbnail();
}
}
Future<void> _loadThumbnail() async {
setState(() {
_isLoading = true;
_hasError = false;
});
final url = await ThumbnailService.instance.getThumbnailUrl(
fileUri: widget.file.relativePath,
contextHint: widget.contextHint,
);
if (!mounted) return;
setState(() {
_imageUrl = url;
_isLoading = false;
_hasError = url == null;
});
}
Widget _buildPlaceholder(BuildContext context) {
return Center(
child: FileIconUtils.buildIconWidget(
context: context,
file: widget.file,
size: 40,
iconSize: 22,
borderRadius: widget.borderRadius,
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) return _buildPlaceholder(context);
if (_hasError || _imageUrl == null) return _buildPlaceholder(context);
return ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: CachedNetworkImage(
imageUrl: _imageUrl!,
cacheManager: CacheManagerService.instance.manager,
fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholder(context),
errorWidget: (context, url, error) => _buildPlaceholder(context),
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:oktoast/oktoast.dart';
/// Toast 封装组件
class ToastHelper {
ToastHelper._();
/// 显示成功提示
static void success(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFE8F5E9),
textColor: const Color(0xFF2E7D32),
);
}
/// 显示失败提示
static void failure(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFFFEBEE),
textColor: const Color(0xFFC62828),
);
}
/// 显示错误提示
static void error(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFFFF3E0),
textColor: const Color(0xFFEF6C00),
duration: const Duration(seconds: 3),
);
}
/// 显示警告提示
static void warning(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFFFF8E1),
textColor: const Color(0xFFFF8F00),
);
}
/// 显示信息提示
static void info(String message) {
_showToast(
message,
backgroundColor: const Color(0xFFE3F2FD),
textColor: const Color(0xFF1565C0),
);
}
static void _showToast(
String message, {
required Color backgroundColor,
required Color textColor,
Duration duration = const Duration(seconds: 2),
}) {
showToastWidget(
_buildToast(message: message, backgroundColor: backgroundColor, textColor: textColor),
duration: duration,
position: ToastPosition.bottom,
);
}
static Widget _buildToast({
required String message,
required Color backgroundColor,
required Color textColor,
}) {
return Builder(builder: (context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: textColor.withValues(alpha: 0.15)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Text(
message,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: theme.textTheme.bodyMedium?.fontFamily,
),
),
);
});
}
/// 显示自定义图标提示
static void showWithIcon({
required String message,
required IconData icon,
required Color iconColor,
required Color backgroundColor,
required Color textColor,
Duration duration = const Duration(seconds: 2),
}) {
showToastWidget(
_buildIconToast(
message: message,
icon: icon,
iconColor: iconColor,
backgroundColor: backgroundColor,
textColor: textColor,
),
duration: duration,
position: ToastPosition.bottom,
);
}
static Widget _buildIconToast({
required String message,
required IconData icon,
required Color iconColor,
required Color backgroundColor,
required Color textColor,
}) {
return Builder(builder: (context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: iconColor.withValues(alpha: 0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 10),
Flexible(
child: Text(
message,
style: TextStyle(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: theme.textTheme.bodyMedium?.fontFamily,
),
),
),
],
),
);
});
}
}
+276
View File
@@ -0,0 +1,276 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../providers/upload_manager_provider.dart';
import '../providers/file_manager_provider.dart';
import '../providers/navigation_provider.dart';
import 'glassmorphism_container.dart';
import 'toast_helper.dart';
/// 显示上传对话框(毛玻璃风格)
void showUploadDialog(BuildContext context) {
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: '上传',
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) =>
const _UploadDialogContent(),
);
}
class _UploadDialogContent extends StatelessWidget {
const _UploadDialogContent();
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 420.0 : screenWidth - 48.0;
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(context),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: _buildFileSelectionButtons(context),
),
const Divider(height: 1),
_buildViewTasksButton(context),
],
),
),
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(LucideIcons.uploadCloud, size: 20, color: theme.hintColor),
const SizedBox(width: 10),
Text(
'选择要上传的文件',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
);
}
Widget _buildFileSelectionButtons(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildUploadOption(
context,
icon: LucideIcons.image,
label: '选择图片',
description: 'JPG, PNG, GIF, WebP 等',
color: Colors.purple.shade400,
onTap: () => _pickFiles(context, FileType.image),
),
const SizedBox(height: 12),
_buildUploadOption(
context,
icon: LucideIcons.video,
label: '选择视频',
description: 'MP4, AVI, MKV 等',
color: Colors.orange.shade400,
onTap: () => _pickFiles(context, FileType.video),
),
const SizedBox(height: 12),
_buildUploadOption(
context,
icon: LucideIcons.file,
label: '选择所有文件',
description: '任意类型文件',
color: colorScheme.primary,
onTap: () => _pickFiles(context, FileType.any),
),
],
);
}
Widget _buildUploadOption(
BuildContext context, {
required IconData icon,
required String label,
required String description,
required Color color,
required VoidCallback onTap,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 20, color: color),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
description,
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
),
),
Icon(LucideIcons.chevronRight, size: 18, color: Theme.of(context).hintColor),
],
),
),
);
}
Widget _buildViewTasksButton(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () {
Navigator.of(context).pop();
Provider.of<NavigationProvider>(context, listen: false).setIndex(2);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.list, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'查看上传任务',
style: TextStyle(
fontWeight: FontWeight.w500,
color: colorScheme.primary,
),
),
],
),
),
);
}
Future<void> _pickFiles(BuildContext context, FileType type) async {
try {
final result = await FilePicker.platform.pickFiles(
type: type,
allowMultiple: true,
);
if (context.mounted) {
Navigator.of(context).pop();
}
if (!context.mounted) return;
if (result == null || result.files.isEmpty) {
ToastHelper.warning('未选择文件');
return;
}
final files = <File>[];
for (final file in result.files) {
if (file.path != null) {
files.add(File(file.path!));
}
}
if (files.isEmpty) {
if (!context.mounted) return;
ToastHelper.error('无法获取文件路径');
return;
}
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
uploadManager.markShouldShowDialog();
await uploadManager.startUpload(files, fileManager.currentPath);
if (context.mounted) {
ToastHelper.info('上传已开始,查看任务页');
}
} catch (e) {
if (!context.mounted) return;
ToastHelper.failure('选择文件失败: $e');
}
}
}
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/upload_task_model.dart';
import '../../services/upload_service.dart';
import '../providers/upload_manager_provider.dart';
import 'upload_progress_item.dart';
/// 上传进度对话框
class UploadProgressDialog extends StatelessWidget {
const UploadProgressDialog({super.key});
@override
Widget build(BuildContext context) {
return Consumer<UploadService>(
builder: (context, uploadService, child) {
final tasks = uploadService.allTasks;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 180),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300,
// 当没有任务时,最小高度约为2个任务的大小(约160px)
minHeight: tasks.isEmpty ? 160 : 0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题栏
_buildHeader(context, uploadService, tasks),
// 任务列表
Flexible(
child: tasks.isEmpty
? _buildEmptyState(context)
: _buildTaskList(context, uploadService, tasks),
),
// 底部操作栏
_buildFooter(context, uploadService, tasks),
],
),
),
);
},
);
}
Widget _buildEmptyState(BuildContext context) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload,
size: 48,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'暂无上传任务',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
);
}
Widget _buildTaskList(
BuildContext context,
UploadService uploadService,
List<UploadTaskModel> tasks,
) {
return ListView.separated(
shrinkWrap: true,
itemCount: tasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = tasks[index];
return UploadProgressItem(
key: ValueKey(task.id),
task: task,
onPause: () => uploadService.cancelUpload(task.id),
onResume: () => uploadService.retryUpload(task.id),
onCancel: () => uploadService.cancelUpload(task.id),
onDelete: () async {
uploadService.removeTask(task.id);
},
onRetry: () => uploadService.retryUpload(task.id),
);
},
);
}
Widget _buildHeader(
BuildContext context,
UploadService uploadService,
List<UploadTaskModel> tasks,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
const Icon(Icons.cloud_upload, color: Colors.blue),
const SizedBox(width: 12),
const Text(
'上传任务',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Consumer<UploadService>(
builder: (context, uploadService, child) {
final uploadingCount = uploadService.activeTasks.length;
if (uploadingCount > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'上传中: $uploadingCount',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
);
}
return const SizedBox.shrink();
},
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
uploadManager.hideDialog();
Navigator.of(context).pop();
},
tooltip: '关闭',
),
],
),
);
}
Widget _buildFooter(
BuildContext context,
UploadService uploadService,
List<UploadTaskModel> tasks,
) {
final hasCompleted = tasks.any((t) => t.status == UploadStatus.completed);
final hasFailed = tasks.any((t) => t.status == UploadStatus.failed);
final hasCancelled = tasks.any((t) => t.status == UploadStatus.cancelled);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
if (hasCompleted || hasCancelled)
TextButton.icon(
icon: const Icon(Icons.delete_sweep, size: 18),
label: const Text('清除已完成'),
onPressed: () async {
uploadService.clearCompletedTasks();
},
),
if (hasFailed)
TextButton.icon(
icon: const Icon(Icons.clear_all, size: 18),
label: const Text('清除失败'),
onPressed: () {
uploadService.clearFailedTasks();
},
),
const Spacer(),
FilledButton(
onPressed: () {
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
uploadManager.hideDialog();
Navigator.of(context).pop();
},
child: const Text('关闭'),
),
],
),
);
}
}
/// 显示上传对话框
void showUploadDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => const UploadProgressDialog(),
barrierDismissible: false,
);
}
@@ -0,0 +1,336 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../data/models/upload_task_model.dart';
/// 上传任务列表项
class UploadProgressItem extends StatelessWidget {
final UploadTaskModel task;
final VoidCallback? onPause;
final VoidCallback? onResume;
final VoidCallback? onCancel;
final VoidCallback? onDelete;
final VoidCallback? onRetry;
final VoidCallback? onNavigate;
const UploadProgressItem({
super.key,
required this.task,
this.onPause,
this.onResume,
this.onCancel,
this.onDelete,
this.onRetry,
this.onNavigate,
});
@override
Widget build(BuildContext context) {
final isUploading = task.status == UploadStatus.uploading;
final isWaiting = task.status == UploadStatus.waiting;
final isPaused = task.status == UploadStatus.paused;
final isFailed = task.status == UploadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: _getCardColor(context, task.status),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getBorderColor(context, task.status),
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildStatusIcon(context, task.status),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.fileName,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
_buildStatusRow(context, task),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: _buildActionButtons(context, task),
),
],
),
if (isUploading || isWaiting || isPaused) ...[
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: isPaused ? null : task.progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
isPaused ? '已暂停' : task.progressText,
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${task.uploadedBytes}/${task.fileSize} bytes',
style: const TextStyle(fontSize: 12),
),
const Spacer(),
if (isUploading && task.speedText.isNotEmpty) ...[
Text(
task.speedText,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
],
Text(
task.readableFileSize,
style: const TextStyle(fontSize: 12),
),
],
),
] else if (isFailed && task.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
task.errorMessage!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
);
}
Widget _buildStatusIcon(BuildContext context, UploadStatus status) {
final color = _getStatusColor(status);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getStatusIcon(status),
size: 18,
color: color,
),
);
}
Widget _buildStatusRow(BuildContext context, UploadTaskModel task) {
final color = _getStatusColor(task.status);
final isCompleted = task.status == UploadStatus.completed;
return Row(
children: [
Text(
task.statusText,
style: TextStyle(fontSize: 12, color: color),
),
if (isCompleted) ...[
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
task.readableFileSize,
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
' · ',
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
Text(
_formatDateTime(task.completedAt!),
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
),
],
],
);
}
List<Widget> _buildActionButtons(
BuildContext context,
UploadTaskModel task,
) {
final errorColor = Theme.of(context).colorScheme.error;
switch (task.status) {
case UploadStatus.waiting:
case UploadStatus.uploading:
return [
IconButton(
icon: const Icon(Icons.pause, size: 20),
onPressed: onPause,
tooltip: '暂停',
),
];
case UploadStatus.paused:
return [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: onResume,
tooltip: '继续',
),
IconButton(
icon: Icon(Icons.cancel, size: 20, color: errorColor),
onPressed: onCancel,
tooltip: '取消',
),
];
case UploadStatus.failed:
return [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: onRetry,
tooltip: '重试',
),
IconButton(
icon: Icon(Icons.delete, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case UploadStatus.completed:
return [
if (onNavigate != null)
IconButton(
icon: const Icon(LucideIcons.folderOpen, size: 20),
onPressed: onNavigate,
tooltip: '打开文件夹',
),
IconButton(
icon: Icon(Icons.delete_outline, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
case UploadStatus.cancelled:
return [
IconButton(
icon: Icon(Icons.delete, size: 20, color: errorColor),
onPressed: onDelete,
tooltip: '删除',
),
];
}
}
IconData _getStatusIcon(UploadStatus status) {
switch (status) {
case UploadStatus.waiting:
return LucideIcons.clock;
case UploadStatus.uploading:
return LucideIcons.upload;
case UploadStatus.completed:
return LucideIcons.checkCircle2;
case UploadStatus.paused:
return LucideIcons.pause;
case UploadStatus.failed:
case UploadStatus.cancelled:
return LucideIcons.xCircle;
}
}
Color _getStatusColor(UploadStatus status) {
switch (status) {
case UploadStatus.waiting:
return Colors.orange;
case UploadStatus.uploading:
return Colors.blue;
case UploadStatus.completed:
return Colors.green;
case UploadStatus.paused:
return Colors.orange;
case UploadStatus.failed:
case UploadStatus.cancelled:
return Colors.red;
}
}
Color _getCardColor(BuildContext context, UploadStatus status) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case UploadStatus.completed:
return isDark ? Colors.green.withValues(alpha: 0.08) : Colors.green.withValues(alpha: 0.05);
case UploadStatus.failed:
case UploadStatus.cancelled:
return isDark ? Colors.red.withValues(alpha: 0.08) : Colors.red.withValues(alpha: 0.05);
default:
return isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white.withValues(alpha: 0.6);
}
}
Color _getBorderColor(BuildContext context, UploadStatus status) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case UploadStatus.completed:
return Colors.green.withValues(alpha: isDark ? 0.2 : 0.15);
case UploadStatus.failed:
case UploadStatus.cancelled:
return Colors.red.withValues(alpha: isDark ? 0.2 : 0.15);
default:
return isDark ? Colors.white.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.3);
}
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/services/avatar_cache_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// 用户头像组件
/// 1. 缓存存在 → 直接 FileImage 显示
/// 2. 缓存不存在 → 默认显示首字母 fallback,异步 getAvatar,成功后更新 UI
class UserAvatar extends StatefulWidget {
final String userId;
final String? email;
final String displayName;
final double radius;
final String? avatarType;
const UserAvatar({
super.key,
required this.userId,
this.email,
required this.displayName,
this.radius = 24,
this.avatarType,
});
@override
State<UserAvatar> createState() => _UserAvatarState();
/// 清除指定用户的头像缓存(头像上传/切换后调用)
static Future<void> evictCache(String userId) async {
await AvatarCacheService.instance.evictCache(userId);
}
/// 清除所有头像缓存(登出时调用)
static Future<void> clearAllCache() async {
await AvatarCacheService.instance.clearAllCache();
}
}
class _UserAvatarState extends State<UserAvatar> {
File? _avatarFile;
bool _loadTriggered = false;
String get _initial {
final name = widget.displayName;
return name.isNotEmpty ? name[0].toUpperCase() : 'U';
}
@override
void initState() {
super.initState();
_checkCache();
AvatarCacheService.instance.addListener(_onServiceNotify);
}
@override
void didUpdateWidget(UserAvatar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId || oldWidget.email != widget.email) {
_avatarFile = null;
_loadTriggered = false;
_checkCache();
}
}
@override
void dispose() {
AvatarCacheService.instance.removeListener(_onServiceNotify);
super.dispose();
}
void _checkCache() {
if (widget.userId.isEmpty) return;
final file = AvatarCacheService.instance.getCachedFile(widget.userId);
if (file != null) {
_avatarFile = file;
}
}
void _onServiceNotify() {
final file = AvatarCacheService.instance.getCachedFile(widget.userId);
if (file != null && _avatarFile?.path != file.path) {
setState(() => _avatarFile = file);
}
}
Future<void> _loadIfMissing() async {
if (_loadTriggered || widget.userId.isEmpty) return;
_loadTriggered = true;
final auth = context.read<AuthProvider>();
await AvatarCacheService.instance.getAvatar(
widget.userId,
baseUrl: auth.currentServer?.baseUrl,
token: auth.token?.accessToken,
email: widget.email,
);
// getAvatar 成功后 notifyListeners → _onServiceNotify 更新 _avatarFile
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// 有缓存文件,显示图片
if (_avatarFile != null) {
return CircleAvatar(
radius: widget.radius,
backgroundColor: colorScheme.primaryContainer,
backgroundImage: FileImage(_avatarFile!),
);
}
// 缓存不存在,触发异步加载
_loadIfMissing();
// Fallback: 首字母(默认展示,避免空白)
return CircleAvatar(
radius: widget.radius,
backgroundColor: colorScheme.primaryContainer,
child: Text(
_initial,
style: TextStyle(
fontSize: widget.radius * 0.75,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
);
}
}