二次开发源码提交
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user