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 createState() => _RemoteDownloadPageState(); } class _RemoteDownloadPageState extends State with SingleTickerProviderStateMixin { final RemoteDownloadService _service = RemoteDownloadService(); List _downloadingTasks = []; List _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 _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? ?? []) .map((t) => RemoteDownloadTaskModel.fromJson(t as Map)) .toList(); final cdTasks = (cdData['tasks'] as List? ?? []) .map((t) => RemoteDownloadTaskModel.fromJson(t as Map)) .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 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 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 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( 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 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 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( 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 = [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 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( context, listen: false, ); final navProvider = Provider.of( 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 _showCreateDialog(BuildContext context) async { final isDesktop = MediaQuery.of(context).size.width > _desktopBreakpoint; String selectedDst = '/'; bool folderSelected = false; final srcController = TextEditingController(); final confirmed = await showDialog( 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 _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 = { 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 = >[]; 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 _cancelTask(RemoteDownloadTaskModel task) async { final colorScheme = Theme.of(context).colorScheme; final confirmed = await showDialog( 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')}'; } }