import 'dart:async'; import 'dart:io'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:window_manager/window_manager.dart'; import '../app/app_theme.dart'; import '../core/storage/storage_manager.dart'; import '../models/cloud_file.dart'; import '../models/media_library.dart'; import '../providers/auth_provider.dart'; import '../providers/file_provider.dart'; import '../providers/media_library_provider.dart'; import '../widgets/breadcrumb_bar.dart'; import '../widgets/file_list_tile.dart'; import '../widgets/file_icon.dart'; import '../widgets/side_panel.dart'; import '../widgets/sort_menu.dart'; import 'media_library_page.dart'; import 'search_results_page.dart'; import 'settings_page.dart'; import 'workspace_tools_page.dart'; enum WorkspaceMode { cloud, media } enum _PaneLayoutMode { single, dual } enum _FileViewMode { list, columns } enum _PaneIdentity { primary, secondary } class _DraggedCloudFiles { final List files; final _PaneIdentity source; const _DraggedCloudFiles(this.files, this.source); } class WorkspacePage extends ConsumerStatefulWidget { const WorkspacePage({super.key}); @override ConsumerState createState() => _WorkspacePageState(); } class _WorkspacePageState extends ConsumerState { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(); WorkspaceMode _mode = WorkspaceMode.cloud; bool _isSidePanelOpen = false; bool _searchOpen = false; String? _fileSearchQuery; String? _mediaSearchQuery; WorkspaceTool? _activeTool; @override void dispose() { _searchController.dispose(); _searchFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final fp = ref.watch(fileProvider); return Scaffold( backgroundColor: Colors.transparent, body: OS26Surface( child: SafeArea( child: Padding( padding: const EdgeInsets.all(18), child: Row( children: [ _mode == WorkspaceMode.cloud ? _CloudSidebar( state: fp, onSection: (section) => ref.read(fileProvider.notifier).setSection(section), onSettings: () => _showSettings(context), onSignOut: () => ref.read(authProvider.notifier).signOut(), onTool: (tool) => setState(() => _activeTool = tool), ) : _MediaSidebar( onCreate: () => MediaLibraryPage.showCreateDialog(context, ref), onTool: (tool) => setState(() => _activeTool = tool), ), const SizedBox(width: 16), Expanded( child: Column( children: [ _TopBar( mode: _mode, onModeChanged: (mode) { setState(() => _mode = mode); if (mode == WorkspaceMode.media) { ref.read(mediaLibraryProvider.notifier).api = ref .read(authProvider.notifier) .api; ref.read(mediaLibraryProvider.notifier).load(); } }, searchController: _searchController, searchFocusNode: _searchFocusNode, searchOpen: _searchOpen, onSearch: (value) { final query = value.trim(); if (query.isEmpty) return; if (_mode == WorkspaceMode.media) { setState(() => _mediaSearchQuery = query); ref .read(mediaLibraryProvider.notifier) .setSearchQuery(query); } else { setState(() => _fileSearchQuery = query); } }, onToggleSearch: () { setState(() => _searchOpen = !_searchOpen); if (!_searchOpen) { _searchController.clear(); } else { WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); }); } }, onSettings: () => _showSettings(context), ), const SizedBox(height: 14), Expanded( child: _activeTool != null ? OS26Glass( radius: 18, opacity: 0.42, padding: EdgeInsets.zero, child: WorkspaceToolsPage( tool: _activeTool!, onClose: () => setState(() => _activeTool = null), ), ) : _fileSearchQuery != null ? OS26Glass( radius: 18, opacity: 0.42, padding: EdgeInsets.zero, child: FileSearchResultsPage( query: _fileSearchQuery!, onClose: () => setState(() { _fileSearchQuery = null; _searchController.clear(); }), ), ) : _mediaSearchQuery != null ? OS26Glass( radius: 18, opacity: 0.42, padding: EdgeInsets.zero, child: MediaSearchResultsPage( query: _mediaSearchQuery!, onClose: () => setState(() { _mediaSearchQuery = null; _searchController.clear(); ref .read(mediaLibraryProvider.notifier) .setSearchQuery(''); }), ), ) : _mode == WorkspaceMode.cloud ? _CloudWorkspace( state: fp, sidePanelOpen: _isSidePanelOpen, onToggleSidePanel: () => setState( () => _isSidePanelOpen = !_isSidePanelOpen, ), ) : OS26Glass( radius: 18, opacity: 0.42, padding: EdgeInsets.zero, child: MediaLibraryPage( showLibrarySidebar: false, ), ), ), ], ), ), ], ), ), ), ), ); } void _showSettings(BuildContext context) { showDialog(context: context, builder: (_) => const SettingsDialog()); } } class _TopBar extends StatelessWidget { final WorkspaceMode mode; final ValueChanged onModeChanged; final TextEditingController searchController; final FocusNode searchFocusNode; final bool searchOpen; final ValueChanged onSearch; final VoidCallback onToggleSearch; final VoidCallback onSettings; const _TopBar({ required this.mode, required this.onModeChanged, required this.searchController, required this.searchFocusNode, required this.searchOpen, required this.onSearch, required this.onToggleSearch, required this.onSettings, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return SizedBox( height: 42, child: Row( children: [ const SizedBox(width: 78), const Expanded(child: DragToMoveArea(child: SizedBox.expand())), OS26Glass( radius: 13, opacity: 0.42, padding: const EdgeInsets.all(3), child: Row( mainAxisSize: MainAxisSize.min, children: [ _SegmentButton( icon: Icons.folder_rounded, label: '光鸭云盘', selected: mode == WorkspaceMode.cloud, onTap: () => onModeChanged(WorkspaceMode.cloud), ), _SegmentButton( icon: Icons.movie_rounded, label: '光鸭影视', selected: mode == WorkspaceMode.media, onTap: () => onModeChanged(WorkspaceMode.media), ), ShadTooltip( builder: (_) => const Text('设置'), child: SizedBox( width: 34, height: 32, child: GestureDetector( onTap: onSettings, child: Icon( Icons.settings_rounded, size: 17, color: cs.mutedForeground, ), ), ), ), ], ), ), const Expanded(child: DragToMoveArea(child: SizedBox.expand())), AnimatedContainer( duration: const Duration(milliseconds: 160), width: searchOpen ? 280 : 42, height: 42, child: OS26Glass( radius: 21, opacity: 0.52, padding: searchOpen ? const EdgeInsets.symmetric(horizontal: 12) : EdgeInsets.zero, child: searchOpen ? Row( children: [ Icon( Icons.search_rounded, size: 18, color: cs.foreground, ), const SizedBox(width: 8), Expanded( child: TextField( controller: searchController, focusNode: searchFocusNode, decoration: InputDecoration( border: InputBorder.none, isDense: true, hintText: mode == WorkspaceMode.cloud ? '搜索文件' : '搜索影视资源', ), textInputAction: TextInputAction.search, onSubmitted: onSearch, ), ), InkWell( borderRadius: BorderRadius.circular(12), onTap: onToggleSearch, child: Icon( Icons.close_rounded, size: 18, color: cs.mutedForeground, ), ), ], ) : InkWell( borderRadius: BorderRadius.circular(21), onTap: onToggleSearch, child: Icon(Icons.search_rounded, color: cs.foreground), ), ), ), ], ), ); } } void _showRenameDialog(BuildContext context, WidgetRef ref, CloudFile file) { final controller = TextEditingController(text: file.name); showShadDialog( context: context, builder: (dialogContext) => ShadDialog( title: const Text('重命名'), description: Text(file.name), actions: [ ShadButton.outline( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('取消'), ), ShadButton( onPressed: () { final name = controller.text.trim(); if (name.isEmpty || name == file.name) return; ref.read(fileProvider.notifier).renameFile(file, name); Navigator.of(dialogContext).pop(); }, child: const Text('保存'), ), ], child: ShadInput( controller: controller, autofocus: true, placeholder: const Text('文件名称'), ), ), ); } class _SegmentButton extends StatelessWidget { final IconData icon; final String label; final bool selected; final VoidCallback onTap; const _SegmentButton({ required this.icon, required this.label, required this.selected, required this.onTap, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return InkWell( borderRadius: BorderRadius.circular(10), onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 160), width: 126, height: 32, decoration: BoxDecoration( color: selected ? Colors.white.withValues(alpha: 0.74) : Colors.transparent, borderRadius: BorderRadius.circular(10), boxShadow: selected ? [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 10, offset: const Offset(0, 3), ), ] : null, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, size: 15, color: selected ? cs.foreground : cs.mutedForeground, ), const SizedBox(width: 7), Text( label, style: TextStyle( fontSize: 12, fontWeight: selected ? FontWeight.w700 : FontWeight.w600, color: selected ? cs.foreground : cs.mutedForeground, ), ), ], ), ), ); } } class _CloudSidebar extends StatelessWidget { final FileState state; final ValueChanged onSection; final VoidCallback onSettings; final VoidCallback onSignOut; final ValueChanged onTool; const _CloudSidebar({ required this.state, required this.onSection, required this.onSettings, required this.onSignOut, required this.onTool, }); @override Widget build(BuildContext context) { final sections = WorkspaceSection.values .where((section) => section != WorkspaceSection.mediaLibrary) .toList(); return SizedBox( width: 250, child: OS26Glass( radius: 24, opacity: 0.56, padding: const EdgeInsets.fromLTRB(14, 18, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SidebarBrand( icon: Icons.cloud_sync_rounded, title: '光鸭云盘', subtitle: 'Cloud Workspace', ), const SizedBox(height: 18), Expanded( child: ListView( children: [ for (final section in sections) _SidebarTile( icon: _sectionIcon(section), label: section.label, selected: state.section == section, onTap: () => onSection(section), ), const Divider(height: 24), _SidebarTile( icon: Icons.manage_search_rounded, label: '文件扫描与清理', selected: false, onTap: () => onTool(WorkspaceTool.scan), ), _SidebarTile( icon: Icons.text_fields_rounded, label: '批量重命名', selected: false, onTap: () => onTool(WorkspaceTool.rename), ), _SidebarTile( icon: Icons.bolt_rounded, label: '秒传工具', selected: false, onTap: () => onTool(WorkspaceTool.fastTransfer), ), ], ), ), _SidebarTile( icon: Icons.settings_rounded, label: '工作区设置', selected: false, onTap: onSettings, ), _SidebarTile( icon: Icons.logout_rounded, label: '退出登录', selected: false, onTap: onSignOut, ), ], ), ), ); } IconData _sectionIcon(WorkspaceSection s) { switch (s) { case WorkspaceSection.files: return Icons.folder_rounded; case WorkspaceSection.recentViewed: return Icons.access_time_rounded; case WorkspaceSection.recentRestored: return Icons.history_rounded; case WorkspaceSection.photos: return Icons.image_rounded; case WorkspaceSection.videos: return Icons.smart_display_rounded; case WorkspaceSection.audio: return Icons.music_note_rounded; case WorkspaceSection.documents: return Icons.description_rounded; case WorkspaceSection.cloud: return Icons.cloud_download_rounded; case WorkspaceSection.shares: return Icons.ios_share_rounded; case WorkspaceSection.recycle: return Icons.delete_outline_rounded; case WorkspaceSection.mediaLibrary: return Icons.movie_filter_rounded; } } } class _MediaSidebar extends ConsumerWidget { final VoidCallback onCreate; final ValueChanged onTool; const _MediaSidebar({required this.onCreate, required this.onTool}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(mediaLibraryProvider); final notifier = ref.read(mediaLibraryProvider.notifier); return SizedBox( width: 250, child: OS26Glass( radius: 24, opacity: 0.56, padding: const EdgeInsets.fromLTRB(14, 18, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SidebarBrand( icon: Icons.play_circle_fill_rounded, title: '光鸭影视', subtitle: 'Media Center', ), const SizedBox(height: 18), _SidebarTile( icon: Icons.home_rounded, label: '首页', selected: true, onTap: () => onTool(WorkspaceTool.tmdb), ), _SidebarTile( icon: Icons.movie_creation_rounded, label: '电影', count: state.statistics.movies, selected: false, onTap: () => onTool(WorkspaceTool.tmdb), ), _SidebarTile( icon: Icons.live_tv_rounded, label: '电视剧', count: state.statistics.series, selected: false, onTap: () {}, ), _SidebarTile( icon: Icons.help_outline_rounded, label: '未识别', count: state.statistics.unmatched, selected: false, onTap: () {}, ), const Padding( padding: EdgeInsets.fromLTRB(8, 18, 8, 8), child: Text( '媒体库', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), Expanded( child: ListView( children: [ for (final library in state.libraries) _SidebarTile( icon: library.kind == MediaLibraryKind.series ? Icons.live_tv_rounded : Icons.smart_display_rounded, label: library.name, selected: state.selectedLibrary?.id == library.id, onTap: () => notifier.selectLibrary(library.id), ), ], ), ), _SidebarTile( icon: Icons.add_rounded, label: '新建媒体库', selected: false, onTap: onCreate, ), _SidebarTile( icon: Icons.auto_fix_high_rounded, label: 'TMDB 整理', selected: false, onTap: () => onTool(WorkspaceTool.tmdb), ), _SidebarTile( icon: Icons.category_rounded, label: '分类管理', selected: false, onTap: () => onTool(WorkspaceTool.categories), ), ], ), ), ); } } class _SidebarBrand extends StatelessWidget { final IconData icon; final String title; final String subtitle; const _SidebarBrand({ required this.icon, required this.title, required this.subtitle, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return Row( children: [ Container( width: 46, height: 46, decoration: BoxDecoration( color: cs.primary, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: cs.primary.withValues(alpha: 0.24), blurRadius: 16, offset: const Offset(0, 8), ), ], ), child: Icon(icon, color: Colors.white, size: 26), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w800, color: cs.foreground, ), ), Text( subtitle, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1.6, color: cs.mutedForeground, ), ), ], ), ), ], ); } } class _SidebarTile extends StatelessWidget { final IconData icon; final String label; final int? count; final bool selected; final VoidCallback onTap; const _SidebarTile({ required this.icon, required this.label, this.count, required this.selected, required this.onTap, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 6), child: InkWell( borderRadius: BorderRadius.circular(10), onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 140), height: 42, padding: const EdgeInsets.symmetric(horizontal: 9), decoration: BoxDecoration( color: selected ? const Color(0xFFFFB18A).withValues(alpha: 0.72) : Colors.transparent, borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: selected ? Colors.white.withValues(alpha: 0.36) : const Color(0xFFFFE4D2).withValues(alpha: 0.82), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, size: 17, color: cs.primary), ), const SizedBox(width: 10), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, color: selected ? cs.foreground : cs.mutedForeground, ), ), ), if (count != null) Text( count.toString(), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: cs.mutedForeground, ), ), ], ), ), ), ); } } class _CloudWorkspace extends ConsumerStatefulWidget { final FileState state; final bool sidePanelOpen; final VoidCallback onToggleSidePanel; const _CloudWorkspace({ required this.state, required this.sidePanelOpen, required this.onToggleSidePanel, }); @override ConsumerState<_CloudWorkspace> createState() => _CloudWorkspaceState(); } class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { _PaneLayoutMode _paneMode = _PaneLayoutMode.dual; _FileViewMode _primaryViewMode = _FileViewMode.list; @override Widget build(BuildContext context) { final state = widget.state; return Row( children: [ Expanded( child: OS26Glass( radius: 18, opacity: 0.42, padding: const EdgeInsets.all(14), child: Column( children: [ _CloudToolbar( state: state, paneMode: _paneMode, onPaneModeChanged: (mode) => setState(() => _paneMode = mode), sidePanelOpen: widget.sidePanelOpen, onToggleSidePanel: widget.onToggleSidePanel, ), const SizedBox(height: 12), Expanded( child: state.section == WorkspaceSection.files ? _paneMode == _PaneLayoutMode.dual ? Row( children: [ Expanded( child: _PrimaryFilePane( title: '左侧面板', state: state, viewMode: _primaryViewMode, onViewModeChanged: (mode) => setState( () => _primaryViewMode = mode, ), ), ), const SizedBox(width: 10), const Expanded(child: _SecondaryFilePane()), ], ) : _PrimaryFilePane( title: '文件列表', state: state, viewMode: _primaryViewMode, onViewModeChanged: (mode) => setState(() => _primaryViewMode = mode), ) : _PrimaryFilePane( title: state.section.label, state: state, viewMode: _primaryViewMode, onViewModeChanged: (mode) => setState(() => _primaryViewMode = mode), ), ), ], ), ), ), if (widget.sidePanelOpen) ...[ const SizedBox(width: 12), SizedBox( width: 280, child: OS26Glass( radius: 18, opacity: 0.48, padding: EdgeInsets.zero, child: const SidePanel(), ), ), ], ], ); } } class _CloudToolbar extends ConsumerWidget { final FileState state; final _PaneLayoutMode paneMode; final ValueChanged<_PaneLayoutMode> onPaneModeChanged; final bool sidePanelOpen; final VoidCallback onToggleSidePanel; const _CloudToolbar({ required this.state, required this.paneMode, required this.onPaneModeChanged, required this.sidePanelOpen, required this.onToggleSidePanel, }); @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(fileProvider.notifier); return Row( children: [ const Spacer(), SortMenu( currentSort: state.serverSort, currentDirection: state.serverSortDirection, onSortChanged: notifier.setSort, ), const SizedBox(width: 8), _ToolbarSegment(value: paneMode, onChanged: onPaneModeChanged), const SizedBox(width: 8), _ToolbarButton( icon: Icons.upload_rounded, label: '上传', primary: true, onTap: () => _pickAndUpload(ref), ), const SizedBox(width: 6), _ToolbarButton( icon: Icons.create_new_folder_rounded, label: '新建文件夹', onTap: () => _showCreateFolderDialog(context, ref), ), _ToolbarButton( icon: Icons.refresh_rounded, label: '刷新', onTap: () => notifier.loadFiles(), ), _ToolbarButton( icon: Icons.more_horiz_rounded, label: sidePanelOpen ? '隐藏详情' : '显示详情', onTap: onToggleSidePanel, ), ], ); } Future _pickAndUpload(WidgetRef ref) async { final result = await FilePicker.pickFiles(); if (result == null) return; final files = result.paths.whereType().map(File.new).toList(); await ref.read(fileProvider.notifier).uploadLocalFiles(files); } void _showCreateFolderDialog(BuildContext context, WidgetRef ref) { final controller = TextEditingController(); showShadDialog( context: context, builder: (ctx) => ShadDialog( title: const Text('新建文件夹'), actions: [ ShadButton.outline( child: const Text('取消'), onPressed: () => Navigator.of(ctx).pop(), ), ShadButton( child: const Text('创建'), onPressed: () { final name = controller.text.trim(); if (name.isNotEmpty) { ref.read(fileProvider.notifier).createFolder(name); Navigator.of(ctx).pop(); } }, ), ], child: Padding( padding: const EdgeInsets.only(top: 12), child: ShadInput( controller: controller, placeholder: const Text('文件夹名称'), autofocus: true, ), ), ), ); } } class _ToolbarSegment extends StatelessWidget { final _PaneLayoutMode value; final ValueChanged<_PaneLayoutMode> onChanged; const _ToolbarSegment({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final isDual = value == _PaneLayoutMode.dual; return _ToolbarButton( icon: isDual ? Icons.view_agenda_rounded : Icons.view_column_rounded, label: isDual ? '切换单面板' : '切换双面板', onTap: () => onChanged(isDual ? _PaneLayoutMode.single : _PaneLayoutMode.dual), ); } } class _ToolbarButton extends StatelessWidget { final IconData icon; final String label; final VoidCallback onTap; final bool primary; const _ToolbarButton({ required this.icon, required this.label, required this.onTap, this.primary = false, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return ShadTooltip( builder: (_) => Text(label), child: Padding( padding: const EdgeInsets.only(left: 6), child: InkWell( borderRadius: BorderRadius.circular(10), onTap: onTap, child: Container( constraints: BoxConstraints(minWidth: primary ? 72 : 38), height: 32, padding: EdgeInsets.symmetric(horizontal: primary ? 10 : 0), decoration: BoxDecoration( color: primary ? cs.primary : Colors.white.withValues(alpha: 0.46), borderRadius: BorderRadius.circular(10), border: Border.all( color: primary ? cs.primary : Colors.white.withValues(alpha: 0.54), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, size: 18, color: primary ? cs.primaryForeground : cs.mutedForeground, ), if (primary) ...[ const SizedBox(width: 6), Text( label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: cs.primaryForeground, ), ), ], ], ), ), ), ), ); } } class _PrimaryFilePane extends ConsumerWidget { final String title; final FileState state; final _FileViewMode viewMode; final ValueChanged<_FileViewMode> onViewModeChanged; const _PrimaryFilePane({ required this.title, required this.state, required this.viewMode, required this.onViewModeChanged, }); @override Widget build(BuildContext context, WidgetRef ref) { final notifier = ref.read(fileProvider.notifier); final files = state.files; return _FilePaneFrame( title: title, itemCount: files.length, isLoading: state.isLoading, errorMessage: state.errorMessage, emptyLabel: '没有文件', breadcrumbPath: state.folderPath, onBreadcrumbNavigate: (index) => ref.read(fileProvider.notifier).navigateToPathIndex(index), header: const _FilePaneHeader(), dropParentID: state.folderPath.isEmpty ? null : state.folderPath.last.id, onMoveCloudFiles: (files, parentID) => notifier.moveFilesTo(files, parentID: parentID), onUploadLocalFiles: (files, parentID) => notifier.uploadLocalFiles(files, parentID: parentID), currentPage: state.currentPage, pageSize: state.pageSize, totalPages: state.totalPages, fileCount: files.where((file) => !file.isDirectory).length, folderCount: files.where((file) => file.isDirectory).length, onPreviousPage: state.currentPage == 0 ? null : notifier.prevPage, onNextPage: state.currentPage >= state.totalPages - 1 ? null : notifier.nextPage, onPageSizeChanged: notifier.setPageSize, trailing: _PaneViewToggle(value: viewMode, onChanged: onViewModeChanged), child: RefreshIndicator( onRefresh: () => notifier.loadFiles(), child: _FilePaneCollection( viewMode: viewMode, itemCount: files.length, itemBuilder: (context, index) { final file = files[index]; final selected = state.selectedIDs.contains(file.id); final tile = FileListTile( file: file, isSelected: selected, onSelect: () { if (file.isDirectory && !selected) { notifier.navigateToFolder(file); } else { notifier.toggleSelection(file.id); } }, onOpen: file.isDirectory ? () => notifier.navigateToFolder(file) : () => notifier.downloadFile(file), onRename: () => _showRenameDialog(context, ref, file), onCopy: () => notifier.copyToClipboard([file]), onCut: () => notifier.cutToClipboard([file]), onDownload: () => notifier.downloadFile(file), onShare: () => notifier.createShare(file), isRecycleItem: state.section == WorkspaceSection.recycle, onDelete: () => state.section == WorkspaceSection.recycle ? notifier.restoreFiles([file]) : notifier.deleteFiles([file]), ); final item = Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.primary), feedback: _DragFeedback(label: file.name), childWhenDragging: Opacity(opacity: 0.35, child: tile), child: tile, ); if (viewMode == _FileViewMode.list) return item; return Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.primary), feedback: _DragFeedback(label: file.name), childWhenDragging: Opacity( opacity: 0.35, child: _FileGridCard( file: file, isSelected: selected, onSelect: () { if (file.isDirectory && !selected) { notifier.navigateToFolder(file); } else { notifier.toggleSelection(file.id); } }, onOpen: file.isDirectory ? () => notifier.navigateToFolder(file) : () => notifier.downloadFile(file), ), ), child: _FileGridCard( file: file, isSelected: selected, onSelect: () { if (file.isDirectory && !selected) { notifier.navigateToFolder(file); } else { notifier.toggleSelection(file.id); } }, onOpen: file.isDirectory ? () => notifier.navigateToFolder(file) : () => notifier.downloadFile(file), ), ); }, ), ), ); } } class _PaneViewToggle extends StatelessWidget { final _FileViewMode value; final ValueChanged<_FileViewMode> onChanged; const _PaneViewToggle({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final columns = value == _FileViewMode.columns; return _PaneIconButton( icon: columns ? Icons.view_list_rounded : Icons.grid_view_rounded, tooltip: columns ? '切换列表模式' : '切换分栏模式', onTap: () => onChanged(columns ? _FileViewMode.list : _FileViewMode.columns), ); } } class _FilePaneCollection extends StatelessWidget { final _FileViewMode viewMode; final int itemCount; final IndexedWidgetBuilder itemBuilder; const _FilePaneCollection({ required this.viewMode, required this.itemCount, required this.itemBuilder, }); @override Widget build(BuildContext context) { if (viewMode == _FileViewMode.list) { return ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.zero, itemCount: itemCount, itemBuilder: itemBuilder, ); } return GridView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(10), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 168, mainAxisExtent: 132, mainAxisSpacing: 8, crossAxisSpacing: 8, ), itemCount: itemCount, itemBuilder: itemBuilder, ); } } class _FileGridCard extends StatelessWidget { final CloudFile file; final bool isSelected; final VoidCallback onSelect; final VoidCallback onOpen; const _FileGridCard({ required this.file, required this.onSelect, required this.onOpen, this.isSelected = false, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return Semantics( button: true, label: '${file.name},${file.isDirectory ? '文件夹' : file.typeName},${file.formattedSize}', child: GestureDetector( onTap: onSelect, onDoubleTap: onOpen, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: isSelected ? cs.primary.withValues(alpha: 0.12) : Colors.white.withValues(alpha: 0.34), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? cs.primary.withValues(alpha: 0.65) : cs.border.withValues(alpha: 0.58), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 38, height: 38, child: FileIcon(file: file)), const Spacer(), Text( file.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: cs.foreground, ), ), const SizedBox(height: 3), Text( file.isDirectory && file.subFileCount != null ? '${file.formattedSize} · ${file.subFileCount} 项' : file.formattedSize, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), ], ), ), ), ); } } class _SecondaryFilePane extends ConsumerStatefulWidget { const _SecondaryFilePane(); @override ConsumerState<_SecondaryFilePane> createState() => _SecondaryFilePaneState(); } class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { final _path = []; var _files = []; var _loading = false; String? _error; var _page = 0; var _pageSize = 50; var _totalPages = 1; var _viewMode = _FileViewMode.list; var _detailGeneration = 0; @override void initState() { super.initState(); Future.microtask(_load); } Future _load({String? parentID}) async { final generation = ++_detailGeneration; setState(() { _loading = true; _error = null; }); try { final api = ref.read(authProvider.notifier).api; final result = await api.fsFiles( parentID: parentID, page: _page, pageSize: _pageSize, ); final files = _extractFiles(result); setState(() { _files = files; _totalPages = _extractTotalPages(result, files.length); }); unawaited(_enrichFolderSizes(files, generation)); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _loading = false); } } Future _enrichFolderSizes(List files, int generation) async { final cache = _readFolderSizeCache(); final now = DateTime.now().millisecondsSinceEpoch; final ttl = Duration( minutes: (int.tryParse( StorageManager.get( StorageKeys.fileCacheTTLMinutes, ) ?? '3', ) ?? 3) .clamp(1, 60), ).inMilliseconds; final queue = []; final cached = {}; for (final file in files.where((file) => file.isDirectory)) { final entry = cache[file.id]; final cachedAt = int.tryParse(entry?['cachedAt']?.toString() ?? ''); final size = int.tryParse(entry?['size']?.toString() ?? ''); if (cachedAt != null && size != null && size > 0 && now - cachedAt <= ttl) { cached[file.id] = size; } else { queue.add(file); } } void apply(Map sizes) { if (!mounted || generation != _detailGeneration || sizes.isEmpty) return; setState(() { _files = _files .map( (file) => sizes.containsKey(file.id) ? file.copyWith(size: sizes[file.id]) : file, ) .toList(); }); } apply(cached); if (queue.isEmpty) return; final api = ref.read(authProvider.notifier).api; final resolved = {}; await Future.wait( List.generate(6, (_) async { while (queue.isNotEmpty) { final folder = queue.removeLast(); try { final detail = await api.fsDetail(folder.id); final size = _findIntDeep(detail, const [ 'size', 'fileSize', 'resSize', 'totalSize', 'dirSize', 'folderSize', ]); if (size == null) continue; cache[folder.id] = {'size': size, 'cachedAt': now}; resolved[folder.id] = size; apply({folder.id: size}); } catch (_) { // Continue enriching the visible folders after an individual failure. } } }), ); if (resolved.isNotEmpty) { await StorageManager.set(StorageKeys.fileMetadataCache, cache); } } Map> _readFolderSizeCache() { final raw = StorageManager.get(StorageKeys.fileMetadataCache); if (raw is! Map) return >{}; return raw.map( (key, value) => MapEntry( key.toString(), value is Map ? Map.from(value) : {}, ), ); } int? _findIntDeep(Map value, List keys) { for (final entry in value.entries) { if (keys.contains(entry.key)) { final parsed = int.tryParse(entry.value?.toString() ?? ''); if (parsed != null) return parsed; } if (entry.value is Map) { final found = _findIntDeep( Map.from(entry.value), keys, ); if (found != null) return found; } else if (entry.value is List) { for (final child in entry.value as List) { if (child is Map) { final found = _findIntDeep(Map.from(child), keys); if (found != null) return found; } } } } return null; } String? get _currentParentID => _path.isEmpty ? null : _path.last.id; Future _moveCloudFiles(List files, String? parentID) async { if (files.isEmpty) return; setState(() { _loading = true; _error = null; }); try { final api = ref.read(authProvider.notifier).api; await api.fsMove( files.map((file) => file.id).toList(), parentID: parentID, ); await _load(parentID: _currentParentID); await ref.read(fileProvider.notifier).loadFiles(); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _loading = false); } } Future _uploadLocalFiles(List files, String? parentID) async { if (files.isEmpty) return; setState(() { _loading = true; _error = null; }); try { final api = ref.read(authProvider.notifier).api; for (final file in files) { if (await file.exists()) { await api.fileUpload(file, parentID: parentID); } } await _load(parentID: _currentParentID); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return _FilePaneFrame( title: _path.isEmpty ? '右侧面板' : _path.last.name, itemCount: _files.length, isLoading: _loading, errorMessage: _error, emptyLabel: '没有文件', breadcrumbPath: _path, onBreadcrumbNavigate: _navigateBreadcrumb, dropParentID: _currentParentID, onMoveCloudFiles: _moveCloudFiles, onUploadLocalFiles: _uploadLocalFiles, header: Row( children: [ if (_path.isNotEmpty) _ToolbarButton( icon: Icons.arrow_back_rounded, label: '返回', onTap: () { setState(() { _path.removeLast(); _page = 0; }); _load(parentID: _path.isEmpty ? null : _path.last.id); }, ), const Expanded(child: _FilePaneHeader()), ], ), currentPage: _page, pageSize: _pageSize, totalPages: _totalPages, fileCount: _files.where((file) => !file.isDirectory).length, folderCount: _files.where((file) => file.isDirectory).length, onPreviousPage: _page == 0 ? null : () { setState(() => _page -= 1); _load(parentID: _currentParentID); }, onNextPage: _page >= _totalPages - 1 ? null : () { setState(() => _page += 1); _load(parentID: _currentParentID); }, onPageSizeChanged: (size) { setState(() { _pageSize = size; _page = 0; }); _load(parentID: _currentParentID); }, trailing: _PaneViewToggle( value: _viewMode, onChanged: (mode) => setState(() => _viewMode = mode), ), child: RefreshIndicator( onRefresh: () => _load(parentID: _currentParentID), child: _FilePaneCollection( viewMode: _viewMode, itemCount: _files.length, itemBuilder: (context, index) { final file = _files[index]; final row = FileListTile( file: file, onSelect: () { if (file.isDirectory) { setState(() { _path.add(file); _page = 0; }); _load(parentID: file.id); } else { ref.read(fileProvider.notifier).downloadFile(file); } }, onOpen: () { if (file.isDirectory) { setState(() { _path.add(file); _page = 0; }); _load(parentID: file.id); } else { ref.read(fileProvider.notifier).downloadFile(file); } }, onCopy: () => ref.read(fileProvider.notifier).copyToClipboard([file]), onCut: () => ref.read(fileProvider.notifier).cutToClipboard([file]), onRename: () => _showRenameDialog(context, ref, file), onDownload: () => ref.read(fileProvider.notifier).downloadFile(file), onShare: () => ref.read(fileProvider.notifier).createShare(file), onDelete: () => ref.read(fileProvider.notifier).deleteFiles([file]), ); if (_viewMode == _FileViewMode.list) { return Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.secondary), feedback: _DragFeedback(label: file.name), childWhenDragging: Opacity(opacity: 0.35, child: row), child: row, ); } void open() { if (file.isDirectory) { setState(() { _path.add(file); _page = 0; }); _load(parentID: file.id); } else { ref.read(fileProvider.notifier).downloadFile(file); } } final card = _FileGridCard( file: file, onSelect: open, onOpen: open, ); return Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.secondary), feedback: _DragFeedback(label: file.name), childWhenDragging: Opacity(opacity: 0.35, child: card), child: card, ); }, ), ), ); } List _extractFiles(Map json) { final result = []; final seen = {}; void visit(dynamic value) { if (value is Map) { try { final file = CloudFile.fromJson(Map.from(value)); if (seen.add(file.id)) result.add(file); } catch (_) {} for (final child in value.values) { visit(child); } } else if (value is List) { for (final child in value) { visit(child); } } } visit(json); return result; } void _navigateBreadcrumb(int index) { if (index < 0) { setState(() { _path.clear(); _page = 0; }); _load(); return; } if (!_path.asMap().containsKey(index)) return; setState(() { _path.removeRange(index + 1, _path.length); _page = 0; }); _load(parentID: _path.isEmpty ? null : _path.last.id); } int _extractTotalPages(Map json, int itemCount) { int? find(Map value) { for (final key in const [ 'totalPages', 'pages', 'pageCount', 'total', 'totalCount', 'count', ]) { final raw = value[key]; final parsed = raw is int ? raw : int.tryParse(raw?.toString() ?? ''); if (parsed != null && parsed >= 0) return parsed; } for (final child in value.values) { if (child is Map) { final result = find(Map.from(child)); if (result != null) return result; } } return null; } final result = find(json); if (result == null) return itemCount < _pageSize ? 1 : _page + 2; if (result <= 0) return 1; final hasExplicitPageCount = json.containsKey('totalPages') || json.containsKey('pages') || json.containsKey('pageCount'); return hasExplicitPageCount ? result : (result / _pageSize).ceil().clamp(1, 1 << 31).toInt(); } } class _PanePagination extends StatelessWidget { static const _pageSizes = [ 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, ]; final int currentPage; final int pageSize; final int totalPages; final int fileCount; final int folderCount; final VoidCallback? onPreviousPage; final VoidCallback? onNextPage; final ValueChanged? onPageSizeChanged; const _PanePagination({ required this.currentPage, required this.pageSize, required this.totalPages, required this.fileCount, required this.folderCount, this.onPreviousPage, this.onNextPage, this.onPageSizeChanged, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return Container( height: 38, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( border: Border( top: BorderSide(color: cs.border.withValues(alpha: 0.62)), ), ), child: Row( children: [ Icon( Icons.insert_drive_file_rounded, size: 14, color: cs.mutedForeground, ), const SizedBox(width: 5), Text( '本页文件 $fileCount', style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), const SizedBox(width: 12), Icon(Icons.folder_rounded, size: 14, color: cs.mutedForeground), const SizedBox(width: 5), Text( '本页文件夹 $folderCount', style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), const SizedBox(width: 14), Text( '第 ${currentPage + 1} / ${totalPages.clamp(1, 1 << 31)} 页', style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), const Spacer(), ShadSelect( initialValue: pageSize, enabled: onPageSizeChanged != null, minWidth: 80, selectedOptionBuilder: (context, value) => Text('$value / 页'), options: [ for (final size in _pageSizes) ShadOption(value: size, child: Text('$size / 页')), ], onChanged: (value) { if (value != null) onPageSizeChanged?.call(value); }, ), const SizedBox(width: 6), _PaneIconButton( icon: Icons.chevron_left_rounded, tooltip: '上一页', onTap: onPreviousPage, ), const SizedBox(width: 3), _PaneIconButton( icon: Icons.chevron_right_rounded, tooltip: '下一页', onTap: onNextPage, ), ], ), ); } } class _PaneIconButton extends StatelessWidget { final IconData icon; final String tooltip; final VoidCallback? onTap; const _PaneIconButton({ required this.icon, required this.tooltip, required this.onTap, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return ShadTooltip( builder: (_) => Text(tooltip), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), child: SizedBox( width: 26, height: 26, child: Icon( icon, size: 16, color: onTap == null ? cs.mutedForeground.withValues(alpha: 0.45) : cs.foreground, ), ), ), ); } } class _FilePaneFrame extends StatelessWidget { final String title; final Widget? trailing; final int itemCount; final bool isLoading; final String? errorMessage; final String emptyLabel; final String? dropParentID; final List breadcrumbPath; final ValueChanged? onBreadcrumbNavigate; final Future Function(List files, String? parentID)? onMoveCloudFiles; final Future Function(List files, String? parentID)? onUploadLocalFiles; final Widget header; final Widget child; final int currentPage; final int pageSize; final int totalPages; final int fileCount; final int folderCount; final VoidCallback? onPreviousPage; final VoidCallback? onNextPage; final ValueChanged? onPageSizeChanged; const _FilePaneFrame({ required this.title, this.trailing, required this.itemCount, required this.isLoading, required this.errorMessage, required this.emptyLabel, this.dropParentID, this.breadcrumbPath = const [], this.onBreadcrumbNavigate, this.onMoveCloudFiles, this.onUploadLocalFiles, required this.header, required this.child, this.currentPage = 0, this.pageSize = 50, this.totalPages = 1, this.fileCount = 0, this.folderCount = 0, this.onPreviousPage, this.onNextPage, this.onPageSizeChanged, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return _PaneDropSurface( parentID: dropParentID, onMoveCloudFiles: onMoveCloudFiles, onUploadLocalFiles: onUploadLocalFiles, child: Container( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.42), borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.white.withValues(alpha: 0.62)), ), child: Column( children: [ Container( height: 42, padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Text( title, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w800, color: cs.foreground, ), ), const SizedBox(width: 8), Text( '$itemCount 项', style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), const Spacer(), if (trailing case final Widget trailing) trailing, ], ), ), Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), SizedBox( height: 36, child: BreadcrumbBar( path: breadcrumbPath, onNavigate: onBreadcrumbNavigate ?? (_) {}, ), ), Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), SizedBox(height: 34, child: header), Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), Expanded( child: isLoading ? const _ShadLoading() : errorMessage != null ? Center( child: Padding( padding: const EdgeInsets.all(16), child: Text( errorMessage!, textAlign: TextAlign.center, style: TextStyle(color: cs.destructive), ), ), ) : itemCount == 0 ? Center( child: Text( emptyLabel, style: TextStyle(color: cs.mutedForeground), ), ) : child, ), _PanePagination( currentPage: currentPage, pageSize: pageSize, totalPages: totalPages, fileCount: fileCount, folderCount: folderCount, onPreviousPage: onPreviousPage, onNextPage: onNextPage, onPageSizeChanged: onPageSizeChanged, ), ], ), ), ); } } class _ShadLoading extends StatelessWidget { const _ShadLoading(); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return Center( child: OS26Glass( radius: 12, opacity: 0.52, padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), child: Column( mainAxisSize: MainAxisSize.min, children: [ const ShadProgress(), const SizedBox(height: 10), Text( '加载中', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: cs.mutedForeground, ), ), ], ), ), ); } } class _PaneDropSurface extends StatefulWidget { final String? parentID; final Future Function(List files, String? parentID)? onMoveCloudFiles; final Future Function(List files, String? parentID)? onUploadLocalFiles; final Widget child; const _PaneDropSurface({ required this.parentID, required this.onMoveCloudFiles, required this.onUploadLocalFiles, required this.child, }); @override State<_PaneDropSurface> createState() => _PaneDropSurfaceState(); } class _PaneDropSurfaceState extends State<_PaneDropSurface> { var _active = false; @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; return DropTarget( onDragEntered: (_) => setState(() => _active = true), onDragExited: (_) => setState(() => _active = false), onDragDone: (details) async { setState(() => _active = false); final files = details.files .map((file) => file.path) .where((path) => path.isNotEmpty) .map(File.new) .where((file) => file.existsSync()) .toList(); if (files.isNotEmpty) { await widget.onUploadLocalFiles?.call(files, widget.parentID); } }, child: DragTarget<_DraggedCloudFiles>( onWillAcceptWithDetails: (_) => widget.onMoveCloudFiles != null, onAcceptWithDetails: (details) async { setState(() => _active = false); await widget.onMoveCloudFiles?.call( details.data.files, widget.parentID, ); }, onMove: (_) { if (!_active) setState(() => _active = true); }, onLeave: (_) => setState(() => _active = false), builder: (context, candidates, rejected) { final active = _active || candidates.isNotEmpty; return Stack( children: [ widget.child, Positioned.fill( child: IgnorePointer( child: AnimatedOpacity( opacity: active ? 1 : 0, duration: const Duration(milliseconds: 120), child: Container( decoration: BoxDecoration( color: cs.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(14), border: Border.all(color: cs.primary, width: 1.5), ), child: Center( child: OS26Glass( radius: 12, opacity: 0.7, padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 12, ), child: Text( '松开以上传或移动到此面板', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w800, color: cs.primary, ), ), ), ), ), ), ), ), ], ); }, ), ); } } class _DragFeedback extends StatelessWidget { final String label; const _DragFeedback({required this.label}); @override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: OS26Glass( radius: 10, opacity: 0.78, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.insert_drive_file_rounded, size: 16), const SizedBox(width: 8), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 220), child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ], ), ), ); } } class _FilePaneHeader extends StatelessWidget { const _FilePaneHeader(); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final style = TextStyle( fontSize: 11, fontWeight: FontWeight.w800, color: cs.mutedForeground, ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ const SizedBox(width: 44), Expanded(child: Text('名称', style: style)), SizedBox( width: 88, child: Text('大小', textAlign: TextAlign.right, style: style), ), const SizedBox(width: 12), SizedBox( width: 116, child: Text('修改时间', textAlign: TextAlign.right, style: style), ), ], ), ); } }