Files
guangya_flutter/lib/pages/workspace_page.dart
T
2026-07-18 11:32:00 +08:00

1859 lines
58 KiB
Dart

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 '../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/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 _PaneIdentity { primary, secondary }
class _DraggedCloudFiles {
final List<CloudFile> files;
final _PaneIdentity source;
const _DraggedCloudFiles(this.files, this.source);
}
class WorkspacePage extends ConsumerStatefulWidget {
const WorkspacePage({super.key});
@override
ConsumerState<WorkspacePage> createState() => _WorkspacePageState();
}
class _WorkspacePageState extends ConsumerState<WorkspacePage> {
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<WorkspaceMode> onModeChanged;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final bool searchOpen;
final ValueChanged<String> 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<WorkspaceSection> onSection;
final VoidCallback onSettings;
final VoidCallback onSignOut;
final ValueChanged<WorkspaceTool> 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<WorkspaceTool> 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;
@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,
),
),
const SizedBox(width: 10),
const Expanded(child: _SecondaryFilePane()),
],
)
: _PrimaryFilePane(title: '文件列表', state: state)
: _PrimaryFilePane(
title: state.section.label,
state: state,
),
),
],
),
),
),
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<void> _pickAndUpload(WidgetRef ref) async {
final result = await FilePicker.pickFiles();
if (result == null) return;
final files = result.paths.whereType<String>().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;
const _PrimaryFilePane({required this.title, required this.state});
@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,
child: RefreshIndicator(
onRefresh: () => notifier.loadFiles(),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.zero,
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]),
);
return Draggable<_DraggedCloudFiles>(
data: _DraggedCloudFiles([file], _PaneIdentity.primary),
feedback: _DragFeedback(label: file.name),
childWhenDragging: Opacity(opacity: 0.35, child: tile),
child: tile,
);
},
),
),
);
}
}
class _SecondaryFilePane extends ConsumerStatefulWidget {
const _SecondaryFilePane();
@override
ConsumerState<_SecondaryFilePane> createState() => _SecondaryFilePaneState();
}
class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> {
final _path = <CloudFile>[];
var _files = <CloudFile>[];
var _loading = false;
String? _error;
var _page = 0;
var _pageSize = 50;
var _totalPages = 1;
@override
void initState() {
super.initState();
Future.microtask(_load);
}
Future<void> _load({String? parentID}) async {
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);
});
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
String? get _currentParentID => _path.isEmpty ? null : _path.last.id;
Future<void> _moveCloudFiles(List<CloudFile> 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<void> _uploadLocalFiles(List<File> 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);
},
child: RefreshIndicator(
onRefresh: () => _load(parentID: _currentParentID),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.zero,
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]),
);
return Draggable<_DraggedCloudFiles>(
data: _DraggedCloudFiles([file], _PaneIdentity.secondary),
feedback: _DragFeedback(label: file.name),
childWhenDragging: Opacity(opacity: 0.35, child: row),
child: row,
);
},
),
),
);
}
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
final result = <CloudFile>[];
final seen = <String>{};
void visit(dynamic value) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.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<String, dynamic> json, int itemCount) {
int? find(Map<String, dynamic> 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<String, dynamic>.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<int>? 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<int>(
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 int itemCount;
final bool isLoading;
final String? errorMessage;
final String emptyLabel;
final String? dropParentID;
final List<CloudFile> breadcrumbPath;
final ValueChanged<int>? onBreadcrumbNavigate;
final Future<void> Function(List<CloudFile> files, String? parentID)?
onMoveCloudFiles;
final Future<void> Function(List<File> 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<int>? onPageSizeChanged;
const _FilePaneFrame({
required this.title,
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),
),
],
),
),
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<void> Function(List<CloudFile> files, String? parentID)?
onMoveCloudFiles;
final Future<void> Function(List<File> 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),
),
],
),
);
}
}