479 lines
16 KiB
Dart
479 lines
16 KiB
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});
|
|
|
|
@override
|
|
State<AppShell> createState() => _AppShellState();
|
|
}
|
|
|
|
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;
|
|
final isDesktop = screenWidth >= 1000;
|
|
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (!didPop) {
|
|
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) {
|
|
navProvider.setIndex(0);
|
|
} else {
|
|
await checkExitApp(context);
|
|
}
|
|
}
|
|
},
|
|
child: Consumer<NavigationProvider>(
|
|
builder: (context, navProvider, _) {
|
|
if (isDesktop) {
|
|
return _buildDesktopLayout(context, navProvider);
|
|
}
|
|
return _buildMobileLayout(context, navProvider);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPageContent(BuildContext context, int currentIndex) {
|
|
final pages = _pages;
|
|
final visibleIndex = _clampedIndex(currentIndex);
|
|
_visitedPageIndexes.add(visibleIndex);
|
|
|
|
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 _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;
|
|
|
|
return NavigationBar(
|
|
height: 64,
|
|
selectedIndex: _clampedIndex(navProvider.currentIndex),
|
|
onDestinationSelected: (i) => navProvider.setIndex(i),
|
|
destinations: [
|
|
const NavigationDestination(
|
|
icon: Icon(LucideIcons.layoutDashboard),
|
|
selectedIcon: Icon(LucideIcons.layoutDashboard, weight: 700),
|
|
label: '概览',
|
|
),
|
|
const NavigationDestination(
|
|
icon: Icon(LucideIcons.folder),
|
|
selectedIcon: Icon(LucideIcons.folder, weight: 700),
|
|
label: '文件',
|
|
),
|
|
NavigationDestination(
|
|
icon: Badge(
|
|
isLabelVisible: activeCount > 0,
|
|
label: Text('$activeCount'),
|
|
child: const Icon(LucideIcons.listChecks),
|
|
),
|
|
selectedIcon: Badge(
|
|
isLabelVisible: activeCount > 0,
|
|
label: Text('$activeCount'),
|
|
child: const Icon(LucideIcons.listChecks, weight: 700),
|
|
),
|
|
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),
|
|
label: '我的',
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDesktopLayout(
|
|
BuildContext context,
|
|
NavigationProvider navProvider,
|
|
) {
|
|
final theme = Theme.of(context);
|
|
final authProvider = context.watch<AuthProvider>();
|
|
final user = authProvider.user;
|
|
final displayName = user?.nickname ?? '用户';
|
|
|
|
return Scaffold(
|
|
body: Row(
|
|
children: [
|
|
NavigationRail(
|
|
selectedIndex: _clampedIndex(navProvider.currentIndex),
|
|
onDestinationSelected: (i) => navProvider.setIndex(i),
|
|
leading: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: GestureDetector(
|
|
onTap: () => navProvider.setIndex(_pages.length - 1),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: navProvider.currentIndex == _pages.length - 1
|
|
? Border.all(
|
|
color: theme.colorScheme.primary,
|
|
width: 2.5,
|
|
)
|
|
: null,
|
|
),
|
|
child: UserAvatar(
|
|
userId: user?.id ?? '',
|
|
email: user?.email,
|
|
displayName: displayName,
|
|
radius: 20,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
destinations: [
|
|
const NavigationRailDestination(
|
|
icon: Icon(LucideIcons.layoutDashboard),
|
|
selectedIcon: Icon(LucideIcons.layoutDashboard, weight: 700),
|
|
label: Text('概览'),
|
|
),
|
|
const NavigationRailDestination(
|
|
icon: Icon(LucideIcons.folder),
|
|
selectedIcon: Icon(LucideIcons.folder, weight: 700),
|
|
label: Text('文件'),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
|
|
builder: (context, uploadManager, downloadManager, _) {
|
|
final activeCount =
|
|
uploadManager.activeTasks.length +
|
|
downloadManager.downloadingCount;
|
|
return Badge(
|
|
isLabelVisible: activeCount > 0,
|
|
label: Text('$activeCount'),
|
|
child: const Icon(LucideIcons.listChecks),
|
|
);
|
|
},
|
|
),
|
|
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),
|
|
label: Text('我的'),
|
|
),
|
|
],
|
|
trailing: Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
const Divider(indent: 12, endIndent: 12),
|
|
_buildSecondaryNavItem(
|
|
context,
|
|
icon: LucideIcons.share2,
|
|
label: '我的分享',
|
|
onTap: () =>
|
|
Navigator.of(context).pushNamed(RouteNames.share),
|
|
),
|
|
_buildSecondaryNavItem(
|
|
context,
|
|
icon: LucideIcons.cloud,
|
|
label: 'WebDAV',
|
|
onTap: () =>
|
|
Navigator.of(context).pushNamed(RouteNames.webdav),
|
|
),
|
|
_buildSecondaryNavItem(
|
|
context,
|
|
icon: LucideIcons.download,
|
|
label: '离线下载',
|
|
onTap: () => Navigator.of(
|
|
context,
|
|
).pushNamed(RouteNames.remoteDownload),
|
|
),
|
|
_buildSecondaryNavItem(
|
|
context,
|
|
icon: LucideIcons.trash2,
|
|
label: '回收站',
|
|
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),
|
|
),
|
|
_buildSecondaryNavItem(
|
|
context,
|
|
icon: LucideIcons.logOut,
|
|
label: '退出登录',
|
|
onTap: () => _handleLogout(context),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const VerticalDivider(thickness: 1, width: 1),
|
|
Expanded(child: _buildPageContent(context, navProvider.currentIndex)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSecondaryNavItem(
|
|
BuildContext context, {
|
|
required IconData icon,
|
|
required String label,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
final theme = Theme.of(context);
|
|
return InkWell(
|
|
onTap: onTap,
|
|
customBorder: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Tooltip(
|
|
message: label,
|
|
preferBelow: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
child: Icon(icon, size: 22, color: theme.hintColor),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleLogout(BuildContext context) async {
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
final fileManager = Provider.of<FileManagerProvider>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('退出登录'),
|
|
content: const Text('确定要退出登录吗?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: const Text('取消'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: const Text('退出'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true) {
|
|
await authProvider.logout();
|
|
fileManager.clearFiles();
|
|
if (context.mounted) {
|
|
Navigator.of(
|
|
context,
|
|
).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
|
|
}
|
|
}
|
|
}
|
|
}
|