diff --git a/lib/models/cloud_file.dart b/lib/models/cloud_file.dart index 5a59f3b..014c3d5 100644 --- a/lib/models/cloud_file.dart +++ b/lib/models/cloud_file.dart @@ -126,7 +126,13 @@ class CloudFile { if (bytes < 1024 * 1024 * 1024) { return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + if (bytes < 1024 * 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + if (bytes < 1024 * 1024 * 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} TB'; + } + return '${(bytes / (1024 * 1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} PB'; } factory CloudFile.fromJson(Map json) { diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 1f13c3c..433970c 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -931,11 +931,16 @@ class _CloudToolbar extends ConsumerWidget { currentSort: state.serverSort, currentDirection: state.serverSortDirection, onSortChanged: notifier.setSort, + onDirectionToggle: notifier.toggleSortDirection, ), const SizedBox(width: 8), - _ToolbarSegment(value: paneMode, onChanged: onPaneModeChanged), - const SizedBox(width: 8), - _FileViewButtons(value: viewMode, onChanged: onViewModeChanged), + _ToolbarControlGroup( + children: [ + _ToolbarSegment(value: paneMode, onChanged: onPaneModeChanged), + const _ToolbarGroupDivider(), + _FileViewButtons(value: viewMode, onChanged: onViewModeChanged), + ], + ), const SizedBox(width: 8), _ToolbarButton( icon: Icons.upload_rounded, @@ -943,21 +948,29 @@ class _CloudToolbar extends ConsumerWidget { 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, + const SizedBox(width: 8), + _ToolbarControlGroup( + children: [ + _ToolbarButton( + icon: Icons.create_new_folder_rounded, + label: '新建文件夹', + onTap: () => _showCreateFolderDialog(context, ref), + grouped: true, + ), + _ToolbarButton( + icon: Icons.refresh_rounded, + label: '刷新', + onTap: () => notifier.loadFiles(), + grouped: true, + ), + _ToolbarButton( + icon: Icons.more_horiz_rounded, + label: sidePanelOpen ? '隐藏详情' : '显示详情', + onTap: onToggleSidePanel, + grouped: true, + selected: sidePanelOpen, + ), + ], ), ], ); @@ -1017,6 +1030,8 @@ class _ToolbarSegment extends StatelessWidget { return _ToolbarButton( icon: isDual ? Icons.view_agenda_rounded : Icons.view_column_rounded, label: isDual ? '切换单面板' : '切换双面板', + grouped: true, + selected: isDual, onTap: () => onChanged(isDual ? _PaneLayoutMode.single : _PaneLayoutMode.dual), ); @@ -1037,16 +1052,22 @@ class _FileViewButtons extends StatelessWidget { _ToolbarButton( icon: Icons.view_list_rounded, label: '列表显示', + grouped: true, + selected: value == _FileViewMode.list, onTap: () => onChanged(_FileViewMode.list), ), _ToolbarButton( icon: Icons.view_column_rounded, label: 'Finder 分栏显示', + grouped: true, + selected: value == _FileViewMode.columns, onTap: () => onChanged(_FileViewMode.columns), ), _ToolbarButton( icon: Icons.grid_view_rounded, label: '网格显示', + grouped: true, + selected: value == _FileViewMode.grid, onTap: () => onChanged(_FileViewMode.grid), ), ], @@ -1059,12 +1080,16 @@ class _ToolbarButton extends StatelessWidget { final String label; final VoidCallback onTap; final bool primary; + final bool grouped; + final bool selected; const _ToolbarButton({ required this.icon, required this.label, required this.onTap, this.primary = false, + this.grouped = false, + this.selected = false, }); @override @@ -1073,22 +1098,30 @@ class _ToolbarButton extends StatelessWidget { return ShadTooltip( builder: (_) => Text(label), child: Padding( - padding: const EdgeInsets.only(left: 6), + padding: EdgeInsets.only(left: grouped ? 0 : 6), child: InkWell( borderRadius: BorderRadius.circular(10), onTap: onTap, child: Container( - constraints: BoxConstraints(minWidth: primary ? 72 : 38), + constraints: BoxConstraints(minWidth: primary ? 72 : 36), height: 32, padding: EdgeInsets.symmetric(horizontal: primary ? 10 : 0), decoration: BoxDecoration( color: primary ? cs.primary + : selected + ? cs.primary.withValues(alpha: 0.14) + : grouped + ? Colors.transparent : Colors.white.withValues(alpha: 0.46), borderRadius: BorderRadius.circular(10), border: Border.all( color: primary ? cs.primary + : selected + ? cs.primary.withValues(alpha: 0.45) + : grouped + ? Colors.transparent : Colors.white.withValues(alpha: 0.54), ), ), @@ -1098,7 +1131,11 @@ class _ToolbarButton extends StatelessWidget { Icon( icon, size: 18, - color: primary ? cs.primaryForeground : cs.mutedForeground, + color: primary + ? cs.primaryForeground + : selected + ? cs.primary + : cs.mutedForeground, ), if (primary) ...[ const SizedBox(width: 6), @@ -1120,6 +1157,41 @@ class _ToolbarButton extends StatelessWidget { } } +class _ToolbarControlGroup extends StatelessWidget { + final List children; + + const _ToolbarControlGroup({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.54)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: children), + ); + } +} + +class _ToolbarGroupDivider extends StatelessWidget { + const _ToolbarGroupDivider(); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 2), + color: cs.border, + ); + } +} + class _PrimaryFilePane extends ConsumerWidget { final String title; final FileState state; @@ -1380,6 +1452,7 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { List<_ColumnListing> _columns = const []; List _path = const []; var _generation = 0; + final _scrollController = ScrollController(); @override void initState() { @@ -1387,6 +1460,12 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { _restorePath(widget.initialPath, initialFiles: widget.initialFiles); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant _ColumnFileBrowser oldWidget) { super.didUpdateWidget(oldWidget); @@ -1414,6 +1493,7 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { const _ColumnListing(parentID: null, title: '全部文件', isLoading: true), ]; }); + _scrollToStart(); await _loadColumn(0, generation: generation); for (var index = 0; index < restoredPath.length; index++) { if (!mounted || generation != _generation) return; @@ -1508,9 +1588,29 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { _columns = columns; }); unawaited(_loadColumn(index + 1, generation: generation)); + _scrollToLatestColumn(); _notifyPathChanged(path); } + void _scrollToStart() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollController.hasClients) { + _scrollController.jumpTo(0); + } + }); + } + + void _scrollToLatestColumn() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + ); + }); + } + void _navigateBreadcrumb(int index) { final path = index < 0 ? const [] @@ -1588,6 +1688,7 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { ), child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( + controller: _scrollController, scrollDirection: Axis.horizontal, child: SizedBox( height: constraints.maxHeight, diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index 9a51863..d45efac 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -171,7 +171,7 @@ class FileNotifier extends StateNotifier { final generation = ++_detailGeneration; final resolvedParentID = parentID ?? _currentParentID; final cacheKey = - '${state.section.name}:${resolvedParentID ?? 'root'}:${state.currentPage}:${state.pageSize}'; + '${state.section.name}:${resolvedParentID ?? 'root'}:${state.currentPage}:${state.pageSize}:${state.serverSort.name}:${state.serverSortDirection.name}'; final cached = _readCachedFiles(cacheKey); if (cached != null) { state = state.copyWith(files: cached, clearError: true); @@ -182,7 +182,7 @@ class FileNotifier extends StateNotifier { try { final result = await _fetchFiles(resolvedParentID); - final extracted = _extractFiles(result); + final extracted = _sortFiles(_extractFiles(result)); final totalPages = _extractTotalPages(result, extracted.length); state = state.copyWith(files: extracted, totalPages: totalPages); await _writeCachedFiles(cacheKey, extracted); @@ -511,6 +511,37 @@ class FileNotifier extends StateNotifier { loadFiles(parentID: _currentParentID); } + void toggleSortDirection() { + final direction = state.serverSortDirection == SortDirection.ascending + ? SortDirection.descending + : SortDirection.ascending; + state = state.copyWith(serverSortDirection: direction, currentPage: 0); + loadFiles(parentID: _currentParentID); + } + + List _sortFiles(List files) { + final sorted = List.from(files); + int compare(CloudFile a, CloudFile b) { + if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1; + final value = switch (state.serverSort) { + FileSort.name => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + FileSort.size => (a.size ?? 0).compareTo(b.size ?? 0), + FileSort.modifiedAt || + FileSort.createdAt => a.modifiedAt.compareTo(b.modifiedAt), + FileSort.type => a.typeName.compareTo(b.typeName), + }; + if (value != 0) { + return state.serverSortDirection == SortDirection.ascending + ? value + : -value; + } + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + } + + sorted.sort(compare); + return sorted; + } + void nextPage() { if (state.currentPage < state.totalPages - 1) { state = state.copyWith(currentPage: state.currentPage + 1); @@ -693,7 +724,8 @@ class FileNotifier extends StateNotifier { final result = await Process.run('/usr/bin/mdfind', [ "kMDItemCFBundleIdentifier == '${player.bundleID}'", ]); - if (result.exitCode == 0 && result.stdout.toString().trim().isNotEmpty) { + if (result.exitCode == 0 && + result.stdout.toString().trim().isNotEmpty) { installed.add(player); } } catch (_) { diff --git a/lib/widgets/sort_menu.dart b/lib/widgets/sort_menu.dart index 63a2ecb..675cb6f 100644 --- a/lib/widgets/sort_menu.dart +++ b/lib/widgets/sort_menu.dart @@ -6,54 +6,89 @@ class SortMenu extends StatelessWidget { final FileSort currentSort; final SortDirection currentDirection; final ValueChanged onSortChanged; + final VoidCallback onDirectionToggle; const SortMenu({ super.key, required this.currentSort, required this.currentDirection, required this.onSortChanged, + required this.onDirectionToggle, }); @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; - return SizedBox( - width: 124, - child: ShadSelect( - initialValue: currentSort, - selectedOptionBuilder: (context, value) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.sort_rounded, size: 15, color: cs.mutedForeground), - const SizedBox(width: 5), - Text( - value.title, - style: TextStyle(fontSize: 12, color: cs.foreground), - ), - ], - ), - options: [ - for (final sort in FileSort.values) - ShadOption( - value: sort, - child: Row( + return Container( + height: 36, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.54)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 126, + child: ShadSelect( + key: ValueKey('$currentSort:$currentDirection'), + initialValue: currentSort, + selectedOptionBuilder: (context, value) => Row( + mainAxisSize: MainAxisSize.min, children: [ - Expanded(child: Text(sort.title)), - if (currentSort == sort) - Icon( - currentDirection == SortDirection.ascending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 14, - color: cs.primary, - ), + Icon(Icons.sort_rounded, size: 15, color: cs.mutedForeground), + const SizedBox(width: 5), + Text( + value.title, + style: TextStyle(fontSize: 12, color: cs.foreground), + ), ], ), + options: [ + for (final sort in FileSort.values) + ShadOption( + value: sort, + child: Row( + children: [ + Expanded(child: Text(sort.title)), + if (currentSort == sort) + Icon( + currentDirection == SortDirection.ascending + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 14, + color: cs.primary, + ), + ], + ), + ), + ], + onChanged: (value) { + if (value != null) onSortChanged(value); + }, ), + ), + Container(width: 1, height: 20, color: cs.border), + ShadTooltip( + builder: (_) => + Text(currentDirection == SortDirection.ascending ? '升序' : '降序'), + child: InkWell( + onTap: onDirectionToggle, + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 34, + child: Icon( + currentDirection == SortDirection.ascending + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 16, + color: cs.mutedForeground, + ), + ), + ), + ), ], - onChanged: (value) { - if (value != null) onSortChanged(value); - }, ), ); }