Files
2026-05-15 09:03:09 +08:00

1689 lines
62 KiB
Dart

import 'dart:async';
import 'dart:ui';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:cloudreve4_flutter/presentation/widgets/folder_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../data/models/remote_download_task_model.dart';
import '../../../services/remote_download_service.dart';
import '../../providers/file_manager_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../widgets/toast_helper.dart';
class _StatusColors {
final Color background;
final Color foreground;
final Color icon;
const _StatusColors({
required this.background,
required this.foreground,
required this.icon,
});
}
/// 离线下载页面
class RemoteDownloadPage extends StatefulWidget {
const RemoteDownloadPage({super.key});
@override
State<RemoteDownloadPage> createState() => _RemoteDownloadPageState();
}
class _RemoteDownloadPageState extends State<RemoteDownloadPage>
with SingleTickerProviderStateMixin {
final RemoteDownloadService _service = RemoteDownloadService();
List<RemoteDownloadTaskModel> _downloadingTasks = [];
List<RemoteDownloadTaskModel> _completedTasks = [];
bool _isLoading = false;
Timer? _pollTimer;
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
late TabController _tabController;
static const double _desktopBreakpoint = 800;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_onTabChanged);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadTasks());
}
@override
void dispose() {
_pollTimer?.cancel();
_searchController.dispose();
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _onTabChanged() {
if (_tabController.indexIsChanging) return;
_startOrStopPolling();
}
void _startOrStopPolling() {
_pollTimer?.cancel();
int refreshTime = 1;
if (_tabController.index == 0 && _downloadingTasks.isNotEmpty) {
_pollTimer = Timer.periodic(
Duration(seconds: refreshTime), (_) => _loadTasks(quiet: true));
}
}
Future<void> _loadTasks({bool quiet = false}) async {
if (!mounted) return;
if (!quiet) setState(() => _isLoading = true);
try {
final results = await Future.wait([
_service.listTasks(category: 'downloading', pageSize: 50),
_service.listTasks(category: 'downloaded', pageSize: 50),
]);
if (!mounted) return;
final dlData = results[0];
final cdData = results[1];
final dlTasks = (dlData['tasks'] as List<dynamic>? ?? [])
.map((t) =>
RemoteDownloadTaskModel.fromJson(t as Map<String, dynamic>))
.toList();
final cdTasks = (cdData['tasks'] as List<dynamic>? ?? [])
.map((t) =>
RemoteDownloadTaskModel.fromJson(t as Map<String, dynamic>))
.toList();
setState(() {
_downloadingTasks = dlTasks;
_completedTasks = cdTasks;
_isLoading = false;
});
_startOrStopPolling();
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
if (!quiet) ToastHelper.failure('加载失败: $e');
}
}
// ─── 辅助方法 ───
_StatusColors _getStatusColors(RemoteDownloadStatus status) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case RemoteDownloadStatus.queued:
return _StatusColors(
background: isDark
? Colors.grey.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.1),
foreground: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
icon: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
);
case RemoteDownloadStatus.running:
return _StatusColors(
background: colorScheme.primary.withValues(alpha: 0.1),
foreground: colorScheme.primary,
icon: colorScheme.primary,
);
case RemoteDownloadStatus.completed:
return _StatusColors(
background: Colors.green.withValues(alpha: isDark ? 0.2 : 0.1),
foreground: isDark ? Colors.green.shade300 : Colors.green.shade700,
icon: isDark ? Colors.green.shade300 : Colors.green.shade600,
);
case RemoteDownloadStatus.error:
return _StatusColors(
background: colorScheme.error.withValues(alpha: 0.1),
foreground: colorScheme.error,
icon: colorScheme.error,
);
case RemoteDownloadStatus.suspending:
case RemoteDownloadStatus.suspended:
return _StatusColors(
background: Colors.orange.withValues(alpha: isDark ? 0.2 : 0.1),
foreground:
isDark ? Colors.orange.shade300 : Colors.orange.shade700,
icon: isDark ? Colors.orange.shade300 : Colors.orange.shade600,
);
}
}
IconData _getStatusIcon(RemoteDownloadStatus status) {
switch (status) {
case RemoteDownloadStatus.queued:
return LucideIcons.clock;
case RemoteDownloadStatus.running:
return LucideIcons.download;
case RemoteDownloadStatus.completed:
return LucideIcons.checkCircle2;
case RemoteDownloadStatus.error:
return LucideIcons.alertCircle;
case RemoteDownloadStatus.suspending:
case RemoteDownloadStatus.suspended:
return LucideIcons.pause;
}
}
Widget _buildCountBadge(int count) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
constraints: const BoxConstraints(minWidth: 24),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
'$count',
key: ValueKey(count),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
);
}
Widget _buildStatusBadge(RemoteDownloadStatus status) {
final colors = _getStatusColors(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(6),
),
child: Text(
status.text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: colors.foreground,
),
),
);
}
Widget _buildLeadingIcon(RemoteDownloadStatus status) {
final colors = _getStatusColors(status);
return Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getStatusIcon(status), color: colors.icon, size: 18),
);
}
// ─── 页面构建 ───
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('离线下载',
style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
centerTitle: !isDesktop,
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('下载中'),
const SizedBox(width: 8),
_buildCountBadge(_downloadingTasks.length),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('已完成'),
const SizedBox(width: 8),
_buildCountBadge(_completedTasks.length),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw),
onPressed: () => _loadTasks(),
tooltip: '刷新',
),
if (isDesktop) const SizedBox(width: 16),
],
),
body: Column(
children: [
_buildHeaderBar(isDesktop),
Expanded(
child: _buildBody(context, isDesktop),
),
],
),
floatingActionButton: isDesktop
? null
: FloatingActionButton.extended(
onPressed: () => _showCreateDialog(context),
label: const Text('新建任务'),
icon: const Icon(LucideIcons.plus),
),
);
}
Widget _buildHeaderBar(bool isDesktop) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? 32 : 16, vertical: 12),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 40,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索任务...',
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
prefixIcon: const Icon(LucideIcons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
onChanged: (value) {
_searchQuery = value.toLowerCase();
setState(() {});
},
),
),
),
if (isDesktop) ...[
const SizedBox(width: 16),
Text(
'下载中 ${_downloadingTasks.length} / 已完成 ${_completedTasks.length}',
style: TextStyle(
color: colorScheme.onSurfaceVariant, fontSize: 13),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: () => _showCreateDialog(context),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('新建任务'),
),
],
],
),
);
}
Widget _buildBody(BuildContext context, bool isDesktop) {
final filteredDownloading = _searchQuery.isEmpty
? _downloadingTasks
: _downloadingTasks
.where((t) => t.displayName.toLowerCase().contains(_searchQuery))
.toList();
final filteredCompleted = _searchQuery.isEmpty
? _completedTasks
: _completedTasks
.where((t) => t.displayName.toLowerCase().contains(_searchQuery))
.toList();
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
child: _isLoading &&
_downloadingTasks.isEmpty &&
_completedTasks.isEmpty
? const Center(
key: ValueKey('loading'),
child: CircularProgressIndicator(),
)
: _searchQuery.isNotEmpty &&
filteredDownloading.isEmpty &&
filteredCompleted.isEmpty
? _buildNoSearchResult()
: TabBarView(
key: const ValueKey('content'),
controller: _tabController,
children: [
_buildTaskList(filteredDownloading,
isOngoing: true, isDesktop: isDesktop),
_buildTaskList(filteredCompleted,
isOngoing: false, isDesktop: isDesktop),
],
),
);
}
Widget _buildTaskList(List<RemoteDownloadTaskModel> tasks,
{required bool isOngoing, required bool isDesktop}) {
if (tasks.isEmpty) {
return _buildEmptyState(isOngoing: isOngoing);
}
return RefreshIndicator(
onRefresh: () => _loadTasks(),
child: isDesktop
? _buildDesktopLayout(tasks, isOngoing)
: _buildMobileLayout(tasks, isOngoing),
);
}
// ─── 桌面端布局 ───
Widget _buildDesktopLayout(
List<RemoteDownloadTaskModel> tasks, bool isOngoing) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: SizedBox(
width: double.infinity,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: isOngoing
? _buildDownloadingTable(tasks)
: _buildCompletedTable(tasks),
),
),
);
}
Widget _buildDownloadingTable(List<RemoteDownloadTaskModel> tasks) {
final colorScheme = Theme.of(context).colorScheme;
return DataTable(
showCheckboxColumn: false,
headingRowColor: WidgetStateProperty.all(
colorScheme.surfaceContainerHighest),
columnSpacing: 24,
columns: const [
DataColumn(
label: Text('名称',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('状态',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('进度',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('大小/速度',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('创建时间',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('操作',
style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: tasks.map((task) {
final download = task.summary?.download;
return DataRow(
onSelectChanged: (_) => _showTaskDetail(task),
cells: [
DataCell(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 250),
child: Row(
children: [
_buildLeadingIcon(task.status),
const SizedBox(width: 12),
Expanded(
child: Text(
task.displayName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
),
),
DataCell(_buildStatusBadge(task.status)),
DataCell(
download != null && download.total > 0
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: download.progress.clamp(0.0, 1.0),
backgroundColor:
colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary),
),
),
const SizedBox(width: 8),
Text(
'${(download.progress * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 12)),
],
)
: const Text('-', style: TextStyle(fontSize: 12)),
),
DataCell(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 150),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (download != null)
Text(
'${_formatSize(download.downloaded)} / ${_formatSize(download.total)}',
style: TextStyle(
fontSize: 12, color: colorScheme.onSurfaceVariant),
overflow: TextOverflow.ellipsis,
),
if (download != null && download.speedText.isNotEmpty)
Text(
download.speedText,
style: TextStyle(
fontSize: 11,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
DataCell(Text(_formatTime(task.createdAt),
style: TextStyle(
fontSize: 12, color: colorScheme.onSurfaceVariant))),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (download != null && download.files.isNotEmpty)
IconButton(
icon: const Icon(LucideIcons.layoutList, size: 18),
onPressed: () {
if (!mounted) return;
Future.microtask(() {
if (!mounted) return;
_showFilesDialog(context, task);
});
},
tooltip: '查看文件',
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(32, 32),
),
),
if (task.status.isOngoing)
IconButton(
icon: Icon(LucideIcons.xCircle,
size: 18, color: colorScheme.error),
onPressed: () => _cancelTask(task),
tooltip: '取消任务',
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(32, 32),
),
),
],
),
),
],
);
}).toList(),
);
}
Widget _buildCompletedTable(List<RemoteDownloadTaskModel> tasks) {
final colorScheme = Theme.of(context).colorScheme;
return DataTable(
showCheckboxColumn: false,
headingRowColor: WidgetStateProperty.all(
colorScheme.surfaceContainerHighest),
columnSpacing: 24,
columns: const [
DataColumn(
label: Text('名称',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('状态',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('大小',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('创建时间',
style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(
label: Text('操作',
style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: tasks.map((task) {
final download = task.summary?.download;
return DataRow(
onSelectChanged: (_) => _showTaskDetail(task),
cells: [
DataCell(
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Row(
children: [
_buildLeadingIcon(task.status),
const SizedBox(width: 12),
Expanded(
child: Text(
task.displayName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
),
),
DataCell(_buildStatusBadge(task.status)),
DataCell(
download != null
? Text(_formatSize(download.total),
style: TextStyle(
fontSize: 12, color: colorScheme.onSurfaceVariant))
: const Text('-', style: TextStyle(fontSize: 12)),
),
DataCell(Text(_formatTime(task.createdAt),
style: TextStyle(
fontSize: 12, color: colorScheme.onSurfaceVariant))),
DataCell(
download != null && download.files.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.layoutList, size: 18),
onPressed: () => _showFilesDialog(context, task),
tooltip: '查看文件',
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(32, 32),
),
)
: const SizedBox.shrink(),
),
],
);
}).toList(),
);
}
// ─── 移动端布局 ───
Widget _buildMobileLayout(
List<RemoteDownloadTaskModel> tasks, bool isOngoing) {
final theme = Theme.of(context);
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: tasks.length,
separatorBuilder: (_, _) => Divider(
height: 1,
indent: 64,
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
itemBuilder: (context, index) =>
_buildTaskCard(tasks[index], isOngoing),
);
}
Widget _buildTaskCard(RemoteDownloadTaskModel task, bool isOngoing) {
final download = task.summary?.download;
final hasFiles = download != null && download.files.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme;
final colors = _getStatusColors(task.status);
final theme = Theme.of(context);
return InkWell(
onTap: () => _showTaskDetail(task),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Left: status icon
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getStatusIcon(task.status),
color: colors.icon, size: 18),
),
const SizedBox(width: 12),
// Middle: name + status/speed/size
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.displayName,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_buildMobileSubtitle(task, isOngoing),
style: theme.textTheme.bodySmall
?.copyWith(color: Theme.of(context).hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// Right: more button
IconButton(
icon: const Icon(LucideIcons.moreVertical, size: 18),
onPressed: () =>
_showMobileActionMenu(task, isOngoing, hasFiles),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(4),
minimumSize: const Size(32, 32),
),
),
],
),
// Progress bar for downloading tasks
if (isOngoing && download != null && download.total > 0) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 48),
child: LinearProgressIndicator(
value: download.progress.clamp(0.0, 1.0),
minHeight: 4,
backgroundColor: colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary),
),
),
],
// Error text
if (task.error != null && task.error!.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 48),
child: Text(
task.error!,
style: TextStyle(fontSize: 12, color: colorScheme.error),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
);
}
String _buildMobileSubtitle(RemoteDownloadTaskModel task, bool isOngoing) {
final download = task.summary?.download;
final parts = <String>[task.status.text];
if (isOngoing && download != null) {
if (download.speedText.isNotEmpty) {
parts.add(download.speedText);
}
if (download.total > 0) {
parts.add(
'${_formatSize(download.downloaded)} / ${_formatSize(download.total)}');
}
} else if (!isOngoing && download != null) {
parts.add(_formatSize(download.total));
}
return parts.join(' · ');
}
// ─── 空状态 / 搜索无结果 ───
Widget _buildEmptyState({required bool isOngoing}) {
final colorScheme = Theme.of(context).colorScheme;
return RefreshIndicator(
onRefresh: () => _loadTasks(),
child: ListView(
children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.3),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isOngoing
? LucideIcons.download
: LucideIcons.checkCircle2,
size: 80,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
isOngoing ? '暂无下载中的任务' : '暂无已完成的任务',
style: TextStyle(
fontSize: 16, color: Theme.of(context).hintColor),
),
const SizedBox(height: 8),
Text(
isOngoing ? '点击右下角按钮新建任务' : '下载完成的任务将显示在这里',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
}
Widget _buildNoSearchResult() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
key: const ValueKey('no_result'),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.searchX, size: 64, color: colorScheme.outline),
const SizedBox(height: 16),
Text(
'没有找到 "$_searchQuery"',
style: TextStyle(
fontSize: 16, color: Theme.of(context).hintColor),
),
],
),
);
}
// ─── 移动端操作菜单 ───
void _showMobileActionMenu(
RemoteDownloadTaskModel task, bool isOngoing, bool hasFiles) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Text(
task.displayName,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.info),
title: const Text('查看详情'),
onTap: () {
Navigator.pop(context);
_showTaskDetail(task);
},
),
if (hasFiles)
ListTile(
leading: const Icon(LucideIcons.layoutList),
title: const Text('查看文件'),
onTap: () {
Navigator.pop(context);
_showFilesDialog(this.context, task);
},
),
if (isOngoing && task.status.isOngoing)
ListTile(
leading: Icon(LucideIcons.xCircle, color: colorScheme.error),
title: Text('取消任务',
style: TextStyle(color: colorScheme.error)),
onTap: () {
Navigator.pop(context);
_cancelTask(task);
},
),
],
),
),
);
}
// ─── 任务详情抽屉 ───
void _showTaskDetail(RemoteDownloadTaskModel task) {
final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint;
final colorScheme = Theme.of(context).colorScheme;
final download = task.summary?.download;
final hasFiles = download != null && download.files.isNotEmpty;
showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: '任务详情',
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween(begin: const Offset(1, 0), end: Offset.zero);
return SlideTransition(
position: tween.animate(CurvedAnimation(
parent: animation, curve: Curves.easeOutCubic)),
child: child,
);
},
pageBuilder: (context, animation, secondaryAnimation) {
return Align(
alignment: Alignment.centerRight,
child: Material(
color: colorScheme.surface,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(16)),
child: SafeArea(
right: false,
bottom: false,
child: Container(
width: isDesktop ? 420 : MediaQuery.of(context).size.width * 0.85,
height: MediaQuery.of(context).size.height,
decoration: BoxDecoration(
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(16)),
border: Border(
left: BorderSide(
color: colorScheme.outlineVariant
.withValues(alpha: 0.5))),
),
child: Column(
children: [
_buildDetailHeader(task, colorScheme),
const Divider(height: 1),
Expanded(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_buildDetailSection('基本信息', [
_buildDetailRow(
icon: LucideIcons.clock,
label: '创建于',
value: _formatDateTime(task.createdAt),
),
_buildDetailRow(
icon: LucideIcons.refreshCw,
label: '更新于',
value: _formatDateTime(task.updatedAt),
),
_buildDetailRow(
icon: _getStatusIcon(task.status),
label: '状态',
value: task.status.text,
valueWidget: _buildStatusBadge(task.status),
),
if (task.node != null)
_buildDetailRow(
icon: LucideIcons.server,
label: '处理节点',
value: task.node!.displayName,
),
]),
const SizedBox(height: 20),
_buildDetailSection('下载信息', [
_buildDetailRow(
icon: LucideIcons.logIn,
label: '输入',
value: task.srcDisplayText,
copyable: true,
),
_buildDetailRow(
icon: LucideIcons.logOut,
label: '输出',
value: task.dstDisplayText,
navigable: true,
onNavigate: () {
Navigator.of(context).pop();
_navigateToFolder(task);
},
),
_buildDetailRow(
icon: LucideIcons.timer,
label: '执行净耗时',
value: task.durationText,
),
_buildDetailRow(
icon: LucideIcons.refreshCw,
label: '重试次数',
value: '${task.retryCount}',
),
if (download != null && download.numPieces > 0)
_buildDetailRow(
icon: LucideIcons.layoutGrid,
label: '分片数量',
value: '${download.numPieces}',
),
if (download != null && download.total > 0) ...[
_buildDetailRow(
icon: LucideIcons.hardDrive,
label: '总大小',
value:
'${_formatSize(download.downloaded)} / ${_formatSize(download.total)}',
),
if (download.speedText.isNotEmpty)
_buildDetailRow(
icon: LucideIcons.gauge,
label: '下载速度',
value: download.speedText,
),
],
]),
if (task.error != null &&
task.error!.isNotEmpty) ...[
const SizedBox(height: 20),
_buildDetailSection('错误信息', [
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
task.error!,
style: TextStyle(
fontSize: 13,
color: colorScheme.onErrorContainer),
),
),
]),
],
const SizedBox(height: 20),
// 操作按钮
Row(
children: [
if (hasFiles)
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showFilesDialog(this.context, task);
},
icon: const Icon(LucideIcons.layoutList, size: 18),
label: const Text('查看文件'),
),
),
if (hasFiles &&
task.status.isOngoing)
const SizedBox(width: 12),
if (task.status.isOngoing)
Expanded(
child: FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
_cancelTask(task);
},
icon: Icon(LucideIcons.xCircle, size: 18),
label: const Text('取消任务'),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error),
),
),
],
),
],
),
),
],
),
), // Container
), // SafeArea
),
);
},
);
}
Widget _buildDetailHeader(
RemoteDownloadTaskModel task, ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 16),
child: Row(
children: [
_buildLeadingIcon(task.status),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.displayName,
style: const TextStyle(
fontWeight: FontWeight.w700, fontSize: 16),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'ID: ${task.id}',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace'),
),
],
),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).pop(),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
],
),
);
}
Widget _buildDetailSection(
String title, List<Widget> children) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
const SizedBox(height: 12),
...children,
],
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
Widget? valueWidget,
bool copyable = false,
bool navigable = false,
VoidCallback? onNavigate,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 10),
SizedBox(
width: 80,
child: Text(
label,
style: TextStyle(
fontSize: 13, color: colorScheme.onSurfaceVariant),
),
),
const SizedBox(width: 8),
Expanded(
child: valueWidget ??
Row(
children: [
Expanded(
child: Text(
value,
style: const TextStyle(fontSize: 13),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (copyable && value != '-')
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ToastHelper.success('已复制到剪贴板');
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(LucideIcons.copy,
size: 14, color: colorScheme.primary),
),
),
if (navigable && value != '-') ...[
const SizedBox(width: 4),
InkWell(
onTap: onNavigate,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(LucideIcons.externalLink,
size: 14, color: colorScheme.primary),
),
),
],
],
),
),
],
),
);
}
void _navigateToFolder(RemoteDownloadTaskModel task) {
final dst = task.summary?.dst ?? '';
if (dst.isEmpty) return;
String relativePath;
if (dst.startsWith('cloudreve://my')) {
relativePath = dst.replaceFirst('cloudreve://my', '');
if (relativePath.isEmpty) relativePath = '/';
} else {
relativePath = dst;
}
try {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final navProvider = Provider.of<NavigationProvider>(
context,
listen: false,
);
// 计算父目录路径用于导航,高亮该目录本身
final parentPath = _getParentPath(relativePath);
fileManager.navigateAndHighlight(parentPath, dst);
navProvider.setIndex(1);
Navigator.of(context).popUntil((route) =>
route.settings.name == '/home' || route.isFirst);
} catch (e) {
ToastHelper.info('目标路径: $relativePath');
}
}
String _getParentPath(String path) {
if (path.isEmpty || path == '/') return '/';
final parts = path.split('/')..removeLast();
final result = parts.join('/');
return result.isEmpty ? '/' : result;
}
// ─── 对话框 ───
/// 创建任务对话框
Future<void> _showCreateDialog(BuildContext context) async {
final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint;
String selectedDst = '/';
bool folderSelected = false;
final srcController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
insetPadding: EdgeInsets.symmetric(
horizontal: isDesktop ? 40 : MediaQuery.of(ctx).size.width * 0.05,
),
title: const Text('新建离线下载'),
content: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isDesktop ? 500 : MediaQuery.of(ctx).size.width * 0.9,
maxHeight: MediaQuery.of(ctx).size.height * 0.75,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 上方:目录选择区域
Align(
alignment: Alignment.centerLeft,
child: Text('选择保存目录',
style: TextStyle(
fontSize: 13,
color: Theme.of(ctx).colorScheme.onSurfaceVariant)),
),
const SizedBox(height: 8),
if (folderSelected) ...[
// 已选择:显示路径 + 修改按钮
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(ctx).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(LucideIcons.folder, size: 18, color: Theme.of(ctx).colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
selectedDst == '/' ? '/ (根目录)' : selectedDst,
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: () => setDialogState(() => folderSelected = false),
child: const Text('修改'),
),
],
),
),
] else ...[
// 未选择:显示目录选择器
Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(ctx).size.height * 0.45,
),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(ctx).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: FolderPicker(
currentPath: selectedDst,
maxVisibleItems: isDesktop ? null : 3,
onFolderSelected: (path) {
setDialogState(() {
selectedDst = path;
folderSelected = true;
});
},
),
),
],
const SizedBox(height: 16),
// 下方:下载链接
TextField(
controller: srcController,
decoration: const InputDecoration(
labelText: '下载链接',
hintText: '输入 URL 或磁力链接,每行一个',
prefixIcon: Icon(LucideIcons.link),
alignLabelWithHint: true,
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('创建'),
),
],
),
),
);
if (!mounted || confirmed != true) return;
final srcText = srcController.text.trim();
if (srcText.isEmpty) {
ToastHelper.error('请输入下载链接');
return;
}
final urls = srcText
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
if (urls.isEmpty) {
ToastHelper.error('请输入至少一个下载链接');
return;
}
final dst = _toCloudreveUri(selectedDst);
try {
final tasks = await _service.createDownload(dst: dst, src: urls);
if (!mounted) return;
setState(() {
_downloadingTasks = [...tasks, ..._downloadingTasks];
});
_tabController.animateTo(0);
_startOrStopPolling();
ToastHelper.success('已创建 ${tasks.length} 个任务');
} catch (e) {
if (!mounted) return;
AppLogger.e('创建失败: $e');
ToastHelper.failure('创建失败: $e');
}
}
/// 将相对路径转为 cloudreve URI 格式
String _toCloudreveUri(String path) {
if (path.startsWith('cloudreve://')) return path;
if (path == '/' || path.isEmpty) return 'cloudreve://my';
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return 'cloudreve://my/$cleanPath';
}
/// 文件选择对话框(毛玻璃风格)
Future<void> _showFilesDialog(
BuildContext context, RemoteDownloadTaskModel task) async {
final download = task.summary?.download;
if (download == null || download.files.isEmpty) return;
final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint;
final colorScheme = Theme.of(context).colorScheme;
final selected = <int, bool>{
for (final f in download.files) f.index: f.selected,
};
await 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: (dialogContext, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final dialogWidth = isDesktop ? 480.0 : screenWidth - 48.0;
final maxDialogHeight = screenHeight * 0.7;
final minContentHeight = download.files.length * 56.0;
final contentHeight = minContentHeight.clamp(200.0, maxDialogHeight - 120);
return Center(
child: Container(
width: dialogWidth,
constraints: BoxConstraints(
maxHeight: maxDialogHeight,
minHeight: 280,
),
child: StatefulBuilder(
builder: (ctx, setDialogState) {
final theme = Theme.of(ctx);
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: theme.brightness == Brightness.light
? Colors.white.withValues(alpha: 0.7)
: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.brightness == Brightness.light
? Colors.white.withValues(alpha: 0.3)
: Colors.white.withValues(alpha: 0.1),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(LucideIcons.layoutList, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
download.name.isNotEmpty ? download.name : '文件列表',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(ctx).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
const Divider(height: 1),
// File list - content fills available space
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: contentHeight,
minHeight: 160,
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: download.files.length,
itemBuilder: (context, index) {
final file = download.files[index];
return CheckboxListTile(
value: selected[file.index] ?? file.selected,
title: Text(file.name,
style: const TextStyle(fontSize: 13),
maxLines: 2,
overflow: TextOverflow.ellipsis),
subtitle: Text(_formatSize(file.size),
style: TextStyle(
fontSize: 11,
color: colorScheme.onSurfaceVariant)),
controlAffinity: ListTileControlAffinity.leading,
dense: true,
onChanged: (v) =>
setDialogState(() => selected[file.index] = v ?? true),
);
},
),
),
const Divider(height: 1),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('关闭'),
),
if (task.status.isOngoing) ...[
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
final changes = <Map<String, dynamic>>[];
for (final file in download.files) {
final wasSelected = file.selected;
final nowSelected = selected[file.index] ?? wasSelected;
if (wasSelected != nowSelected) {
changes.add({'index': file.index, 'download': nowSelected});
}
}
if (changes.isEmpty) {
Navigator.of(ctx).pop();
return;
}
try {
await _service.selectFiles(taskId: task.id, files: changes);
if (!mounted || !ctx.mounted) return;
Navigator.of(ctx).pop();
ToastHelper.success('文件选择已更新');
_loadTasks();
} catch (e) {
if (!mounted) return;
ToastHelper.failure('更新失败: $e');
}
},
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('保存'),
),
],
],
),
),
],
),
),
),
),
),
);
},
),
),
);
},
);
}
/// 取消任务
Future<void> _cancelTask(RemoteDownloadTaskModel task) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('取消任务'),
content: Text('确定要取消离线下载任务"${task.displayName}"吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('')),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style:
FilledButton.styleFrom(backgroundColor: colorScheme.error),
child: const Text('取消任务'),
),
],
),
);
if (!mounted || confirmed != true) return;
try {
await _service.cancelTask(taskId: task.id);
if (!mounted) return;
ToastHelper.success('任务已取消');
_loadTasks();
} catch (e) {
if (!mounted) return;
ToastHelper.failure('取消失败: $e');
}
}
String _formatSize(int bytes) {
if (bytes <= 0) {
return '0 B';
}
if (bytes < 1024) {
return '$bytes B';
}
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inSeconds < 60) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
if (diff.inHours < 24) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${time.month}/${time.day} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
}
String _formatDateTime(DateTime time) {
return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
}