This commit is contained in:
@@ -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: '快捷入口',
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user