主要合入内容:

- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、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
+36 -5
View File
@@ -4,7 +4,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/file_utils.dart';
/// 面包屑导航组件
class FileBreadcrumb extends StatelessWidget {
class FileBreadcrumb extends StatefulWidget {
final String currentPath;
final void Function(String path) onPathTap;
@@ -14,9 +14,38 @@ class FileBreadcrumb extends StatelessWidget {
required this.onPathTap,
});
@override
State<FileBreadcrumb> createState() => _FileBreadcrumbState();
}
class _FileBreadcrumbState extends State<FileBreadcrumb> {
final _controller = ScrollController();
@override
void didUpdateWidget(covariant FileBreadcrumb oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentPath != widget.currentPath) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_controller.hasClients) {
_controller.animateTo(
_controller.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pathParts = currentPath.split('/');
final pathParts = widget.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -34,6 +63,7 @@ class FileBreadcrumb extends StatelessWidget {
),
),
child: SingleChildScrollView(
controller: _controller,
scrollDirection: Axis.horizontal,
child: Row(
children: [
@@ -43,7 +73,7 @@ class FileBreadcrumb extends StatelessWidget {
path: '/',
icon: LucideIcons.home,
primaryColor: colorScheme.primary,
onTap: () => onPathTap('/'),
onTap: () => widget.onPathTap('/'),
),
for (int i = 0; i < pathParts.length; i++) ...[
Padding(
@@ -60,8 +90,9 @@ class FileBreadcrumb extends StatelessWidget {
path: '/${pathParts.sublist(0, i + 1).join('/')}',
icon: null,
primaryColor: colorScheme.primary,
onTap: () =>
onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
onTap: () => widget.onPathTap(
'/${pathParts.sublist(0, i + 1).join('/')}',
),
),
],
],
+81 -10
View File
@@ -1,18 +1,23 @@
import 'package:flutter/material.dart';
/// 文件列表表头(桌面端)
import '../../core/constants/sort_options.dart';
/// 文件列表表头(桌面端),支持点击排序
class FileListHeader extends StatelessWidget {
final bool showCheckbox;
const FileListHeader({super.key, this.showCheckbox = false});
final SortOption? currentSort;
final ValueChanged<SortOption>? onSort;
const FileListHeader({
super.key,
this.showCheckbox = false,
this.currentSort,
this.onSort,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = TextStyle(
color: theme.hintColor,
fontSize: 12,
fontWeight: FontWeight.w500,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -26,11 +31,77 @@ class FileListHeader extends StatelessWidget {
if (showCheckbox) const SizedBox(width: 40),
// 图标占位
const SizedBox(width: 36 + 16),
Expanded(flex: 5, child: Text('名称', style: style)),
Expanded(flex: 2, child: Text('修改日期', style: style)),
Expanded(flex: 1, child: Text('大小', style: style)),
Expanded(
flex: 5,
child: _buildSortHeader(context, theme, SortField.name, '名称'),
),
Expanded(
flex: 2,
child: _buildSortHeader(
context,
theme,
SortField.updatedAt,
'修改日期',
),
),
Expanded(
flex: 1,
child: _buildSortHeader(context, theme, SortField.size, '大小'),
),
],
),
);
}
Widget _buildSortHeader(
BuildContext context,
ThemeData theme,
SortField field,
String label,
) {
final isActive = currentSort?.field == field;
final style = TextStyle(
color: isActive ? theme.colorScheme.primary : theme.hintColor,
fontSize: 12,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
);
return InkWell(
onTap: () => _onHeaderTap(field),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: style),
if (isActive) ...[
const SizedBox(width: 4),
Icon(
currentSort!.direction == SortDirection.asc
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: theme.colorScheme.primary,
),
],
],
),
),
);
}
void _onHeaderTap(SortField field) {
if (onSort == null) return;
if (currentSort?.field == field) {
// 同一列:切换方向
final newDir = currentSort!.direction == SortDirection.asc
? SortDirection.desc
: SortDirection.asc;
onSort!(SortOption(field, newDir));
} else {
// 新列:默认升序
onSort!(SortOption(field, SortDirection.asc));
}
}
}
+41 -20
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
@@ -18,7 +19,12 @@ import 'glassmorphism_container.dart';
class SearchDialog extends StatefulWidget {
const SearchDialog({super.key});
static bool _isShowing = false;
static bool get isShowing => _isShowing;
static Future<void> show(BuildContext context) {
if (_isShowing) return Future.value();
_isShowing = true;
return showGeneralDialog(
context: context,
barrierDismissible: true,
@@ -41,7 +47,7 @@ class SearchDialog extends StatefulWidget {
},
pageBuilder: (context, animation, secondaryAnimation) =>
const SearchDialog(),
);
).whenComplete(() => _isShowing = false);
}
@override
@@ -185,25 +191,40 @@ class _SearchDialogState extends State<SearchDialog> {
final isWide = screenWidth >= 600;
final dialogWidth = isWide ? 560.0 : screenWidth - 32.0;
return Center(
child: Container(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSearchInput(context),
const Divider(height: 1),
Flexible(child: _buildBody(context)),
],
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (_) {
Navigator.of(context).pop();
return null;
},
),
},
child: Center(
child: Container(
width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSearchInput(context),
const Divider(height: 1),
Flexible(child: _buildBody(context)),
],
),
),
),
),
),
@@ -0,0 +1,363 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
/// 同步统计卡片
/// 宽屏(>=400):左饼图 + 右双列明细(左5右4)
/// 窄屏(<400):上饼图+5指标,下4指标
class SyncStatsCard extends StatelessWidget {
final int uploaded;
final int downloaded;
final int renamed;
final int moved;
final int conflicts;
final int failed;
final int deletedLocal;
final int deletedRemote;
final int skipped;
const SyncStatsCard({
super.key,
required this.uploaded,
required this.downloaded,
required this.renamed,
required this.moved,
required this.conflicts,
required this.failed,
required this.deletedLocal,
required this.deletedRemote,
required this.skipped,
});
static const _colorUploaded = Color(0xFF8E5D67);
static const _colorDownloaded = Color(0xFF8BA7DA);
static const _colorRenamed = Color(0xFFD4AF37);
static const _colorMoved = Color(0xFF67B5B1);
static const _colorConflicts = Color(0xFFE69A6A);
static const _colorFailed = Color(0xFFD9534F);
static const _colorDeletedLocal = Color(0xFFE57373);
static const _colorDeletedRemote = Color(0xFFEF9A9A);
static const _colorSkipped = Color(0xFFB0BEC5);
static const _narrowThreshold = 400.0;
int get _total =>
uploaded +
downloaded +
renamed +
moved +
conflicts +
failed +
deletedLocal +
deletedRemote +
skipped;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final 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),
);
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= _narrowThreshold;
if (isWide) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: decoration,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
'同步数据概览',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 16),
Row(
children: [
// 饼图
Expanded(
flex: 4,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
height: 160,
child: PieChart(
PieChartData(
sectionsSpace: 4,
centerSpaceRadius: 42,
sections: _buildSections(),
),
),
),
Text(
'$_total',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(width: 16),
// 双列明细
Expanded(
flex: 6,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 左列 5 项
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRow(
'上传',
uploaded,
_colorUploaded,
theme,
),
_buildRow(
'下载',
downloaded,
_colorDownloaded,
theme,
),
_buildRow('移动', moved, _colorMoved, theme),
_buildRow(
'冲突',
conflicts,
_colorConflicts,
theme,
),
_buildRow('失败', failed, _colorFailed, theme),
],
),
),
const SizedBox(width: 12),
// 右列 4 项
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRow('重命名', renamed, _colorRenamed, theme),
_buildRow(
'删本地',
deletedLocal,
_colorDeletedLocal,
theme,
),
_buildRow(
'删远程',
deletedRemote,
_colorDeletedRemote,
theme,
),
_buildRow('跳过', skipped, _colorSkipped, theme),
],
),
),
],
),
),
],
),
],
),
);
}
// 窄屏:上下布局
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
decoration: decoration,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
'同步数据概览',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 12),
// 上半区:饼图 + 5 个主指标
Row(
children: [
SizedBox(
width: 120,
height: 120,
child: Stack(
alignment: Alignment.center,
children: [
PieChart(
PieChartData(
sectionsSpace: 3,
centerSpaceRadius: 32,
sections: _buildSections(),
),
),
Text(
'$_total',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRow('上传', uploaded, _colorUploaded, theme),
_buildRow('下载', downloaded, _colorDownloaded, theme),
_buildRow('移动', moved, _colorMoved, theme),
_buildRow('冲突', conflicts, _colorConflicts, theme),
_buildRow('失败', failed, _colorFailed, theme),
],
),
),
],
),
const SizedBox(height: 12),
// 下半区:4 个副指标
Row(
children: [
_buildSubItem('重命名', renamed, _colorRenamed, theme),
const SizedBox(width: 16),
_buildSubItem('删本地', deletedLocal, _colorDeletedLocal, theme),
const SizedBox(width: 16),
_buildSubItem(
'删远程',
deletedRemote,
_colorDeletedRemote,
theme,
),
const SizedBox(width: 16),
_buildSubItem('跳过', skipped, _colorSkipped, theme),
],
),
],
),
);
},
);
}
/// 指标行:圆点 + 固定宽标签 + 数字,保证数字对齐
Widget _buildRow(String label, int count, Color color, ThemeData theme) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3.5),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
SizedBox(
width: 40,
child: Text(
label,
style: TextStyle(fontSize: 12, color: theme.hintColor),
),
),
Text(
'$count',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
);
}
/// 窄屏副指标:圆点 + 标签 + 数字(紧凑,Expanded 均分)
Widget _buildSubItem(String label, int count, Color color, ThemeData theme) {
return Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: TextStyle(fontSize: 11, color: theme.hintColor),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 3),
Text(
'$count',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
],
),
);
}
List<PieChartSectionData> _buildSections() {
if (_total == 0) {
return [
PieChartSectionData(
value: 1,
color: const Color(0xFFE0E0E0),
radius: 14,
showTitle: false,
),
];
}
final entries = [
(uploaded, _colorUploaded, 18.0),
(downloaded, _colorDownloaded, 16.0),
(renamed, _colorRenamed, 14.0),
(moved, _colorMoved, 12.0),
(conflicts, _colorConflicts, 10.0),
(failed, _colorFailed, 8.0),
(deletedLocal, _colorDeletedLocal, 8.0),
(deletedRemote, _colorDeletedRemote, 6.0),
(skipped, _colorSkipped, 6.0),
];
return entries
.where((e) => e.$1 > 0)
.map(
(e) => PieChartSectionData(
value: e.$1.toDouble(),
color: e.$2,
radius: e.$3,
showTitle: false,
),
)
.toList();
}
}