1.3.21.3.2
Android APK Release / Build Android APK (push) Successful in 55m29s

This commit is contained in:
2026-05-26 16:32:18 +08:00
parent 546cef5ba6
commit 17673b2862
89 changed files with 24098 additions and 272 deletions
+66 -12
View File
@@ -179,14 +179,7 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.currentPath == '/') {
return const Text('文件');
}
final segments = FileUtils.toRelativePath(
fileManager.currentPath,
).split('/').where((s) => s.isNotEmpty).toList();
return Text(
segments.isNotEmpty
? FileUtils.decodePathSegment(segments.last)
: '文件',
);
return _buildDesktopBreadcrumb(context, fileManager);
}
return _buildMobileBreadcrumb(context, fileManager);
},
@@ -195,15 +188,76 @@ class _FilesPageState extends State<FilesPage> {
);
}
String _decodePathSegment(String segment) {
return FileUtils.safeDecodePathSegment(segment);
}
Widget _buildDesktopBreadcrumb(
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 = FileUtils.toRelativePath(
fileManager.currentPath,
).split('/');
final pathParts = fileManager.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
return SizedBox(
@@ -231,7 +285,7 @@ class _FilesPageState extends State<FilesPage> {
),
_buildBreadcrumbChip(
context,
label: FileUtils.decodePathSegment(pathParts[i]),
label: _decodePathSegment(pathParts[i]),
icon: null,
color: colorScheme.primary,
isLast: i == pathParts.length - 1,
@@ -71,6 +71,7 @@ class QuickAccessGrid extends StatelessWidget {
child: _QuickAccessButton(
item: items[index],
onTap: () => _onTap(context, items[index]),
fillHeight: fillHeight,
),
),
);
@@ -150,8 +151,13 @@ class QuickAccessGrid extends StatelessWidget {
class _QuickAccessButton extends StatelessWidget {
final QuickAccessConfig item;
final VoidCallback onTap;
final bool fillHeight;
const _QuickAccessButton({required this.item, required this.onTap});
const _QuickAccessButton({
required this.item,
required this.onTap,
this.fillHeight = false,
});
@override
Widget build(BuildContext context) {
@@ -179,24 +185,27 @@ class _QuickAccessButton extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, color: foreground, size: 22),
const SizedBox(width: 9),
Flexible(
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontWeight: FontWeight.w800,
fontSize: 14,
child: SizedBox(
height: fillHeight ? double.infinity : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, color: foreground, size: 22),
const SizedBox(width: 9),
Flexible(
child: Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: foreground,
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
),
),
],
],
),
),
),
),
@@ -1,28 +1,67 @@
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
import 'package:cloudreve4_flutter/router/app_router.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
class _QuickFunction {
final IconData icon;
final String label;
final String route;
final String? route;
final void Function(BuildContext context)? onTap;
const _QuickFunction({
required this.icon,
required this.label,
required this.route,
this.route,
this.onTap,
});
}
class QuickFunctionsSection extends StatelessWidget {
const QuickFunctionsSection({super.key});
static const _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.settings, label: '设置', route: RouteNames.settings),
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.refreshCw,
label: '文件同步',
onTap: (ctx) {
final isMobilePlatform =
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
if (isMobilePlatform) {
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
return;
}
ctx.read<NavigationProvider>().setIndex(3);
},
),
_QuickFunction(
icon: LucideIcons.settings,
label: '设置',
route: RouteNames.settings,
),
];
static const double _spacing = 12;
@@ -43,9 +82,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,
),
),
],
),
),
@@ -59,7 +101,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,
@@ -70,7 +113,13 @@ class QuickFunctionsSection extends StatelessWidget {
child: _QuickFunctionCard(
icon: fn.icon,
label: fn.label,
onTap: () => Navigator.of(context).pushNamed(fn.route),
onTap: () {
if (fn.onTap != null) {
fn.onTap!(context);
} else if (fn.route != null) {
Navigator.of(context).pushNamed(fn.route!);
}
},
),
);
}).toList(),
@@ -106,9 +155,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),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:logger/logger.dart' show Level;
import 'package:provider/provider.dart';
import '../../../core/constants/storage_keys.dart';
import '../../../core/utils/app_logger.dart';
@@ -38,6 +39,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
String _logFilePath = '';
int? _logFileSize;
String _cacheDirPath = '';
Level _logLevel = Level.info;
@override
void initState() {
@@ -46,6 +48,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
_loadWifiOnlySetting();
_loadGravatarMirrorSetting();
_loadLogInfo();
_loadLogLevel();
}
Future<void> _loadCacheSettings() async {
@@ -127,6 +130,15 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
}
}
Future<void> _loadLogLevel() async {
final saved = await StorageService.instance.getString(StorageKeys.logLevel);
if (saved != null && mounted) {
setState(() {
_logLevel = _parseLogLevel(saved);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -295,6 +307,13 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
_buildSection(
title: '日志管理',
children: [
ListTile(
leading: const Icon(Icons.tune),
title: const Text('日志级别'),
subtitle: Text(_logLevelLabel(_logLevel)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _pickLogLevel(),
),
ListTile(
title: const Text('日志文件路径'),
subtitle: Text(
@@ -883,4 +902,70 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
if (mounted) ToastHelper.success('日志已清空');
}
}
String _logLevelLabel(Level level) {
return switch (level) {
Level.error => 'Error — 仅错误',
Level.warning => 'Warning — 错误 + 警告',
Level.info => 'Info — 常规信息',
Level.debug => 'Debug — 调试信息(含FFI交互)',
Level.trace => 'Trace — 全量追踪',
_ => level.name,
};
}
Level _parseLogLevel(String level) {
return switch (level) {
'error' => Level.error,
'warning' => Level.warning,
'info' => Level.info,
'debug' => Level.debug,
'trace' => Level.trace,
_ => Level.info,
};
}
Future<void> _pickLogLevel() async {
final levels = [
(Level.error, 'Error — 仅错误'),
(Level.warning, 'Warning — 错误 + 警告'),
(Level.info, 'Info — 常规信息'),
(Level.debug, 'Debug — 调试信息(含FFI交互)'),
(Level.trace, 'Trace — 全量追踪'),
];
final result = await showDialog<Level>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('日志级别'),
children: levels.map((e) {
final isSelected = _logLevel == e.$1;
return SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e.$1),
child: Row(
children: [
Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 20,
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
),
const SizedBox(width: 8),
Text(e.$2),
],
),
);
}).toList(),
),
);
if (result != null && result != _logLevel) {
setState(() => _logLevel = result);
AppLogger.setLevel(result);
await StorageService.instance.setString(
StorageKeys.logLevel,
result.name,
);
if (mounted) ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}');
}
}
}
@@ -16,6 +16,7 @@ import 'file_preferences_page.dart';
import 'app_settings_page.dart';
import 'credit_history_page.dart';
import 'quick_access_settings_page.dart';
import '../../../router/app_router.dart';
/// 设置主页
class SettingsPage extends StatefulWidget {
@@ -105,6 +106,12 @@ class _SettingsPageState extends State<SettingsPage> {
_buildSection(
title: '偏好',
children: [
_SettingsTile(
icon: Icons.sync_outlined,
title: '文件同步',
subtitle: '本地与云端文件自动同步',
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings),
),
_SettingsTile(
icon: Icons.apps_outlined,
title: '快捷入口',
+211 -42
View File
@@ -1,21 +1,44 @@
import 'package:animations/animations.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/widgets/gesture_handler_mixin.dart';
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../../router/app_router.dart';
import '../files/files_page.dart';
import '../overview/overview_page.dart';
import '../sync/sync_page.dart';
import '../tasks/tasks_page.dart';
import '../profile/profile_page.dart';
class _ShellPageSlot extends StatefulWidget {
final Widget child;
const _ShellPageSlot({required this.child});
@override
State<_ShellPageSlot> createState() => _ShellPageSlotState();
}
class _ShellPageSlotState extends State<_ShellPageSlot>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class AppShell extends StatefulWidget {
const AppShell({super.key});
@@ -23,7 +46,52 @@ class AppShell extends StatefulWidget {
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> with GestureHandlerMixin {
class _AppShellState extends State<AppShell>
with GestureHandlerMixin, TickerProviderStateMixin {
final Set<int> _visitedPageIndexes = <int>{0};
late AnimationController _syncSpinController;
bool get _showSyncTab =>
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS;
List<Widget> get _pages => _showSyncTab
? [
const OverviewPage(),
const FilesPage(),
const TasksPage(),
const SyncPage(),
const ProfilePage(),
]
: [
const OverviewPage(),
const FilesPage(),
const TasksPage(),
const ProfilePage(),
];
int _clampedIndex(int index) {
final maxIndex = _pages.length - 1;
if (index < 0) return 0;
if (index > maxIndex) return maxIndex;
return index;
}
@override
void initState() {
super.initState();
_syncSpinController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
}
@override
void dispose() {
_syncSpinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
@@ -33,12 +101,19 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) {
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final navProvider = Provider.of<NavigationProvider>(
context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') {
await fileManager.goBack();
} else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) {
} else if (navProvider.currentIndex != 0 &&
navProvider.currentIndex != 1) {
navProvider.setIndex(0);
} else {
await checkExitApp(context);
@@ -57,42 +132,74 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
}
Widget _buildPageContent(BuildContext context, int currentIndex) {
const pages = [
OverviewPage(),
FilesPage(),
TasksPage(),
ProfilePage(),
];
final pages = _pages;
final visibleIndex = _clampedIndex(currentIndex);
_visitedPageIndexes.add(visibleIndex);
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation, secondaryAnimation) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: KeyedSubtree(
key: ValueKey(currentIndex),
child: pages[currentIndex],
return RepaintBoundary(
child: IndexedStack(
index: visibleIndex,
children: List.generate(pages.length, (index) {
if (!_visitedPageIndexes.contains(index)) {
return const SizedBox.shrink();
}
return _ShellPageSlot(child: pages[index]);
}),
),
);
}
Widget _buildMobileLayout(BuildContext context, NavigationProvider navProvider) {
Widget _buildSyncIcon({required bool isSelected, required double size}) {
return Consumer<SyncProvider>(
builder: (context, sync, _) {
final hasWorkers = sync.activeWorkerCount > 0;
if (hasWorkers && !_syncSpinController.isAnimating) {
_syncSpinController.repeat();
} else if (!hasWorkers && _syncSpinController.isAnimating) {
_syncSpinController.stop();
_syncSpinController.value = 0;
}
final icon = Icon(
LucideIcons.refreshCw,
size: size,
weight: isSelected ? 700 : 400,
);
if (!hasWorkers) return icon;
return ListenableBuilder(
listenable: _syncSpinController,
builder: (context, child) {
return Transform.rotate(
angle: _syncSpinController.value * 2 * 3.14159265,
child: child,
);
},
child: icon,
);
},
);
}
Widget _buildMobileLayout(
BuildContext context,
NavigationProvider navProvider,
) {
return Scaffold(
body: _buildPageContent(context, navProvider.currentIndex),
bottomNavigationBar: GlassmorphismContainer(
borderRadius: 0,
child: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) {
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
final activeCount =
uploadManager.activeTasks.length +
downloadManager.downloadingCount;
return NavigationBar(
height: 64,
selectedIndex: navProvider.currentIndex,
selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i),
destinations: [
const NavigationDestination(
@@ -118,6 +225,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
),
label: '任务',
),
if (_showSyncTab)
NavigationDestination(
icon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: false, size: 24),
);
},
),
selectedIcon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: true, size: 24),
);
},
),
label: '同步',
),
const NavigationDestination(
icon: Icon(LucideIcons.user),
selectedIcon: Icon(LucideIcons.user, weight: 700),
@@ -131,7 +262,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
);
}
Widget _buildDesktopLayout(BuildContext context, NavigationProvider navProvider) {
Widget _buildDesktopLayout(
BuildContext context,
NavigationProvider navProvider,
) {
final theme = Theme.of(context);
final authProvider = context.watch<AuthProvider>();
final user = authProvider.user;
@@ -141,16 +275,16 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
body: Row(
children: [
NavigationRail(
selectedIndex: navProvider.currentIndex,
selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i),
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: GestureDetector(
onTap: () => navProvider.setIndex(3),
onTap: () => navProvider.setIndex(_pages.length - 1),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: navProvider.currentIndex == 3
border: navProvider.currentIndex == _pages.length - 1
? Border.all(
color: theme.colorScheme.primary,
width: 2.5,
@@ -180,7 +314,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
NavigationRailDestination(
icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
builder: (context, uploadManager, downloadManager, _) {
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
final activeCount =
uploadManager.activeTasks.length +
downloadManager.downloadingCount;
return Badge(
isLabelVisible: activeCount > 0,
label: Text('$activeCount'),
@@ -191,6 +327,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
label: const Text('任务'),
),
if (_showSyncTab)
NavigationRailDestination(
icon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: false, size: 24),
);
},
),
selectedIcon: Consumer<SyncProvider>(
builder: (context, sync, _) {
final count = sync.activeWorkerCount;
return Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: _buildSyncIcon(isSelected: true, size: 24),
);
},
),
label: const Text('同步'),
),
const NavigationRailDestination(
icon: Icon(LucideIcons.user),
selectedIcon: Icon(LucideIcons.user, weight: 700),
@@ -206,32 +366,38 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
context,
icon: LucideIcons.share2,
label: '我的分享',
onTap: () => Navigator.of(context).pushNamed(RouteNames.share),
onTap: () =>
Navigator.of(context).pushNamed(RouteNames.share),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.cloud,
label: 'WebDAV',
onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav),
onTap: () =>
Navigator.of(context).pushNamed(RouteNames.webdav),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.download,
label: '离线下载',
onTap: () => Navigator.of(context).pushNamed(RouteNames.remoteDownload),
onTap: () => Navigator.of(
context,
).pushNamed(RouteNames.remoteDownload),
),
_buildSecondaryNavItem(
context,
icon: LucideIcons.trash2,
label: '回收站',
onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin),
onTap: () =>
Navigator.of(context).pushNamed(RouteNames.recycleBin),
),
const Divider(indent: 12, endIndent: 12),
_buildSecondaryNavItem(
context,
icon: LucideIcons.settings,
label: '设置',
onTap: () => Navigator.of(context).pushNamed(RouteNames.settings),
onTap: () =>
Navigator.of(context).pushNamed(RouteNames.settings),
),
_buildSecondaryNavItem(
context,
@@ -245,9 +411,7 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
),
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(
child: _buildPageContent(context, navProvider.currentIndex),
),
Expanded(child: _buildPageContent(context, navProvider.currentIndex)),
],
),
);
@@ -278,7 +442,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
Future<void> _handleLogout(BuildContext context) async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final confirmed = await showDialog<bool>(
context: context,
@@ -302,7 +469,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
await authProvider.logout();
fileManager.clearFiles();
if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
Navigator.of(
context,
).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
}
}
}
@@ -5,6 +5,7 @@ import '../../../services/api_service.dart';
import '../../../services/server_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart';
import '../../providers/sync_provider.dart';
/// 启动页
class SplashPage extends StatefulWidget {
@@ -45,6 +46,16 @@ class _SplashPageState extends State<SplashPage> {
if (!mounted) return;
if (authProvider.isAuthenticated) {
// 自动恢复同步(如果之前处于同步状态)
if (!mounted) return;
final syncProvider = Provider.of<SyncProvider>(context, listen: false);
final token = authProvider.token;
await syncProvider.autoResumeIfNeeded(
currentAccessToken: token?.accessToken,
currentRefreshToken: token?.refreshToken,
);
if (!mounted) return;
Navigator.of(context).pushReplacementNamed(RouteNames.home);
} else {
Navigator.of(context).pushReplacementNamed(RouteNames.login);
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import '../../widgets/toast_helper.dart';
/// 同步引擎日志预览页面
class SyncLogViewerPage extends StatefulWidget {
const SyncLogViewerPage({super.key});
@override
State<SyncLogViewerPage> createState() => _SyncLogViewerPageState();
}
class _SyncLogViewerPageState extends State<SyncLogViewerPage> {
String _logContent = '';
bool _isLoading = true;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadLog();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<String> _getSyncLogPath() async {
final appDir = await getApplicationSupportDirectory();
return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt';
}
Future<void> _loadLog() async {
setState(() => _isLoading = true);
try {
final path = await _getSyncLogPath();
final file = File(path);
if (!await file.exists()) {
if (mounted) {
setState(() {
_logContent = '';
_isLoading = false;
});
}
return;
}
final lines = await file.readAsLines();
const maxLines = 1000;
String content;
if (lines.length <= maxLines) {
content = lines.join('\n');
} else {
content = '... (仅显示最近 $maxLines 行)\n\n'
'${lines.sublist(lines.length - maxLines).join('\n')}';
}
if (mounted) {
setState(() {
_logContent = content;
_isLoading = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ToastHelper.error('读取同步日志失败:$e');
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('同步日志'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadLog,
tooltip: '刷新',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _logContent.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.description_outlined,
size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
const SizedBox(height: 16),
Text('暂无同步日志', style: TextStyle(color: theme.hintColor)),
],
),
)
: Container(
color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
child: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(12),
child: SelectableText(
_logContent,
style: TextStyle(
fontFamily: 'SourceCodePro',
fontSize: 13,
height: 1.5,
color: isDark ? Colors.grey[300] : Colors.grey[900],
),
),
),
),
),
);
}
}
+604
View File
@@ -0,0 +1,604 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../data/models/sync_task_model.dart';
import '../../providers/sync_provider.dart';
import '../../widgets/toast_helper.dart';
import 'sync_settings_page.dart';
/// Tab -
class SyncPage extends StatefulWidget {
const SyncPage({super.key});
@override
State<SyncPage> createState() => _SyncPageState();
}
class _SyncPageState extends State<SyncPage> {
// ID
final Set<String> _expandedTasks = {};
// ID
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: 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,
children: [
Row(
children: [
Icon(statusIcon, color: statusColor, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
],
),
),
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,
),
),
),
],
],
),
],
),
),
);
}
Widget _summaryChip(ThemeData theme, String label, int value) {
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)),
],
);
}
String _syncModeLabel(SyncProvider sync) {
final mode = sync.persistedConfig?.syncMode ?? 'full';
return switch (mode) {
'full' => '全量同步中',
'upload_only' => '仅上传中',
'download_only' => '仅下载中',
'album' => '相册同步中',
'mirror_wcf' => '镜像同步中',
_ => '同步中',
};
}
Widget _buildActiveTasksSection(SyncProvider sync, ThemeData theme) {
final tasks = sync.activeTasks;
return _buildTaskSection(
theme: theme,
title: '正在同步',
icon: LucideIcons.loader,
tasks: tasks,
emptyText: '暂无正在进行的同步任务',
);
}
Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) {
final tasks = sync.recentTasks
.where((t) =>
(t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') &&
t.totalCount > 0)
.toList();
return _buildTaskSection(
theme: theme,
title: '已完成',
icon: LucideIcons.checkCircle2,
tasks: tasks,
emptyText: '暂无已完成的同步任务',
);
}
Widget _buildTaskSection({
required ThemeData theme,
required String title,
required IconData icon,
required List<SyncTaskModel> tasks,
required String emptyText,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 8),
child: Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
if (tasks.isEmpty)
Card(
margin: const EdgeInsets.symmetric(horizontal: 5),
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(emptyText, style: TextStyle(color: theme.hintColor)),
),
),
)
else
...tasks.map((task) => _buildTaskCard(task, theme)),
],
);
}
Widget _buildTaskCard(SyncTaskModel task, ThemeData theme) {
final isExpanded = _expandedTasks.contains(task.id);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: Column(
children: [
InkWell(
onTap: () => _toggleTaskExpand(task.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12),
child: Row(
children: [
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: task.id));
ToastHelper.success('已复制任务 ID');
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 72,
child: Text(
task.shortId,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.hintColor,
),
),
),
Icon(Icons.copy, size: 14, color: theme.hintColor),
],
),
),
Expanded(
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _statusColor(task.status, theme).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
task.statusLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: _statusColor(task.status, theme),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Text(
'${task.completedCount}/${task.totalCount}',
style: theme.textTheme.bodySmall,
),
if (task.failedCount > 0) ...[
const SizedBox(width: 8),
Text(
'失败${task.failedCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
),
),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
size: 20,
color: theme.hintColor,
),
],
),
),
),
if (isExpanded)
_buildTaskDetailList(task, theme),
],
),
);
}
Widget _buildTaskDetailList(SyncTaskModel task, ThemeData theme) {
final sync = context.watch<SyncProvider>();
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: [
const Divider(height: 1, indent: 16, endIndent: 16),
Padding(
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))),
],
),
),
...items.map((item) => _buildTaskItemRow(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 _buildTaskItemRow(SyncTaskItemModel item, ThemeData theme) {
final timeStr = _formatTime(item.updatedAt);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Row(
children: [
SizedBox(
width: 70,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: _actionColor(item.actionType, theme).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
item.actionLabel,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11,
color: _actionColor(item.actionType, theme),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.filename,
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
SizedBox(
width: 55,
child: Text(
item.statusLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: _itemStatusColor(item.status, theme),
),
),
),
SizedBox(
width: 110,
child: Text(
timeStr,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
),
),
],
),
);
}
Color _statusColor(String status, ThemeData theme) {
return switch (status) {
'running' => theme.colorScheme.primary,
'completed' => Colors.green,
'failed' => theme.colorScheme.error,
'cancelled' => theme.hintColor,
_ => theme.hintColor,
};
}
Color _actionColor(String actionType, ThemeData theme) {
return switch (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(String status, ThemeData theme) {
return switch (status) {
'completed' => Colors.green,
'failed' => theme.colorScheme.error,
'running' => theme.colorScheme.primary,
_ => theme.hintColor,
};
}
String _formatTime(String isoTime) {
try {
final dt = DateTime.parse(isoTime).toLocal();
return '${dt.year}/${dt.month.toString().padLeft(2, '0')}/${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
} catch (_) {
return isoTime;
}
}
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) {
final sync = context.read<SyncProvider>();
sync.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();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,611 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/sync_config_model.dart';
import '../../../data/models/sync_event_model.dart';
import '../../../data/models/sync_task_model.dart';
import '../../../services/storage_service.dart';
import '../../../services/sync_service.dart';
enum SyncState {
idle,
initializing,
initialSync,
continuous,
paused,
error,
stopped,
}
class SyncProvider extends ChangeNotifier {
SyncState _state = SyncState.idle;
String? _errorMessage;
SyncSummaryModel? _lastSummary;
int _syncedFiles = 0;
int _totalFiles = 0;
int _conflictCount = 0;
int _uploadingCount = 0;
int _downloadingCount = 0;
String? _currentFile;
StreamSubscription<SyncEventModel>? _eventSub;
Timer? _pollTimer;
int _pollIntervalSeconds = 1; // 1s3s
//
List<SyncTaskModel> _activeTasks = [];
List<SyncTaskModel> _recentTasks = [];
int _activeWorkerCount = 0;
// : taskId -> items
final Map<String, List<SyncTaskItemModel>> _taskDetailCache = {};
// : taskId -> hasMore
final Map<String, bool> _taskDetailHasMore = {};
// IDUI
final Set<String> _watchedTaskIds = {};
bool _recentTasksLoaded = false;
//
SyncConfigModel? _persistedConfig;
SyncState get state => _state;
String? get errorMessage => _errorMessage;
SyncSummaryModel? get lastSummary => _lastSummary;
int get syncedFiles => _syncedFiles;
int get totalFiles => _totalFiles;
int get conflictCount => _conflictCount;
int get uploadingCount => _uploadingCount;
int get downloadingCount => _downloadingCount;
String? get currentFile => _currentFile;
SyncConfigModel? get persistedConfig => _persistedConfig;
List<SyncTaskModel> get activeTasks => _activeTasks;
List<SyncTaskModel> get recentTasks => _recentTasks;
int get activeWorkerCount => _activeWorkerCount;
bool get isActive =>
_state == SyncState.initializing ||
_state == SyncState.initialSync ||
_state == SyncState.continuous;
bool get isPaused => _state == SyncState.paused;
bool get hasError => _state == SyncState.error;
/// Rust "开始同步" true
bool _engineInitialized = false;
bool get engineInitialized => _engineInitialized;
double get progress =>
_totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0;
///
Future<void> restoreFromStorage() async {
final configMap = await StorageService.instance.getSyncConfig();
if (configMap != null) {
try {
_persistedConfig = SyncConfigModel(
baseUrl: configMap['baseUrl'] as String? ?? '',
accessToken: configMap['accessToken'] as String? ?? '',
refreshToken: configMap['refreshToken'] as String? ?? '',
localRoot: configMap['localRoot'] as String? ?? '',
remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my',
syncMode: configMap['syncMode'] as String? ?? 'full',
conflictStrategy: configMap['conflictStrategy'] as String? ?? 'keep_both',
wcfDeleteMode: configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only',
maxConcurrentTransfers: configMap['maxConcurrentTransfers'] as int? ?? 3,
bandwidthLimitKbps: configMap['bandwidthLimitKbps'] as int? ?? 0,
maxWorkers: configMap['maxWorkers'] as int? ?? 0,
dataDir: configMap['dataDir'] as String? ?? '',
clientId: configMap['clientId'] as String? ?? '',
logLevel: configMap['logLevel'] as String? ?? 'info',
);
AppLogger.i('恢复同步配置: 模式=${_persistedConfig!.syncMode}, 冲突=${_persistedConfig!.conflictStrategy}, 并发=${_persistedConfig!.maxConcurrentTransfers}, 带宽=${_persistedConfig!.bandwidthLimitKbps}kbps, maxWorkers=${_persistedConfig!.maxWorkers}');
} catch (e) {
AppLogger.e('恢复同步配置失败: $e');
}
}
final savedState = await StorageService.instance.getSyncState();
if (savedState != null && savedState != 'idle' && savedState != 'stopped') {
AppLogger.i('恢复同步状态: $savedState');
}
}
///
Future<void> _persistConfig(SyncConfigModel config) async {
_persistedConfig = config;
await StorageService.instance.setSyncConfig({
'baseUrl': config.baseUrl,
'accessToken': config.accessToken,
'refreshToken': config.refreshToken,
'localRoot': config.localRoot,
'remoteRoot': config.remoteRoot,
'syncMode': config.syncMode,
'conflictStrategy': config.conflictStrategy,
'wcfDeleteMode': config.wcfDeleteMode,
'maxConcurrentTransfers': config.maxConcurrentTransfers,
'bandwidthLimitKbps': config.bandwidthLimitKbps,
'maxWorkers': config.maxWorkers,
'dataDir': config.dataDir,
'clientId': config.clientId,
'logLevel': config.logLevel,
});
}
///
Future<void> _persistState(SyncState state) async {
final stateStr = switch (state) {
SyncState.idle => 'idle',
SyncState.initializing => 'initializing',
SyncState.initialSync => 'initialSync',
SyncState.continuous => 'continuous',
SyncState.paused => 'paused',
SyncState.error => 'error',
SyncState.stopped => 'stopped',
};
await StorageService.instance.setSyncState(stateStr);
}
///
Future<void> startSync(SyncConfigModel config) async {
_state = SyncState.initializing;
_errorMessage = null;
_syncedFiles = 0;
_totalFiles = 0;
_currentFile = null;
notifyListeners();
// clientId Dart
final clientId = await StorageService.instance.getOrCreateClientId();
final configWithClientId = config.copyWith(clientId: clientId);
//
await _persistConfig(configWithClientId);
await _persistState(SyncState.initializing);
try {
await SyncService.instance.init(configWithClientId);
_engineInitialized = true;
_subscribeEvents();
_state = SyncState.initialSync;
await _persistState(SyncState.initialSync);
notifyListeners();
//
_startPolling();
//
SyncService.instance.startInitialSync().then((summary) async {
_lastSummary = summary;
_state = SyncState.continuous;
await _persistState(SyncState.continuous);
AppLogger.i('初始同步完成');
notifyListeners();
//
SyncService.instance.startContinuousSync();
}).catchError((e) async {
_state = SyncState.error;
_errorMessage = e.toString();
await _persistState(SyncState.error);
AppLogger.e('初始同步失败: $e');
notifyListeners();
});
} catch (e) {
_state = SyncState.error;
_errorMessage = e.toString();
await _persistState(SyncState.error);
AppLogger.e('同步启动失败: $e');
notifyListeners();
}
}
///
Future<void> autoResumeIfNeeded({
String? currentAccessToken,
String? currentRefreshToken,
}) async {
if (_persistedConfig == null) {
await restoreFromStorage();
}
if (_persistedConfig == null) return;
final savedState = await StorageService.instance.getSyncState();
if (savedState == null || savedState == 'idle' || savedState == 'stopped' || savedState == 'error') {
return;
}
AppLogger.i('自动恢复同步,上次状态: $savedState');
var config = _persistedConfig!;
if (currentAccessToken != null && currentRefreshToken != null) {
config = config.copyWith(
accessToken: currentAccessToken,
refreshToken: currentRefreshToken,
);
}
await startSync(config);
}
/// + Rust
Future<void> updateConfig(SyncConfigModel config) async {
await _persistConfig(config);
// Rust
try {
await SyncService.instance.updateConfig(config);
AppLogger.i('同步配置已更新到引擎: 模式=${config.syncMode}');
} catch (e) {
AppLogger.e('更新配置到引擎失败: $e');
}
}
///
Future<void> setLogLevel(String level) async {
//
if (_persistedConfig != null) {
final updated = _persistedConfig!.copyWith(logLevel: level);
await _persistConfig(updated);
}
// Rust
try {
await SyncService.instance.setLogLevel(level);
AppLogger.i('日志级别已切换为: $level');
} catch (e) {
AppLogger.e('切换日志级别失败: $e');
}
}
/// Rust 1s3s
void _startPolling() {
_pollTimer?.cancel();
_pollIntervalSeconds = isActive || isPaused ? 1 : 3;
_pollTimer = Timer.periodic(
Duration(seconds: _pollIntervalSeconds),
(_) => _pollStatus(),
);
}
void _adjustPollInterval() {
final target = isActive || isPaused ? 1 : 3;
if (target != _pollIntervalSeconds) {
_pollIntervalSeconds = target;
_startPolling();
}
}
void _stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
int _pollErrorCount = 0;
Future<void> _pollStatus() async {
try {
final status = await SyncService.instance.getStatus();
_syncedFiles = status.syncedFiles;
_totalFiles = status.totalFiles;
_uploadingCount = status.uploadingCount;
_downloadingCount = status.downloadingCount;
_conflictCount = status.conflictCount;
_pollErrorCount = 0;
final rustState = _parseState(status.state);
if (rustState != _state && rustState != SyncState.idle) {
_state = rustState;
await _persistState(rustState);
}
if (status.errorMessage != null && status.errorMessage!.isNotEmpty) {
_errorMessage = status.errorMessage;
}
// +
try {
final newActiveWorkerCount = await SyncService.instance.getActiveWorkerCount();
final newActiveTasks = await SyncService.instance.getActiveTasksTyped();
bool changed = newActiveWorkerCount != _activeWorkerCount;
_activeWorkerCount = newActiveWorkerCount;
//
if (!_taskListEqual(newActiveTasks, _activeTasks)) {
_activeTasks = newActiveTasks;
changed = true;
//
_taskDetailCache.removeWhere(
(key, _) => !newActiveTasks.any((t) => t.id == key),
);
}
// polling
await _refreshRecentTasks();
// item
await _refreshWatchedTaskDetails();
if (changed) {
notifyListeners();
} else {
notifyListeners();
}
} catch (_) {}
notifyListeners();
_adjustPollInterval();
} catch (_) {
//
_pollErrorCount++;
if (_pollErrorCount >= 3) {
_stopPolling();
}
}
}
///
Future<void> loadRecentTasks() async {
if (_recentTasksLoaded) return;
await _refreshRecentTasks();
if (_pollTimer == null) {
_startPolling();
}
}
Future<void> _refreshRecentTasks() async {
try {
final tasks = await SyncService.instance.getRecentTasksTyped(limit: 50);
if (!_taskListEqual(tasks, _recentTasks)) {
_recentTasks = tasks;
}
_recentTasksLoaded = true;
} catch (_) {}
}
bool _taskListEqual(List<SyncTaskModel> a, List<SyncTaskModel> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i].id != b[i].id ||
a[i].completedCount != b[i].completedCount ||
a[i].failedCount != b[i].failedCount ||
a[i].status != b[i].status) {
return false;
}
}
return true;
}
/// watched
Future<void> _refreshWatchedTaskDetails() async {
if (_watchedTaskIds.isEmpty) return;
for (final taskId in _watchedTaskIds.toList()) {
try {
// 20
final cachedCount = _taskDetailCache[taskId]?.length ?? 20;
final newItems = await SyncService.instance.queryTaskItemsTyped(
taskId: taskId,
limit: cachedCount.clamp(20, 100),
offset: 0,
);
final oldItems = _taskDetailCache[taskId];
if (oldItems != null && !_itemListEqual(oldItems, newItems)) {
_taskDetailCache[taskId] = newItems;
notifyListeners();
} else if (oldItems == null) {
_taskDetailCache[taskId] = newItems;
notifyListeners();
}
} catch (_) {}
}
}
bool _itemListEqual(List<SyncTaskItemModel> a, List<SyncTaskItemModel> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i].id != b[i].id || a[i].status != b[i].status) {
return false;
}
}
return true;
}
void _subscribeEvents() {
_eventSub = SyncService.instance.events.listen((event) async {
switch (event) {
case SyncStateChanged(:final newState):
_state = _parseState(newState);
await _persistState(_state);
case SyncProgress(:final synced, :final total, :final currentFile):
_syncedFiles = synced;
_totalFiles = total;
_currentFile = currentFile;
case SyncFileUploaded():
_syncedFiles++;
case SyncFileDownloaded():
_syncedFiles++;
case SyncConflictDetected():
_conflictCount++;
case SyncError(:final message, :final recoverable):
if (!recoverable) {
_state = SyncState.error;
_errorMessage = message;
await _persistState(SyncState.error);
}
case SyncTokenExpired():
_handleTokenExpired();
case SyncInitialSyncComplete(:final summary):
_lastSummary = summary;
_state = SyncState.continuous;
await _persistState(SyncState.continuous);
SyncService.instance.startContinuousSync();
case SyncDiskSpaceWarning():
AppLogger.w('磁盘空间不足');
}
notifyListeners();
});
}
SyncState _parseState(String state) {
return switch (state) {
'idle' => SyncState.idle,
'initializing' => SyncState.initializing,
'initialSync' => SyncState.initialSync,
'continuous' => SyncState.continuous,
'paused' => SyncState.paused,
'error' => SyncState.error,
'stopped' => SyncState.stopped,
_ => SyncState.idle,
};
}
void _handleTokenExpired() {
AppLogger.w('Token 过期,需要刷新');
}
///
Future<List<SyncTaskItemModel>> getTaskDetail(String taskId) async {
if (_taskDetailCache.containsKey(taskId)) {
return _taskDetailCache[taskId]!;
}
final items = await SyncService.instance.queryTaskItemsTyped(
taskId: taskId,
limit: 20,
offset: 0,
);
_taskDetailCache[taskId] = items;
_taskDetailHasMore[taskId] = items.length >= 20;
notifyListeners();
return items;
}
///
Future<List<SyncTaskItemModel>> loadMoreTaskDetail(String taskId) async {
final current = _taskDetailCache[taskId] ?? [];
final offset = current.length;
final newItems = await SyncService.instance.queryTaskItemsTyped(
taskId: taskId,
limit: 20,
offset: offset,
);
final merged = [...current, ...newItems];
_taskDetailCache[taskId] = merged;
_taskDetailHasMore[taskId] = newItems.length >= 20;
notifyListeners();
return merged;
}
///
bool hasMoreTaskDetail(String taskId) {
return _taskDetailHasMore[taskId] ?? true;
}
/// UI
void watchTaskDetail(String taskId) {
_watchedTaskIds.add(taskId);
}
/// UI
void unwatchTaskDetail(String taskId) {
_watchedTaskIds.remove(taskId);
}
/// null
List<SyncTaskItemModel>? getCachedTaskDetail(String taskId) {
return _taskDetailCache[taskId];
}
///
void invalidateTaskDetail(String taskId) {
_taskDetailCache.remove(taskId);
_taskDetailHasMore.remove(taskId);
}
///
void invalidateAllTaskDetails() {
_taskDetailCache.clear();
_taskDetailHasMore.clear();
_recentTasksLoaded = false;
}
///
Future<void> pause() async {
await SyncService.instance.pause();
_state = SyncState.paused;
await _persistState(SyncState.paused);
notifyListeners();
}
///
Future<void> resume() async {
await SyncService.instance.resume();
_state = SyncState.continuous;
await _persistState(SyncState.continuous);
notifyListeners();
}
///
Future<void> stop() async {
await SyncService.instance.stop();
_state = SyncState.stopped;
await _persistState(SyncState.stopped);
notifyListeners();
_adjustPollInterval();
}
/// DB
Future<void> resetSync() async {
//
try {
await SyncService.instance.resetSync();
} catch (_) {}
_state = SyncState.idle;
_errorMessage = null;
_syncedFiles = 0;
_totalFiles = 0;
_conflictCount = 0;
_uploadingCount = 0;
_downloadingCount = 0;
_currentFile = null;
_lastSummary = null;
_activeTasks = [];
_recentTasks = [];
_activeWorkerCount = 0;
_taskDetailCache.clear();
_recentTasksLoaded = false;
_engineInitialized = false;
await _persistState(SyncState.idle);
notifyListeners();
}
///
Future<void> forceSync() async {
_state = SyncState.initialSync;
_syncedFiles = 0;
_totalFiles = 0;
await _persistState(SyncState.initialSync);
notifyListeners();
try {
final summary = await SyncService.instance.forceSync();
_lastSummary = summary;
_state = SyncState.continuous;
await _persistState(SyncState.continuous);
//
SyncService.instance.startContinuousSync();
} catch (e) {
_state = SyncState.error;
_errorMessage = e.toString();
await _persistState(SyncState.error);
}
notifyListeners();
}
@override
void dispose() {
_stopPolling();
_eventSub?.cancel();
super.dispose();
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/file_utils.dart';
///
@@ -15,7 +16,7 @@ class FileBreadcrumb extends StatelessWidget {
@override
Widget build(BuildContext context) {
final pathParts = FileUtils.toRelativePath(currentPath).split('/');
final pathParts = currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -55,7 +56,7 @@ class FileBreadcrumb extends StatelessWidget {
),
_buildBreadcrumbItem(
context,
name: FileUtils.decodePathSegment(pathParts[i]),
name: _decodePathSegment(pathParts[i]),
path: '/${pathParts.sublist(0, i + 1).join('/')}',
icon: null,
primaryColor: colorScheme.primary,
@@ -69,6 +70,10 @@ class FileBreadcrumb extends StatelessWidget {
);
}
String _decodePathSegment(String value) {
return FileUtils.safeDecodePathSegment(value);
}
Widget _buildBreadcrumbItem(
BuildContext context, {
required String name,
@@ -210,7 +210,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
_buildInfoRow(
LucideIcons.folderOpen,
'位置',
FileUtils.decodePathForDisplay(file.relativePath),
FileUtils.safeDecodePathSegment(file.relativePath),
),
if (file.isFile)
_buildInfoRow(
+12 -26
View File
@@ -2,7 +2,6 @@ import 'package:cloudreve4_flutter/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:flutter/material.dart';
import '../../core/utils/app_logger.dart';
import '../../core/utils/file_utils.dart';
///
class FolderPicker extends StatefulWidget {
@@ -62,7 +61,7 @@ class _FolderPickerState extends State<FolderPicker> {
void _enterFolder(FileModel folder) {
setState(() {
_currentPath = folder.relativePath;
_currentPath = folder.path;
});
_loadFolders();
}
@@ -81,10 +80,7 @@ class _FolderPickerState extends State<FolderPicker> {
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: widget.maxVisibleItems != null
? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(
80.0,
maxHeight,
)
? (widget.maxVisibleItems! * 56.0 + 40.0).clamp(80.0, maxHeight)
: maxHeight,
),
child: _buildListContent(context, primaryColor),
@@ -112,10 +108,7 @@ class _FolderPickerState extends State<FolderPicker> {
children: [
Icon(Icons.folder_off, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'此文件夹为空',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
),
Text('此文件夹为空', style: TextStyle(color: Colors.grey.shade600, fontSize: 14)),
],
),
);
@@ -153,13 +146,7 @@ class _FolderPickerState extends State<FolderPicker> {
),
const SizedBox(width: 12),
Expanded(
child: Text(
folder.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
child: Text(folder.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
),
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20),
],
@@ -169,7 +156,7 @@ class _FolderPickerState extends State<FolderPicker> {
}
Widget _buildBreadcrumb(BuildContext context, Color primaryColor) {
final pathParts = FileUtils.toRelativePath(_currentPath).split('/');
final pathParts = _currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty);
return Column(
@@ -193,8 +180,8 @@ class _FolderPickerState extends State<FolderPicker> {
),
...pathParts.asMap().entries.expand((entry) {
final index = entry.key;
final path =
'/${pathParts.sublist(0, index + 1).join('/')}';
final part = entry.value;
final path = '/${pathParts.sublist(0, index + 1).join('/')}';
return [
Padding(
@@ -207,7 +194,7 @@ class _FolderPickerState extends State<FolderPicker> {
),
_buildBreadcrumbItem(
context,
name: FileUtils.decodePathSegment(entry.value),
name: part,
path: path,
isLast: index == pathParts.length - 1,
primaryColor: primaryColor,
@@ -221,14 +208,13 @@ class _FolderPickerState extends State<FolderPicker> {
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () {
final relative = FileUtils.toRelativePath(_currentPath);
final relative = _currentPath.startsWith('cloudreve://my')
? _currentPath.replaceFirst('cloudreve://my', '')
: _currentPath;
widget.onFolderSelected(relative.isEmpty ? '/' : relative);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('选择'),
),
+1 -1
View File
@@ -436,7 +436,7 @@ class _SearchDialogState extends State<SearchDialog> {
),
const SizedBox(height: 2),
Text(
FileUtils.decodePathForDisplay(file.relativePath),
FileUtils.safeDecodePathSegment(file.relativePath),
style: TextStyle(fontSize: 12, color: theme.hintColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+143
View File
@@ -0,0 +1,143 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../data/models/app_update_model.dart';
import '../../services/update_service.dart';
import 'toast_helper.dart';
class UpdatePrompt extends StatefulWidget {
final Widget child;
const UpdatePrompt({super.key, required this.child});
@override
State<UpdatePrompt> createState() => _UpdatePromptState();
}
class _UpdatePromptState extends State<UpdatePrompt> {
static bool _checkedThisRun = false;
bool _dialogVisible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdate());
}
Future<void> _checkUpdate() async {
if (_checkedThisRun || _dialogVisible || !mounted) return;
_checkedThisRun = true;
final update = await UpdateService.instance.checkForUpdate();
if (update == null || !mounted) return;
_dialogVisible = true;
try {
await _showUpdateDialog(update);
} finally {
_dialogVisible = false;
}
}
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
var downloading = false;
var progress = 0.0;
String? status;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
final canDownload =
update.downloadUrl != null &&
(Platform.isAndroid || Platform.isWindows);
Future<void> startUpdate() async {
if (downloading || !canDownload) return;
setDialogState(() {
downloading = true;
progress = 0;
status = '正在下载更新...';
});
try {
final file = await UpdateService.instance.downloadUpdate(
update,
onProgress: (received, total) {
if (!mounted || total <= 0) return;
setDialogState(() {
progress = received / total;
});
},
);
if (file == null) return;
setDialogState(() {
progress = 1;
status = '下载完成,正在打开安装包...';
});
await UpdateService.instance.openInstaller(file);
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
} catch (e) {
setDialogState(() {
downloading = false;
status = '下载或打开安装包失败';
});
ToastHelper.failure('更新失败: $e');
}
}
return AlertDialog(
title: Text('发现新版本 ${update.version}'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(update.title),
if ((update.description ?? '').isNotEmpty) ...[
const SizedBox(height: 12),
Text(
update.description!,
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
],
if (downloading) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: progress == 0 ? null : progress,
),
const SizedBox(height: 8),
Text(status ?? ''),
],
],
),
),
actions: [
TextButton(
onPressed: downloading
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('稍后'),
),
FilledButton(
onPressed: downloading ? null : startUpdate,
child: const Text('立即更新'),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) => widget.child;
}