1689 lines
62 KiB
Dart
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')}';
|
|
}
|
|
}
|