主要合入内容:
- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、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:
@@ -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('清空'),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user