主要合入内容:

- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、Ctrl/Cmd+F搜索、Esc 关闭搜索、鼠标右滑返回上级。
- 移动端同步状态页、同步统计卡片、同步任务/累计统计事件、同步页布局更新。
- Android 相册同步默认路径改为 DCIM/Camera,下载路径改用 external_path。
- Rust sync-core 更新:Android 日志依赖、AlbumUpload/AlbumDownload、MirrorWcf 统计修复、Linux FUSE镜像挂载与读写同步支持。

- 依赖更新:external_path、fl_chart、pdfrx 升级,并修了 pdfrx 新版本的deprecated API。
- 新增文件包括:sort_options.dart、sync_page_android.dart、sync_stats_card.dart、platform/fuse.rs、sync_engine/fuse.rs。
This commit is contained in:
2026-06-04 07:11:43 +08:00
parent 5ee6ba1e28
commit b02daf1448
56 changed files with 7589 additions and 1200 deletions
@@ -4,10 +4,13 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../core/utils/date_utils.dart' as date_utils;
import '../../../core/constants/sort_options.dart';
import '../../../core/constants/storage_keys.dart';
import '../../../core/utils/file_type_utils.dart';
import '../../../data/models/file_model.dart';
import '../../../router/app_router.dart';
import '../../../services/file_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/file_manager_provider.dart';
import '../../widgets/file_info_dialog.dart';
import '../../widgets/file_operation_dialogs.dart';
@@ -52,10 +55,7 @@ class CategoryFilesPageArgs {
class CategoryFilesPage extends StatefulWidget {
final CategoryFilesPageArgs args;
const CategoryFilesPage({
super.key,
required this.args,
});
const CategoryFilesPage({super.key, required this.args});
@override
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
@@ -73,6 +73,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
bool _isLoading = true;
bool _isLoadingMore = false;
String? _errorMessage;
SortOption _sortOption = SortOption.default_;
bool get _hasSelection => _selectedFilePaths.isNotEmpty;
@@ -83,6 +84,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
@override
void initState() {
super.initState();
_restoreSortOption();
_loadFiles(refresh: true);
_scrollController.addListener(_onScroll);
}
@@ -133,11 +135,14 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
final response = await _fileService.listFilesByCategory(
category: widget.args.category,
pageSize: 100,
orderBy: _sortOption.field.apiKey,
orderDirection: _sortOption.direction.apiKey,
nextPageToken: refresh ? null : _nextPageToken,
);
final filesData = response['files'] as List<dynamic>? ?? const [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? const {};
final pagination =
response['pagination'] as Map<String, dynamic>? ?? const {};
final newFiles = filesData
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
.where((file) => file.isFile)
@@ -152,7 +157,9 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
..addAll(newFiles);
} else {
final existingIds = _files.map((e) => e.id).toSet();
_files.addAll(newFiles.where((file) => !existingIds.contains(file.id)));
_files.addAll(
newFiles.where((file) => !existingIds.contains(file.id)),
);
}
_nextPageToken = pagination['next_token'] as String?;
@@ -170,8 +177,65 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
}
}
Widget _buildSortMenu() {
final allOptions = [
for (final field in SortField.values)
for (final dir in SortDirection.values) SortOption(field, dir),
];
return PopupMenuButton<SortOption>(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: '排序',
position: PopupMenuPosition.under,
onSelected: _setSortOption,
itemBuilder: (context) => allOptions.map((option) {
final isSelected = _sortOption == option;
return PopupMenuItem<SortOption>(
value: option,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.check,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
)
else
const SizedBox(width: 24),
Text(option.menuLabel),
],
),
);
}).toList(),
);
}
Future<void> _refresh() => _loadFiles(refresh: true);
Future<void> _restoreSortOption() async {
final key = await StorageService.instance.getString(
StorageKeys.fileSortOption,
);
final option = SortOption.fromKey(key);
if (option != _sortOption) {
setState(() => _sortOption = option);
}
}
Future<void> _setSortOption(SortOption option) async {
if (_sortOption == option) return;
setState(() => _sortOption = option);
await StorageService.instance.setString(
StorageKeys.fileSortOption,
option.toKey(),
);
await _loadFiles(refresh: true);
}
void _toggleSelection(FileModel file) {
HapticFeedback.selectionClick();
setState(() {
@@ -207,10 +271,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
},
child: Scaffold(
appBar: _buildAppBar(context),
body: RefreshIndicator(
onRefresh: _refresh,
child: _buildBody(context),
),
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
bottomNavigationBar: _buildSelectionBottomBar(context),
),
);
@@ -230,10 +291,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
centerTitle: true,
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
actions: [
TextButton(
onPressed: _selectAllVisible,
child: const Text('全选'),
),
TextButton(onPressed: _selectAllVisible, child: const Text('全选')),
],
);
}
@@ -241,6 +299,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
return AppBar(
title: Text(args.title),
actions: [
_buildSortMenu(),
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新',
@@ -265,10 +324,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.bottomCenter,
children: <Widget>[
...previousChildren,
?currentChild,
],
children: <Widget>[...previousChildren, ?currentChild],
);
},
transitionBuilder: (child, animation) {
@@ -298,17 +354,17 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
? null
: () => _showSelectionMore(context, singleSelected),
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
false,
),
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
true,
),
context,
context.read<FileManagerProvider>(),
_selectedFilePaths.toList(),
true,
),
onDelete: () => _deleteSelectedFiles(context, selected),
)
: const SizedBox.shrink(
@@ -359,11 +415,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon(
widget.args.icon,
size: 52,
color: widget.args.color,
),
Icon(widget.args.icon, size: 52, color: widget.args.color),
const SizedBox(height: 12),
Center(
child: Text(
@@ -383,7 +435,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
final columnWidth =
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
columnCount;
columnCount;
final columns = List.generate(columnCount, (_) => <FileModel>[]);
final heights = List.generate(columnCount, (_) => 0.0);
@@ -391,7 +443,8 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
for (final file in _files) {
final targetIndex = _indexOfMin(heights);
columns[targetIndex].add(file);
heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing;
heights[targetIndex] +=
_estimatedTileHeight(file, columnWidth) + spacing;
}
return ListView(
@@ -417,12 +470,16 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: _CategoryFileTile(
key: ValueKey('category-file-${file.id.isNotEmpty ? file.id : file.path}'),
key: ValueKey(
'category-file-${file.id.isNotEmpty ? file.id : file.path}',
),
file: file,
contextHint: _contextHint,
category: widget.args.category,
accentColor: widget.args.color,
isSelected: _selectedFilePaths.contains(file.path),
isSelected: _selectedFilePaths.contains(
file.path,
),
selectionMode: _hasSelection,
onTap: () {
if (_hasSelection) {
@@ -536,11 +593,17 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
}
}
@@ -760,9 +823,7 @@ class _CategoryFileTile extends StatelessWidget {
),
),
if (category == 'video')
const Center(
child: _PlayOverlay(),
),
const Center(child: _PlayOverlay()),
],
),
),
@@ -807,8 +868,9 @@ class _CategoryFileTile extends StatelessWidget {
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary
.withValues(alpha: 0.12),
color: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
blurRadius: 10,
spreadRadius: 0.5,
),
@@ -877,10 +939,7 @@ class _SelectionCircle extends StatelessWidget {
final bool selected;
final VoidCallback? onTap;
const _SelectionCircle({
required this.selected,
this.onTap,
});
const _SelectionCircle({required this.selected, this.onTap});
@override
Widget build(BuildContext context) {
@@ -915,11 +974,7 @@ class _SelectionCircle extends StatelessWidget {
],
),
child: selected
? const Icon(
LucideIcons.check,
color: Colors.white,
size: 16,
)
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
: null,
),
),
@@ -948,10 +1003,7 @@ class _TypeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(9),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 7,
vertical: 4,
),
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -986,11 +1038,7 @@ class _PlayOverlay extends StatelessWidget {
),
child: const Padding(
padding: EdgeInsets.all(10),
child: Icon(
LucideIcons.play,
color: Colors.white,
size: 22,
),
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
),
);
}
+436 -133
View File
@@ -4,10 +4,12 @@ import 'dart:ui';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/services.dart';
import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:cloudreve4_flutter/services/upload_service.dart';
import '../../../core/utils/file_utils.dart';
import '../../../core/constants/sort_options.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
@@ -41,6 +43,8 @@ class _FilesPageState extends State<FilesPage> {
bool _isFirstLoad = true;
FileModel? _infoFile;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final ScrollController _scrollController = ScrollController();
final ScrollController _breadcrumbController = ScrollController();
// FAB 状态
bool _isFabVisible = true;
@@ -50,24 +54,37 @@ class _FilesPageState extends State<FilesPage> {
// 桌面端拖拽状态
bool _isDraggingOver = false;
// 滑动手势追踪
Offset? _swipeStartPos;
DateTime? _swipeStartTime;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScrollForPagination);
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= 1000) {
fileManager.setViewType(FileViewType.grid);
} else {
fileManager.setViewType(FileViewType.list);
}
fileManager.restoreSortOption();
if (_isFirstLoad) {
fileManager.loadFiles();
_isFirstLoad = false;
}
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
downloadManager.initialize();
}
});
@@ -75,8 +92,13 @@ class _FilesPageState extends State<FilesPage> {
// 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final normalizedCurrent = FileUtils.toCloudreveUri(
fileManager.currentPath,
);
if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName'
@@ -88,11 +110,74 @@ class _FilesPageState extends State<FilesPage> {
@override
void dispose() {
_scrollController.removeListener(_onScrollForPagination);
_scrollController.dispose();
_breadcrumbController.dispose();
_fabShowTimer?.cancel();
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
UploadService.instance.onUploadCompleted = null;
super.dispose();
}
void _onScrollForPagination() {
if (!_scrollController.hasClients) return;
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
if (!fileManager.hasMore ||
fileManager.isLoadingMore ||
fileManager.isLoading) {
return;
}
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 320) {
fileManager.loadMoreFiles();
}
}
bool _handleKeyEvent(KeyEvent event) {
if (!mounted || event is! KeyDownEvent) return false;
// Ctrl+F / Cmd+F → 打开搜索
if (event.logicalKey == LogicalKeyboardKey.keyF &&
(HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed)) {
if (ModalRoute.of(context)?.isCurrent == false) return false;
final nav = Provider.of<NavigationProvider>(context, listen: false);
if (nav.currentIndex == 1 && !SearchDialog.isShowing) {
SearchDialog.show(context);
return true;
}
}
return false;
}
void _onPointerDown(PointerDownEvent event) {
_swipeStartPos = event.position;
_swipeStartTime = DateTime.now();
}
void _onPointerUp(PointerUpEvent event) {
if (_swipeStartPos == null || _swipeStartTime == null) return;
final dx = event.position.dx - _swipeStartPos!.dx;
final dy = event.position.dy - _swipeStartPos!.dy;
final duration = DateTime.now().difference(_swipeStartTime!);
_swipeStartPos = null;
_swipeStartTime = null;
// 从右往左快速滑动 → 返回上一级
if (dx < -150 &&
dx.abs() > dy.abs() * 1.5 &&
duration.inMilliseconds < 500) {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
if (fileManager.currentPath != '/') {
fileManager.goBack();
}
}
}
void _showFileInfo(FileModel file) {
setState(() => _infoFile = file);
// 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开
@@ -101,10 +186,7 @@ class _FilesPageState extends State<FilesPage> {
});
}
void _showSelectionMore(
FileModel file,
FileManagerProvider fileManager,
) {
void _showSelectionMore(FileModel file, FileManagerProvider fileManager) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
@@ -116,7 +198,11 @@ class _FilesPageState extends State<FilesPage> {
title: const Text('重命名'),
onTap: () {
Navigator.of(sheetContext).pop();
FileOperationDialogs.showRenameDialog(context, fileManager, file);
FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
);
},
),
ListTile(
@@ -179,13 +265,17 @@ class _FilesPageState extends State<FilesPage> {
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
return Scaffold(
key: _scaffoldKey,
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context),
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
return Listener(
onPointerDown: _onPointerDown,
onPointerUp: _onPointerUp,
child: Scaffold(
key: _scaffoldKey,
appBar: _buildAppBar(context),
body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context),
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
),
);
}
@@ -200,7 +290,13 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.currentPath == '/') {
return const Text('文件');
}
return _buildDesktopBreadcrumb(context, fileManager);
final segments = fileManager.currentPath
.split('/')
.where((s) => s.isNotEmpty)
.toList();
return Text(
segments.isNotEmpty ? _decodePathSegment(segments.last) : '文件',
);
}
return _buildMobileBreadcrumb(context, fileManager);
},
@@ -224,60 +320,30 @@ class _FilesPageState extends State<FilesPage> {
return decoded;
}
Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
Widget _buildMobileBreadcrumb(
BuildContext context,
FileManagerProvider fileManager,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Icon(LucideIcons.home, size: 18, color: colorScheme.primary),
),
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
),
InkWell(
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(
_decodePathSegment(pathParts[i]),
style: TextStyle(
color: i == pathParts.length - 1 ? colorScheme.onSurface : colorScheme.primary,
fontWeight: i == pathParts.length - 1 ? FontWeight.w600 : FontWeight.normal,
),
),
),
),
],
],
);
}
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
// 路径变化后自动滚动到末尾
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_breadcrumbController.hasClients) {
_breadcrumbController.animateTo(
_breadcrumbController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
return SizedBox(
height: 40,
child: ListView(
key: const PageStorageKey('mobile_breadcrumb'),
controller: _breadcrumbController,
scrollDirection: Axis.horizontal,
children: [
_buildBreadcrumbChip(
@@ -285,12 +351,18 @@ class _FilesPageState extends State<FilesPage> {
label: '文件',
icon: LucideIcons.home,
color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
onTap: () => fileManager.currentPath != '/'
? fileManager.enterFolder('/')
: null,
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
child: Icon(
LucideIcons.chevronRight,
size: 14,
color: theme.hintColor.withValues(alpha: 0.5),
),
),
_buildBreadcrumbChip(
context,
@@ -300,7 +372,9 @@ class _FilesPageState extends State<FilesPage> {
isLast: i == pathParts.length - 1,
onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
if (targetPath != fileManager.currentPath) {
fileManager.enterFolder(targetPath);
}
},
),
],
@@ -324,7 +398,9 @@ class _FilesPageState extends State<FilesPage> {
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
color: isLast
? color.withValues(alpha: 0.15)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
),
child: Row(
@@ -358,12 +434,19 @@ class _FilesPageState extends State<FilesPage> {
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return IconButton(
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
icon: Icon(
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
),
onPressed: () => fileManager.refreshFiles(),
tooltip: '刷新',
);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return _buildSortMenu(fileManager);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list
@@ -378,14 +461,19 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
);
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
FileOperationDialogs.showCreateDialog(context, fileManager);
},
tooltip: '新建',
@@ -397,7 +485,8 @@ class _FilesPageState extends State<FilesPage> {
),
IconButton(
icon: const Icon(Icons.cloud_download),
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
onPressed: () =>
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载',
),
];
@@ -405,6 +494,11 @@ class _FilesPageState extends State<FilesPage> {
List<Widget> _buildMobileActions() {
return [
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return _buildSortMenu(fileManager);
},
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list
@@ -419,13 +513,52 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list,
);
},
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
);
},
),
];
}
Widget _buildSortMenu(FileManagerProvider fileManager) {
final allOptions = [
for (final field in SortField.values)
for (final dir in SortDirection.values) SortOption(field, dir),
];
return PopupMenuButton<SortOption>(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: '排序',
position: PopupMenuPosition.under,
onSelected: (option) => fileManager.setSortOption(option),
itemBuilder: (context) => allOptions.map((option) {
final isSelected = fileManager.sortOption == option;
return PopupMenuItem<SortOption>(
value: option,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.check,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
)
else
const SizedBox(width: 24),
Text(option.menuLabel),
],
),
);
}).toList(),
);
}
// ---- SpeedDial FAB ----
Widget _buildSpeedDialFAB(BuildContext context) {
@@ -471,8 +604,16 @@ class _FilesPageState extends State<FilesPage> {
isDark: isDark,
colorScheme: colorScheme,
onTap: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
_onFabSubAction(
() => FileOperationDialogs.showCreateDialog(
context,
fileManager,
),
);
},
),
_buildFabSubItem(
@@ -482,7 +623,10 @@ class _FilesPageState extends State<FilesPage> {
label: '离线下载',
isDark: isDark,
colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
onTap: () => _onFabSubAction(
() =>
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
),
Consumer<FileManagerProvider>(
builder: (context, fileManager, _) {
@@ -568,7 +712,10 @@ class _FilesPageState extends State<FilesPage> {
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
@@ -649,7 +796,8 @@ class _FilesPageState extends State<FilesPage> {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context);
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
if (!isDesktop ||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child;
}
@@ -673,13 +821,19 @@ class _FilesPageState extends State<FilesPage> {
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.85),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary),
Icon(
LucideIcons.upload,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'释放文件以上传到当前目录',
@@ -709,8 +863,14 @@ class _FilesPageState extends State<FilesPage> {
}
if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final uploadManager = Provider.of<UploadManagerProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
uploadManager.markShouldShowDialog();
uploadManager.startUpload(files, fileManager.currentPath);
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
@@ -740,12 +900,19 @@ class _FilesPageState extends State<FilesPage> {
);
}
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) {
Widget _buildErrorView(
BuildContext context,
FileManagerProvider fileManager,
) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error),
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
fileManager.errorMessage!,
@@ -783,23 +950,38 @@ class _FilesPageState extends State<FilesPage> {
Widget _buildListView(BuildContext context, FileManagerProvider fileManager) {
final isDesktop = MediaQuery.of(context).size.width >= 1000;
final showCheckbox = fileManager.hasSelection;
final itemCount =
fileManager.files.length +
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
return Column(
children: [
if (isDesktop) FileListHeader(showCheckbox: showCheckbox),
if (isDesktop)
FileListHeader(
showCheckbox: showCheckbox,
currentSort: fileManager.sortOption,
onSort: (option) => fileManager.setSortOption(option),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: ListView.builder(
controller: _scrollController,
key: PageStorageKey('files_list_${fileManager.currentPath}'),
cacheExtent: 900,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: fileManager.files.length,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: itemCount,
itemBuilder: (context, index) {
if (index >= fileManager.files.length) {
return _buildLoadMoreIndicator(context, fileManager);
}
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path);
final isSelected = fileManager.selectedFiles.contains(
file.path,
);
return FileListItem(
key: ValueKey('file_${file.id}'),
@@ -821,14 +1003,40 @@ class _FilesPageState extends State<FilesPage> {
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
onShare: () => FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
onDownload: !file.isFolder
? () => _downloadFile(context, fileManager, file)
: null,
onOpenInBrowser: !file.isFolder
? () => _openInBrowser(context, file)
: null,
onOpenInCloudreveApp: !file.isFolder
? () => _openInCloudreveApp(context, file)
: null,
onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () =>
FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onInfo: () => _showFileInfo(file),
);
},
@@ -857,15 +1065,20 @@ class _FilesPageState extends State<FilesPage> {
crossAxisCount = 5;
}
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final itemWidth =
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 160;
final showCheckbox = fileManager.hasSelection;
final itemCount =
fileManager.files.length +
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
return RefreshIndicator(
onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: GridView.builder(
controller: _scrollController,
key: PageStorageKey('files_grid_${fileManager.currentPath}'),
cacheExtent: 1100,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
@@ -876,8 +1089,11 @@ class _FilesPageState extends State<FilesPage> {
crossAxisSpacing: spacing / 2,
childAspectRatio: childAspectRatio,
),
itemCount: fileManager.files.length,
itemCount: itemCount,
itemBuilder: (context, index) {
if (index >= fileManager.files.length) {
return _buildGridLoadMoreIndicator(context, fileManager);
}
final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path);
@@ -900,14 +1116,39 @@ class _FilesPageState extends State<FilesPage> {
}
},
onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
onShare: () => FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
onDownload: !file.isFolder
? () => _downloadFile(context, fileManager, file)
: null,
onOpenInBrowser: !file.isFolder
? () => _openInBrowser(context, file)
: null,
onOpenInCloudreveApp: !file.isFolder
? () => _openInCloudreveApp(context, file)
: null,
onRename: () => FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
),
onMove: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
false,
),
onCopy: () => FileOperationDialogs.showMoveDialog(
context,
fileManager,
file,
true,
),
onShare: () =>
FileOperationDialogs.showShareDialog(context, file),
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
context,
fileManager,
file,
),
onInfo: () => _showFileInfo(file),
);
},
@@ -916,6 +1157,62 @@ class _FilesPageState extends State<FilesPage> {
);
}
Widget _buildLoadMoreIndicator(
BuildContext context,
FileManagerProvider fileManager,
) {
if (fileManager.isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: OutlinedButton.icon(
onPressed: () => fileManager.loadMoreFiles(),
icon: const Icon(LucideIcons.chevronsDown, size: 16),
label: const Text('加载更多'),
),
),
);
}
Widget _buildGridLoadMoreIndicator(
BuildContext context,
FileManagerProvider fileManager,
) {
if (fileManager.isLoadingMore) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: OutlinedButton.icon(
onPressed: () => fileManager.loadMoreFiles(),
icon: const Icon(LucideIcons.chevronsDown, size: 16),
label: const Text('加载更多'),
),
),
);
}
Widget _buildBottomBar(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
@@ -930,29 +1227,29 @@ class _FilesPageState extends State<FilesPage> {
onSelectAll: () => fileManager.selectAll(),
onMore: fileManager.selectedFiles.length == 1
? () => _showSelectionMore(
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
fileManager,
)
fileManager.files.firstWhere(
(f) => f.path == fileManager.selectedFiles.first,
),
fileManager,
)
: null,
onMove: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
false,
),
context,
fileManager,
fileManager.selectedFiles,
false,
),
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
context,
fileManager,
fileManager.selectedFiles,
true,
),
context,
fileManager,
fileManager.selectedFiles,
true,
),
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
context,
fileManager,
fileManager.selectedFiles,
),
context,
fileManager,
fileManager.selectedFiles,
),
);
}
@@ -976,11 +1273,17 @@ class _FilesPageState extends State<FilesPage> {
} else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) {
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
Navigator.of(
context,
).pushNamed(RouteNames.markdownPreview, arguments: file);
} else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
}
}
@@ -989,7 +1292,10 @@ class _FilesPageState extends State<FilesPage> {
FileManagerProvider fileManager,
FileModel file,
) async {
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final task = await downloadManager.addDownloadTask(
fileName: file.name,
fileUri: file.relativePath,
@@ -1037,11 +1343,8 @@ class _FilesPageState extends State<FilesPage> {
}
void _openInCloudreveApp(BuildContext context, FileModel file) {
Navigator.of(context).pushNamed(
RouteNames.cloudreveFileApp,
arguments: {
'file': file,
},
);
Navigator.of(
context,
).pushNamed(RouteNames.cloudreveFileApp, arguments: {'file': file});
}
}
@@ -114,9 +114,12 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
initialPageNumber: 1,
params: const PdfViewerParams(
activeMatchTextColor: Colors.yellow,
annotationRenderingMode: PdfAnnotationRenderingMode.annotationAndForms,
maxScale: 4.0,
minScale: 0.8, // Allow 300% zoom
annotationRenderingMode:
PdfAnnotationRenderingMode.annotationAndForms,
sizeDelegateProvider: PdfViewerSizeDelegateProviderLegacy(
maxScale: 4.0,
minScale: 0.8,
),
scaleEnabled: true,
textSelectionParams: PdfTextSelectionParams(
enabled: true,
@@ -23,26 +23,48 @@ class QuickFunctionsSection extends StatelessWidget {
const QuickFunctionsSection({super.key});
static final _functions = [
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
_QuickFunction(
icon: LucideIcons.share2,
label: '我的分享',
route: RouteNames.share,
),
_QuickFunction(
icon: LucideIcons.cloud,
label: 'WebDAV',
route: RouteNames.webdav,
),
_QuickFunction(
icon: LucideIcons.download,
label: '离线下载',
route: RouteNames.remoteDownload,
),
_QuickFunction(
icon: LucideIcons.trash2,
label: '回收站',
route: RouteNames.recycleBin,
),
_QuickFunction(
icon: LucideIcons.refreshCw,
label: '文件同步',
onTap: (ctx) {
final nav = ctx.read<NavigationProvider>();
// 桌面端有同步 Tabindex 4,直接切换;移动端跳转同步设置
final isDesktop = defaultTargetPlatform != TargetPlatform.android &&
// 桌面端或 Android 平板(宽屏)有同步 Tab,直接切换;手机端跳转同步详情
final isDesktop =
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
if (isDesktop) {
nav.setIndex(4);
final isWideScreen = MediaQuery.of(ctx).size.width >= 800;
if (isDesktop || isWideScreen) {
nav.setIndex(3);
} else {
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
Navigator.of(ctx).pushNamed(RouteNames.syncStatus);
}
},
),
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
_QuickFunction(
icon: LucideIcons.settings,
label: '设置',
route: RouteNames.settings,
),
];
static const double _spacing = 12;
@@ -63,9 +85,12 @@ class QuickFunctionsSection extends StatelessWidget {
children: [
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('快捷功能',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
Text(
'快捷功能',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
@@ -79,7 +104,8 @@ class QuickFunctionsSection extends StatelessWidget {
if (itemWidth < _minItemWidth) break;
perRow = next;
}
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
final itemWidth =
(availableWidth - _spacing * (perRow - 1)) / perRow;
return Wrap(
spacing: _spacing,
@@ -132,9 +158,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
final colorScheme = theme.colorScheme;
return Card(
color: _hovered
? colorScheme.surfaceContainerHighest
: null,
color: _hovered ? colorScheme.surfaceContainerHighest : null,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(12),
+78 -14
View File
@@ -1,9 +1,11 @@
import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/announcement_dialog.dart';
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart';
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
@@ -57,12 +59,18 @@ class _AppShellState extends State<AppShell>
final Set<int> _visitedPageIndexes = <int>{0};
late AnimationController _syncSpinController;
String? _lastClipboardShareId;
String? _lastUserId;
bool _cachedShowSyncTab = false;
bool get _showSyncTab =>
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
static bool _shouldShowSyncTab(double screenWidth) {
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return true;
}
return screenWidth >= 800;
}
List<Widget> get _pages => _showSyncTab
List<Widget> _pages(bool showSyncTab) => showSyncTab
? [
const OverviewPage(),
const FilesPage(),
@@ -78,7 +86,7 @@ class _AppShellState extends State<AppShell>
];
int _clampedIndex(int index) {
final maxIndex = _pages.length - 1;
final maxIndex = _pages(_cachedShowSyncTab).length - 1;
if (index < 0) return 0;
if (index > maxIndex) return maxIndex;
return index;
@@ -95,6 +103,7 @@ class _AppShellState extends State<AppShell>
WidgetsBinding.instance.addPostFrameCallback((_) {
_showPostLoginAnnouncement();
_checkClipboardShareLink();
_lastUserId = context.read<AuthProvider>().user?.id;
});
}
@@ -112,6 +121,56 @@ class _AppShellState extends State<AppShell>
}
}
void _handleTabSelected(int index) {
final nav = Provider.of<NavigationProvider>(context, listen: false);
nav.setIndex(index);
final userSetting = Provider.of<UserSettingProvider>(
context,
listen: false,
);
if (index == 0 || index == _pages(_cachedShowSyncTab).length - 1) {
userSetting.loadCapacity();
}
}
void _checkUserChange(AuthProvider auth) {
final currentUserId = auth.user?.id;
if (_lastUserId != null && currentUserId != _lastUserId) {
_lastUserId = currentUserId;
Future.microtask(() {
if (mounted) _resetProvidersOnUserChange();
});
} else if (_lastUserId == null && currentUserId != null) {
_lastUserId = currentUserId;
}
}
void _resetProvidersOnUserChange() {
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final userSetting = Provider.of<UserSettingProvider>(
context,
listen: false,
);
final admin = Provider.of<AdminProvider>(context, listen: false);
final sync = Provider.of<SyncProvider>(context, listen: false);
fileManager.clearFiles();
userSetting.clear();
admin.clear();
if (sync.engineInitialized) {
sync.resetSync();
}
final nav = Provider.of<NavigationProvider>(context, listen: false);
nav.setIndex(0);
userSetting.loadCapacity();
}
Future<void> _showPostLoginAnnouncement() async {
final authProvider = context.read<AuthProvider>();
if (!authProvider.isAuthenticated) return;
@@ -193,6 +252,7 @@ class _AppShellState extends State<AppShell>
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000;
_cachedShowSyncTab = _shouldShowSyncTab(screenWidth);
return PopScope(
canPop: false,
@@ -217,8 +277,9 @@ class _AppShellState extends State<AppShell>
}
}
},
child: Consumer<NavigationProvider>(
builder: (context, navProvider, _) {
child: Consumer2<AuthProvider, NavigationProvider>(
builder: (context, auth, navProvider, _) {
_checkUserChange(auth);
if (isDesktop) {
return _buildDesktopLayout(context, navProvider);
}
@@ -229,7 +290,7 @@ class _AppShellState extends State<AppShell>
}
Widget _buildPageContent(BuildContext context, int currentIndex) {
final pages = _pages;
final pages = _pages(_cachedShowSyncTab);
final visibleIndex = _clampedIndex(currentIndex);
_visitedPageIndexes.add(visibleIndex);
@@ -297,7 +358,7 @@ class _AppShellState extends State<AppShell>
return NavigationBar(
height: 64,
selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i),
onDestinationSelected: _handleTabSelected,
destinations: [
const NavigationDestination(
icon: Icon(LucideIcons.layoutDashboard),
@@ -322,7 +383,7 @@ class _AppShellState extends State<AppShell>
),
label: '任务',
),
if (_showSyncTab)
if (_cachedShowSyncTab)
NavigationDestination(
icon: Consumer<SyncProvider>(
builder: (context, sync, _) {
@@ -373,15 +434,18 @@ class _AppShellState extends State<AppShell>
children: [
NavigationRail(
selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i),
onDestinationSelected: _handleTabSelected,
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: GestureDetector(
onTap: () => navProvider.setIndex(_pages.length - 1),
onTap: () =>
navProvider.setIndex(_pages(_cachedShowSyncTab).length - 1),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: navProvider.currentIndex == _pages.length - 1
border:
navProvider.currentIndex ==
_pages(_cachedShowSyncTab).length - 1
? Border.all(
color: theme.colorScheme.primary,
width: 2.5,
@@ -424,7 +488,7 @@ class _AppShellState extends State<AppShell>
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
label: const Text('任务'),
),
if (_showSyncTab)
if (_cachedShowSyncTab)
NavigationRailDestination(
icon: Consumer<SyncProvider>(
builder: (context, sync, _) {
+379 -188
View File
@@ -5,6 +5,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../../../data/models/sync_task_model.dart';
import '../../providers/sync_provider.dart';
import '../../widgets/sync_stats_card.dart';
import '../../widgets/toast_helper.dart';
import 'sync_settings_page.dart';
@@ -47,191 +48,328 @@ class _SyncPageState extends State<SyncPage> {
],
),
body: RefreshIndicator(
onRefresh: () async {
final sync = context.read<SyncProvider>();
sync.invalidateAllTaskDetails();
await sync.loadRecentTasks();
},
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_buildStatusCard(sync, theme),
const SizedBox(height: 8),
_buildActiveTasksSection(sync, theme),
const SizedBox(height: 8),
_buildCompletedTasksSection(sync, theme),
const SizedBox(height: 32),
],
),
),
);
}
void _navigateToSettings() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SyncSettingsPage()),
);
}
Widget _buildStatusCard(SyncProvider sync, ThemeData theme) {
final isActive = sync.isActive;
final isPaused = sync.isPaused;
final hasError = sync.hasError;
Color statusColor;
IconData statusIcon;
String statusText;
if (isActive) {
statusColor = theme.colorScheme.primary;
statusIcon = Icons.sync;
statusText = _syncModeLabel(sync);
} else if (isPaused) {
statusColor = Colors.orange;
statusIcon = Icons.pause_circle_outline;
statusText = '已暂停';
} else if (hasError) {
statusColor = theme.colorScheme.error;
statusIcon = Icons.error_outline;
statusText = '同步错误';
} else if (sync.state == SyncState.stopped) {
statusColor = theme.disabledColor;
statusIcon = Icons.stop_circle_outlined;
statusText = '已停止';
} else {
statusColor = theme.disabledColor;
statusIcon = Icons.cloud_off;
statusText = '未启动';
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 5),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
onRefresh: () async {
final sync = context.read<SyncProvider>();
sync.invalidateAllTaskDetails();
await sync.loadRecentTasks();
},
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
Row(
children: [
Icon(statusIcon, color: statusColor, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 800;
final statsCard = SyncStatsCard(
uploaded: sync.cumUploaded,
downloaded: sync.cumDownloaded,
renamed: sync.cumRenamed,
moved: sync.cumMoved,
conflicts: sync.cumConflicts,
failed: sync.cumFailed,
deletedLocal: sync.cumDeletedLocal,
deletedRemote: sync.cumDeletedRemote,
skipped: sync.cumSkipped,
);
if (isWide) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _buildStatusHeaderCard(sync, theme)),
const SizedBox(width: 8),
Expanded(child: statsCard),
],
),
);
}
return Column(
children: [
Text(
statusText,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (sync.errorMessage != null)
Text(
sync.errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
_buildStatusHeaderCard(sync, theme),
const SizedBox(height: 8),
statsCard,
],
),
),
if (sync.activeWorkerCount > 0)
Badge(
label: Text('${sync.activeWorkerCount}'),
child: Icon(LucideIcons.loader, color: statusColor, size: 24),
),
],
),
if (isActive || isPaused) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: sync.totalFiles > 0 ? sync.progress : null,
minHeight: 6,
),
),
const SizedBox(height: 8),
Text(
sync.totalFiles > 0
? '${sync.syncedFiles} / ${sync.totalFiles} 文件'
: sync.state == SyncState.continuous
? '持续同步中'
: '正在同步...',
style: theme.textTheme.bodySmall,
),
],
if (sync.lastSummary != null) ...[
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 4,
children: [
_summaryChip(theme, '上传', sync.lastSummary!.uploaded),
_summaryChip(theme, '下载', sync.lastSummary!.downloaded),
_summaryChip(theme, '冲突', sync.lastSummary!.conflicts),
_summaryChip(theme, '失败', sync.lastSummary!.failed),
_summaryChip(theme, '重命名', sync.lastSummary!.renamed),
_summaryChip(theme, '移动', sync.lastSummary!.moved),
],
),
],
const SizedBox(height: 12),
Row(
children: [
if (!sync.isActive && !sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => _navigateToSettings(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('开始同步'),
),
),
if (sync.isActive)
Expanded(
child: OutlinedButton.icon(
onPressed: () => sync.pause(),
icon: const Icon(Icons.pause, size: 18),
label: const Text('暂停'),
),
),
if (sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => sync.resume(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('恢复'),
),
),
if (sync.isActive || sync.isPaused) ...[
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _stopSync(sync),
icon: const Icon(Icons.stop, size: 18),
label: const Text('停止'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
),
],
],
);
},
),
),
const SizedBox(height: 8),
_buildActiveTasksSection(sync, theme),
const SizedBox(height: 8),
_buildCompletedTasksSection(sync, theme),
const SizedBox(height: 32),
],
),
),
);
}
Widget _summaryChip(ThemeData theme, String label, int value) {
void _navigateToSettings() {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage()));
}
Widget _buildStatusHeaderCard(SyncProvider sync, ThemeData theme) {
final isActive = sync.isActive;
final isPaused = sync.isPaused;
final hasError = sync.hasError;
Color statusColor;
String statusText;
if (isActive) {
statusColor = theme.colorScheme.primary;
statusText = _syncModeLabel(sync);
} else if (isPaused) {
statusColor = Colors.orange;
statusText = '已暂停';
} else if (hasError) {
statusColor = theme.colorScheme.error;
statusText = '同步错误';
} else if (sync.state == SyncState.stopped) {
statusColor = theme.disabledColor;
statusText = '已停止';
} else {
statusColor = theme.disabledColor;
statusText = '未启动';
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(24),
),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
statusText,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: statusColor,
),
),
),
if (sync.errorMessage != null) ...[
const SizedBox(height: 4),
Center(
child: Text(
sync.errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 96,
height: 96,
child: Stack(
alignment: Alignment.center,
children: [
if (isActive)
SizedBox(
width: 96,
height: 96,
child: CircularProgressIndicator(
value: sync.activeTotalCount > 0
? sync.activeProgress
: null,
strokeWidth: 6,
strokeCap: StrokeCap.round,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
statusColor,
),
),
)
else
SizedBox(
width: 96,
height: 96,
child: CircularProgressIndicator(
value: 0,
strokeWidth: 6,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
statusColor,
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isActive && sync.activeTotalCount > 0)
Text(
'${(sync.activeProgress * 100).toInt()}%',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: statusColor,
),
),
Text(
isActive
? (sync.state == SyncState.continuous
? '持续同步'
: '同步中')
: isPaused
? '已暂停'
: hasError
? '错误'
: '未启动',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 11,
),
),
],
),
],
),
),
const SizedBox(width: 24),
IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatRow(
Icons.file_upload_outlined,
'${sync.cumUploaded}',
'已上传',
Colors.blue,
theme,
),
const SizedBox(height: 8),
_buildStatRow(
Icons.file_download_outlined,
'${sync.cumDownloaded}',
'已下载',
Colors.green,
theme,
),
const SizedBox(height: 8),
_buildStatRow(
Icons.warning_amber_outlined,
'${sync.cumConflicts}',
'冲突',
Colors.orange,
theme,
),
],
),
),
],
),
if (isActive && sync.currentFile != null) ...[
const SizedBox(height: 12),
Text(
sync.currentFile!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
if (isActive && sync.activeTotalCount > 0) ...[
const SizedBox(height: 8),
Text(
'${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
const SizedBox(height: 16),
Row(
children: [
if (!sync.isActive && !sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => _navigateToSettings(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('开始同步'),
),
),
if (sync.isActive)
Expanded(
child: OutlinedButton.icon(
onPressed: () => sync.pause(),
icon: const Icon(Icons.pause, size: 18),
label: const Text('暂停'),
),
),
if (sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => sync.resume(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('恢复'),
),
),
if (sync.isActive || sync.isPaused) ...[
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _stopSync(sync),
icon: const Icon(Icons.stop, size: 18),
label: const Text('停止'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
),
],
],
),
],
),
);
}
Widget _buildStatRow(
IconData icon,
String value,
String label,
Color color,
ThemeData theme,
) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$label:', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
Text(' $value', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)),
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
],
);
}
@@ -262,9 +400,13 @@ class _SyncPageState extends State<SyncPage> {
Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) {
final tasks = sync.recentTasks
.where((t) =>
(t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') &&
t.totalCount > 0)
.where(
(t) =>
(t.status == 'completed' ||
t.status == 'failed' ||
t.status == 'cancelled') &&
t.totalCount > 0,
)
.toList();
return _buildTaskSection(
@@ -308,7 +450,10 @@ class _SyncPageState extends State<SyncPage> {
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(emptyText, style: TextStyle(color: theme.hintColor)),
child: Text(
emptyText,
style: TextStyle(color: theme.hintColor),
),
),
),
)
@@ -357,9 +502,15 @@ class _SyncPageState extends State<SyncPage> {
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: _statusColor(task.status, theme).withValues(alpha: 0.1),
color: _statusColor(
task.status,
theme,
).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
@@ -396,8 +547,7 @@ class _SyncPageState extends State<SyncPage> {
),
),
),
if (isExpanded)
_buildTaskDetailList(task, theme),
if (isExpanded) _buildTaskDetailList(task, theme),
],
),
);
@@ -410,7 +560,13 @@ class _SyncPageState extends State<SyncPage> {
if (_loadingDetails.contains(task.id)) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
@@ -430,10 +586,41 @@ class _SyncPageState extends State<SyncPage> {
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6),
child: Row(
children: [
SizedBox(width: 70, child: Text('操作', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
Expanded(child: Text('文件名', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
SizedBox(width: 55, child: Text('状态', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
SizedBox(width: 110, child: Text('时间', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))),
SizedBox(
width: 70,
child: Text(
'操作',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.hintColor,
),
),
),
Expanded(
child: Text(
'文件名',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.hintColor,
),
),
),
SizedBox(
width: 55,
child: Text(
'状态',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.hintColor,
),
),
),
SizedBox(
width: 110,
child: Text(
'时间',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.hintColor,
),
),
),
],
),
),
@@ -463,7 +650,10 @@ class _SyncPageState extends State<SyncPage> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: _actionColor(item.actionType, theme).withValues(alpha: 0.1),
color: _actionColor(
item.actionType,
theme,
).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
@@ -560,7 +750,8 @@ class _SyncPageState extends State<SyncPage> {
setState(() => _expandedTasks.add(taskId));
sync.watchTaskDetail(taskId);
if (sync.getCachedTaskDetail(taskId) == null && !_loadingDetails.contains(taskId)) {
if (sync.getCachedTaskDetail(taskId) == null &&
!_loadingDetails.contains(taskId)) {
setState(() => _loadingDetails.add(taskId));
sync.getTaskDetail(taskId).whenComplete(() {
if (mounted) {
@@ -0,0 +1,768 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../data/models/sync_task_model.dart';
import '../../providers/sync_provider.dart';
import '../../widgets/sync_stats_card.dart';
import 'sync_settings_page.dart';
/// 移动端同步详情页面 - 展示实时同步状态和任务列表
class SyncPageAndroid extends StatefulWidget {
const SyncPageAndroid({super.key});
@override
State<SyncPageAndroid> createState() => _SyncPageAndroidState();
}
class _SyncPageAndroidState extends State<SyncPageAndroid> {
final Set<String> _expandedTasks = {};
final Set<String> _loadingDetails = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SyncProvider>().loadRecentTasks();
});
}
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncProvider>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('文件同步'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => _navigateToSettings(),
tooltip: '同步设置',
),
],
),
body: RefreshIndicator(
onRefresh: () async {
final sync = context.read<SyncProvider>();
sync.invalidateAllTaskDetails();
await sync.loadRecentTasks();
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _buildHeader(sync, theme),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SyncStatsCard(
uploaded: sync.cumUploaded,
downloaded: sync.cumDownloaded,
renamed: sync.cumRenamed,
moved: sync.cumMoved,
conflicts: sync.cumConflicts,
failed: sync.cumFailed,
deletedLocal: sync.cumDeletedLocal,
deletedRemote: sync.cumDeletedRemote,
skipped: sync.cumSkipped,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 12),
child: Row(
children: [
Icon(
Icons.sync_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'同步任务',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
),
_buildTaskList(sync, theme),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
),
),
);
}
void _navigateToSettings() {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage()));
}
Widget _buildHeader(SyncProvider sync, ThemeData theme) {
final isActive = sync.isActive;
final isPaused = sync.isPaused;
final hasError = sync.hasError;
Color statusColor;
String statusText;
if (isActive) {
statusColor = theme.colorScheme.primary;
statusText = _syncModeLabel(sync);
} else if (isPaused) {
statusColor = Colors.orange;
statusText = '已暂停';
} else if (hasError) {
statusColor = theme.colorScheme.error;
statusText = '同步错误';
} else if (sync.state == SyncState.stopped) {
statusColor = theme.disabledColor;
statusText = '已停止';
} else {
statusColor = theme.disabledColor;
statusText = '未启动';
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(24),
),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:状态文字居中
Center(
child: Text(
statusText,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: statusColor,
),
),
),
const SizedBox(height: 16),
// 中间:左侧旋转圆 + 右侧统计
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 左侧:旋转圆形指示器
SizedBox(
width: 96,
height: 96,
child: Stack(
alignment: Alignment.center,
children: [
if (isActive)
SizedBox(
width: 96,
height: 96,
child: CircularProgressIndicator(
value: sync.activeTotalCount > 0
? sync.activeProgress
: null,
strokeWidth: 6,
strokeCap: StrokeCap.round,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
statusColor,
),
),
)
else
SizedBox(
width: 96,
height: 96,
child: CircularProgressIndicator(
value: 0,
strokeWidth: 6,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
statusColor,
),
),
),
// 中心文字
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isActive && sync.activeTotalCount > 0)
Text(
'${(sync.activeProgress * 100).toInt()}%',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: statusColor,
),
),
Text(
isActive
? (sync.state == SyncState.continuous
? '持续同步'
: '同步中')
: isPaused
? '已暂停'
: hasError
? '错误'
: '未启动',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 11,
),
),
],
),
],
),
),
const SizedBox(width: 24),
// 右侧:统计行
IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatRow(
Icons.file_upload_outlined,
'${sync.cumUploaded}',
'已上传',
Colors.blue,
theme,
),
const SizedBox(height: 8),
_buildStatRow(
Icons.file_download_outlined,
'${sync.cumDownloaded}',
'已下载',
Colors.green,
theme,
),
const SizedBox(height: 8),
_buildStatRow(
Icons.warning_amber_outlined,
'${sync.cumConflicts}',
'冲突',
Colors.orange,
theme,
),
],
),
),
],
),
if (isActive && sync.currentFile != null) ...[
const SizedBox(height: 12),
Text(
sync.currentFile!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
if (isActive && sync.activeTotalCount > 0) ...[
const SizedBox(height: 8),
Text(
'${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
const SizedBox(height: 16),
// 操作按钮
Row(
children: [
if (!sync.isActive && !sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => _navigateToSettings(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('开始同步'),
),
),
if (sync.isActive)
Expanded(
child: OutlinedButton.icon(
onPressed: () => sync.pause(),
icon: const Icon(Icons.pause, size: 18),
label: const Text('暂停'),
),
),
if (sync.isPaused)
Expanded(
child: FilledButton.icon(
onPressed: () => sync.resume(),
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('恢复'),
),
),
if (sync.isActive || isPaused) ...[
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _stopSync(sync),
icon: const Icon(Icons.stop, size: 18),
label: const Text('停止'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
),
),
],
],
),
],
),
);
}
Widget _buildStatRow(
IconData icon,
String value,
String label,
Color color,
ThemeData theme,
) {
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
],
);
}
Widget _buildTaskList(SyncProvider sync, ThemeData theme) {
// 活跃任务 + 已完成任务
final activeTasks = sync.activeTasks;
final completedTasks = sync.recentTasks
.where(
(t) =>
(t.status == 'completed' ||
t.status == 'failed' ||
t.status == 'cancelled') &&
t.totalCount > 0,
)
.toList();
final allTasks = [...activeTasks, ...completedTasks];
if (allTasks.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, size: 48, color: theme.hintColor),
const SizedBox(height: 12),
Text('暂无同步任务', style: TextStyle(color: theme.hintColor)),
],
),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTaskCard(allTasks[index], sync, theme),
childCount: allTasks.length,
),
),
);
}
Widget _buildTaskCard(
SyncTaskModel task,
SyncProvider sync,
ThemeData theme,
) {
final isExpanded = _expandedTasks.contains(task.id);
final isRunning = task.status == 'running';
final isFailed = task.status == 'failed';
final isCompleted = task.status == 'completed';
Color statusColor = switch (task.status) {
'running' => theme.colorScheme.primary,
'completed' => Colors.green,
'failed' => theme.colorScheme.error,
'cancelled' => theme.hintColor,
_ => theme.hintColor,
};
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
InkWell(
onTap: () => _toggleTaskExpand(task.id),
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 状态图标
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
isRunning
? Icons.sync
: isFailed
? Icons.error_outline
: isCompleted
? Icons.check_circle_outline
: Icons.cloud_off,
color: statusColor,
size: 20,
),
),
const SizedBox(width: 14),
// 任务信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
task.statusLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Text(
task.triggerLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
const SizedBox(height: 8),
// 进度条
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: isRunning && task.totalCount > 0
? task.progress
: (isCompleted ? 1.0 : null),
minHeight: 4,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
statusColor,
),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${task.completedCount}/${task.totalCount}',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11,
),
),
if (task.failedCount > 0)
Text(
'失败${task.failedCount}',
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11,
color: theme.colorScheme.error,
),
),
],
),
],
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 20,
color: theme.hintColor,
),
],
),
),
),
if (isExpanded) _buildTaskDetailList(task, sync, theme),
],
),
);
}
Widget _buildTaskDetailList(
SyncTaskModel task,
SyncProvider sync,
ThemeData theme,
) {
final items = sync.getCachedTaskDetail(task.id);
if (_loadingDetails.contains(task.id)) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (items == null || items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)),
);
}
final hasMore = sync.hasMoreTaskDetail(task.id);
return Column(
children: [
Divider(
height: 1,
indent: 16,
endIndent: 16,
color: theme.dividerColor,
),
...items.map((item) => _buildTaskItemTile(item, theme)),
if (hasMore)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextButton.icon(
onPressed: () => _loadMoreDetail(task.id),
icon: const Icon(Icons.expand_more, size: 16),
label: const Text('加载更多'),
),
),
const SizedBox(height: 4),
],
);
}
Widget _buildTaskItemTile(SyncTaskItemModel item, ThemeData theme) {
Color actionColor = switch (item.actionType) {
'upload' => Colors.blue,
'download' => Colors.green,
'create_placeholder' => Colors.teal,
'delete_local' || 'delete_remote' => theme.colorScheme.error,
'rename' || 'move' => Colors.orange,
'conflict_resolve' => Colors.purple,
_ => theme.colorScheme.primary,
};
Color itemStatusColor = switch (item.status) {
'completed' => Colors.green,
'failed' => theme.colorScheme.error,
'running' => theme.colorScheme.primary,
_ => theme.hintColor,
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
// 操作类型图标
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
item.actionType == 'upload'
? Icons.file_upload_outlined
: item.actionType == 'download'
? Icons.file_download_outlined
: item.actionType == 'delete_local' ||
item.actionType == 'delete_remote'
? Icons.delete_outline
: item.actionType == 'rename'
? Icons.edit_outlined
: item.actionType == 'move'
? Icons.drive_file_move_outline
: Icons.sync_outlined,
color: actionColor,
size: 14,
),
),
const SizedBox(width: 10),
// 文件名 + 状态
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.filename,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (item.errorMessage != null)
Text(
item.errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 10,
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// 状态标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: itemStatusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.statusLabel,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 10,
color: itemStatusColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
String _syncModeLabel(SyncProvider sync) {
final mode = sync.persistedConfig?.syncMode ?? 'full';
return switch (mode) {
'full' => '全量同步中',
'upload_only' => '仅上传中',
'download_only' => '仅下载中',
'album_upload' => '相册上传中',
'album_download' => '相册下载中',
'mirror_wcf' => '镜像同步中',
_ => '同步中',
};
}
void _toggleTaskExpand(String taskId) {
final sync = context.read<SyncProvider>();
if (_expandedTasks.contains(taskId)) {
setState(() => _expandedTasks.remove(taskId));
sync.unwatchTaskDetail(taskId);
return;
}
setState(() => _expandedTasks.add(taskId));
sync.watchTaskDetail(taskId);
if (sync.getCachedTaskDetail(taskId) == null &&
!_loadingDetails.contains(taskId)) {
setState(() => _loadingDetails.add(taskId));
sync.getTaskDetail(taskId).whenComplete(() {
if (mounted) {
setState(() => _loadingDetails.remove(taskId));
}
});
}
}
void _loadMoreDetail(String taskId) {
context.read<SyncProvider>().loadMoreTaskDetail(taskId);
}
Future<void> _stopSync(SyncProvider sync) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('停止同步'),
content: const Text('确定要停止文件同步吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('停止'),
),
],
),
);
if (confirmed == true) {
await sync.stop();
}
}
}
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:external_path/external_path.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:open_file/open_file.dart';
@@ -10,8 +11,11 @@ import '../../../core/utils/app_logger.dart';
import '../../../data/models/sync_config_model.dart';
import '../../providers/auth_provider.dart';
import '../../providers/sync_provider.dart';
import '../../../services/sync_service.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../widgets/desktop_constrained.dart';
import '../../widgets/folder_picker.dart';
import '../../widgets/sync_stats_card.dart';
import '../../widgets/toast_helper.dart';
import 'sync_log_viewer_page.dart';
@@ -42,6 +46,13 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_localRootController = TextEditingController(
text: SyncDefaults.defaultLocalRoot(),
);
if (Platform.isAndroid) {
_syncMode = SyncDefaults.defaultAndroidSyncMode;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
SyncDefaults.getDefaultAndroidLocalRoot().then((path) {
if (mounted) setState(() => _localRootController.text = path);
});
}
AppLogger.i('默认同步目录: ${_localRootController.text}');
@@ -60,6 +71,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_maxWorkers = config.maxWorkers;
_logLevel = config.logLevel;
});
_applyAlbumPaths();
}
_loadSyncLogInfo();
});
@@ -109,10 +121,16 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
children: [
if (sync.isActive || sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'同步运行中,无法切换模式。请先停止同步再修改。',
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
RadioGroup<String>(
@@ -124,7 +142,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
},
child: Column(
children: [
if (Platform.isWindows)
if (Platform.isWindows || Platform.isLinux)
RadioListTile<String>(
title: Row(
mainAxisSize: MainAxisSize.min,
@@ -132,9 +150,13 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
const Text('镜像同步'),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
color: Theme.of(context).colorScheme.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
@@ -142,7 +164,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
color: Theme.of(
context,
).colorScheme.primary,
),
),
),
@@ -182,7 +206,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
? const Text('同步运行中,无法修改')
: Text(_conflictStrategyLabel(_conflictStrategy)),
trailing: const Icon(Icons.chevron_right),
onTap: (sync.isActive || sync.isPaused) ? null : () => _pickConflictStrategy(),
onTap: (sync.isActive || sync.isPaused)
? null
: () => _pickConflictStrategy(),
),
],
),
@@ -197,7 +223,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
? const Text('同步运行中,无法修改')
: Text(_wcfDeleteModeLabel(_wcfDeleteMode)),
trailing: const Icon(Icons.chevron_right),
onTap: (sync.isActive || sync.isPaused) ? null : () => _pickWcfDeleteMode(),
onTap: (sync.isActive || sync.isPaused)
? null
: () => _pickWcfDeleteMode(),
),
],
),
@@ -206,14 +234,41 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_buildSection(
title: '相册同步',
children: [
SwitchListTile(
title: const Text('自动备份相册'),
subtitle: const Text('将手机照片自动备份到云端'),
value: _syncMode == 'album',
onChanged: (v) {
setState(() => _syncMode = v ? 'album' : 'full');
_pushConfig();
},
if (sync.isActive || sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'同步运行中,无法切换模式。请先停止同步再修改。',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
RadioGroup<String>(
groupValue: _syncMode,
onChanged: (sync.isActive || sync.isPaused)
? (_) {}
: (v) {
if (v != null) _handleAlbumModeChange(v);
},
child: Column(
children: [
RadioListTile<String>(
title: const Text('仅上传'),
subtitle: const Text('备份手机照片到云端'),
value: 'album_upload',
),
RadioListTile<String>(
title: const Text('仅下载'),
subtitle: const Text('从云端下载照片到手机'),
value: 'album_download',
),
],
),
),
],
),
@@ -253,7 +308,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
children: [
if (!sync.isActive && !sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -265,7 +323,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
if (sync.isActive || sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
if (sync.isActive)
@@ -291,7 +352,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
icon: const Icon(Icons.stop),
label: const Text('停止'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(
context,
).colorScheme.error,
),
),
),
@@ -300,7 +363,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
if (sync.isActive)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
@@ -312,7 +378,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
if (sync.engineInitialized)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
@@ -394,7 +463,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (newMode == 'full') {
final confirmed = await _showModeConfirmDialog(
title: '全量同步',
description: '此模式下:\n\n'
description:
'此模式下:\n\n'
'• 本地和远程双向同步所有文件\n'
'• 本地新增、修改的文件将上传到远程\n'
'• 远程新增、修改的文件将下载到本地\n'
@@ -407,7 +477,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'upload_only') {
final confirmed = await _showModeConfirmDialog(
title: '仅上传本地到远程',
description: '此模式下:\n\n'
description:
'此模式下:\n\n'
'• 本地新增、修改的文件将上传到远程\n'
'• 本地重命名、移动的文件会在远程同步操作\n'
'• 本地删除的文件不会删除远程副本\n'
@@ -419,7 +490,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'download_only') {
final confirmed = await _showModeConfirmDialog(
title: '仅下载远程到本地',
description: '此模式下:\n\n'
description:
'此模式下:\n\n'
'• 远程新增、修改的文件将下载到本地\n'
'• 远程删除的文件将同步删除本地副本\n'
'• 远程重命名、移动会在本地同步\n'
@@ -432,7 +504,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'mirror_wcf') {
final confirmed = await _showModeConfirmDialog(
title: '镜像同步',
description: '此模式下(仅 Windows):\n\n'
description:
'此模式下(仅 Windows | Linux):\n\n'
'• 远程文件以占位符形式出现在本地\n'
'• 占位符不占用磁盘空间,但在资源管理器中可见\n'
'• 打开文件时自动从云端下载(水合)\n'
@@ -447,6 +520,27 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
setState(() => _syncMode = newMode);
}
void _handleAlbumModeChange(String mode) {
setState(() {
_syncMode = mode;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
});
SyncDefaults.getDefaultAndroidLocalRoot().then((path) {
if (mounted) setState(() => _localRootController.text = path);
});
_pushConfig();
}
/// 当前模式为相册模式时,自动设置写死路径
Future<void> _applyAlbumPaths() async {
if (!Platform.isAndroid) return;
if (_syncMode == 'album_upload' || _syncMode == 'album_download') {
final path = await SyncDefaults.getDefaultAndroidLocalRoot();
_localRootController.text = path;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
}
}
Future<bool?> _showModeConfirmDialog({
required String title,
required String description,
@@ -475,10 +569,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
title: '同步状态',
children: [
ListTile(
leading: Icon(
_stateIcon(sync),
color: _stateColor(sync),
),
leading: Icon(_stateIcon(sync), color: _stateColor(sync)),
title: Text(_stateLabel(sync)),
subtitle: sync.hasError && sync.errorMessage != null
? Text(
@@ -502,8 +593,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
sync.totalFiles > 0
? '${sync.syncedFiles} / ${sync.totalFiles} 文件'
: sync.state == SyncState.continuous
? '持续同步中'
: '正在同步...',
? '持续同步中'
: '正在同步...',
style: Theme.of(context).textTheme.bodySmall,
),
),
@@ -518,21 +609,21 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
),
],
if (sync.lastSummary != null) ...[
// 实时累积统计卡片
if (sync.engineInitialized) ...[
const Divider(indent: 16, endIndent: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 16,
children: [
_summaryChip('上传', sync.lastSummary!.uploaded),
_summaryChip('下载', sync.lastSummary!.downloaded),
_summaryChip('冲突', sync.lastSummary!.conflicts),
_summaryChip('失败', sync.lastSummary!.failed),
_summaryChip('跳过', sync.lastSummary!.skipped),
_summaryChip('删本地', sync.lastSummary!.deletedLocal),
_summaryChip('删远程', sync.lastSummary!.deletedRemote),
],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SyncStatsCard(
uploaded: sync.cumUploaded,
downloaded: sync.cumDownloaded,
renamed: sync.cumRenamed,
moved: sync.cumMoved,
conflicts: sync.cumConflicts,
failed: sync.cumFailed,
deletedLocal: sync.cumDeletedLocal,
deletedRemote: sync.cumDeletedRemote,
skipped: sync.cumSkipped,
),
),
],
@@ -540,15 +631,6 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
);
}
Widget _summaryChip(String label, int value) {
return Chip(
label: Text('$label: $value'),
labelStyle: Theme.of(context).textTheme.bodySmall,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}
IconData _stateIcon(SyncProvider sync) {
if (sync.isActive) return Icons.sync;
if (sync.isPaused) return Icons.pause_circle_outline;
@@ -797,9 +879,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
const SizedBox(height: 8),
Text(
'0 表示自动等于 CPU 核心数\n最大不超过 CPU 核心数的 2 倍,超出无效',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).hintColor,
),
style: Theme.of(
ctx,
).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor),
),
],
),
@@ -871,6 +953,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (config == null) return;
final updated = config.copyWith(
localRoot: _localRootController.text,
remoteRoot: _remoteRoot,
syncMode: _syncMode,
conflictStrategy: _conflictStrategy,
wcfDeleteMode: _wcfDeleteMode,
@@ -890,6 +974,67 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
return;
}
// Android 相册模式:先申请对应权限
if (Platform.isAndroid && _syncMode == 'album_upload') {
final statuses = await [Permission.photos, Permission.videos].request();
if (!statuses[Permission.photos]!.isGranted ||
!statuses[Permission.videos]!.isGranted) {
if (mounted) {
ToastHelper.failure('需要相册和视频权限才能同步');
final shouldOpen = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('权限不足'),
content: const Text('相册同步需要访问照片和视频的权限,请在系统设置中开启。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('去设置'),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
return;
}
} else if (Platform.isAndroid && _syncMode == 'album_download') {
// 仅下载模式需要写入 Camera 目录,必须拥有所有文件管理权限
final status = await Permission.manageExternalStorage.request();
if (!status.isGranted) {
if (mounted) {
ToastHelper.failure('需要所有文件管理权限才能写入相册');
final shouldOpen = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('权限不足'),
content: const Text('下载照片到手机相册需要"所有文件管理权限",请在系统设置中开启。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('去设置'),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
return;
}
}
final appSupportDir = await getApplicationSupportDirectory();
final config = SyncConfigModel(
@@ -909,7 +1054,23 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
logLevel: _logLevel,
);
await sync.startSync(config);
// Album 模式:先初始化引擎,再确保远程相册目录存在
if (_syncMode == 'album_upload' || _syncMode == 'album_download') {
await sync.startSync(config);
try {
final result = await SyncService.instance.checkCloudAlbumDirs(
'cloudreve://my',
);
if (!(result['cameraExists'] as bool? ?? false)) {
AppLogger.i('远程 DCIM/Camera 目录不完整,正在创建...');
await SyncService.instance.createCloudAlbumDirs('cloudreve://my');
}
} catch (e) {
AppLogger.w('检查/创建远程相册目录失败: $e');
}
} else {
await sync.startSync(config);
}
}
Future<void> _stopSync(SyncProvider sync) async {
@@ -940,17 +1101,23 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
}
Future<void> _resetSync(SyncProvider sync) async {
final isAndroid = Platform.isAndroid;
final description = isAndroid
? '此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n\n'
'本地文件不会被删除。重置后需重新点击"开始同步"。'
: '此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n'
'• 删除本地同步目录中的所有文件(不影响远程)\n\n'
'重置后需重新点击"开始同步"。此操作不可恢复。';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('重置同步'),
content: const Text(
'此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n'
'• 删除本地同步目录中的所有文件(不影响远程)\n\n'
'重置后需重新点击"开始同步"。此操作不可恢复。',
),
content: Text(description),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -968,7 +1135,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
);
if (confirmed == true) {
await sync.resetSync();
await sync.resetSync(deleteLocalFiles: !Platform.isAndroid);
if (mounted) ToastHelper.success('同步已重置');
}
}
@@ -1085,13 +1252,25 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (mounted) ToastHelper.error('日志文件不存在');
return;
}
final downloadDir = await getDownloadsDirectory();
if (downloadDir == null) {
if (mounted) ToastHelper.error('无法获取下载目录');
return;
final String downloadPath;
if (Platform.isAndroid) {
downloadPath = await ExternalPath.getExternalStoragePublicDirectory(
ExternalPath.DIRECTORY_DOWNLOAD,
);
} else {
final downloadDir = await getDownloadsDirectory();
if (downloadDir == null) {
if (mounted) ToastHelper.error('无法获取下载目录');
return;
}
downloadPath = downloadDir.path;
}
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').substring(0, 19);
final destPath = '${downloadDir.path}${Platform.pathSeparator}sync_core_log_$timestamp.txt';
final timestamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.substring(0, 19);
final destPath =
'$downloadPath${Platform.pathSeparator}sync_core_log_$timestamp.txt';
await srcFile.copy(destPath);
if (mounted) ToastHelper.success('日志已导出到:$destPath');
} catch (e) {
@@ -1100,11 +1279,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
}
Future<void> _previewSyncLog() async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SyncLogViewerPage(),
),
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SyncLogViewerPage()));
}
Future<void> _clearSyncLog() async {
@@ -1120,7 +1297,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('清空'),
),
],