Files
chen08209 9bd258f654 feat(dashboard): redesign the dashboard around the current exit
Changelog: A redesigned dashboard around the current exit
2026-10-17 17:21:43 +08:00

445 lines
13 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/manager/app_manager.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasViewSize = ref.watch(
viewSizeProvider.select((size) => !size.isEmpty),
);
if (!hasViewSize) {
return const SizedBox.shrink();
}
return HomeBackScopeContainer(
child: AppSidebarContainer(
child: _HomeShell(
child: Consumer(
builder: (_, ref, _) {
final navigationItems = ref
.watch(currentNavigationItemsStateProvider)
.value;
final isMobile = ref.watch(isMobileViewProvider);
return _HomePageView(
navigationItems: navigationItems,
pageBuilder: (_, index) {
final navigationItem = navigationItems[index];
return _NavigationPage(
key: ValueKey(navigationItem.label),
item: navigationItem,
isMobile: isMobile,
view: navigationItem.builder(context),
);
},
);
},
),
),
),
);
}
}
class _HomeShell extends ConsumerWidget {
const _HomeShell({required this.child});
final Widget child;
void _handleToPage(PageLabel pageLabel, WidgetRef ref) {
ref.read(currentPageLabelProvider.notifier).toPage(pageLabel);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(navigationStateProvider);
final isMobile = state.viewMode == ViewMode.mobile;
final navigationItems = state.navigationItems;
final floating = ref.watch(
appSettingProvider.select((state) => state.floatingNavigationBar),
);
return Material(
color: SurfaceLevel.page.colorOf(context.colorScheme),
child: Column(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: FocusTraversalGroup(
policy: PageTraversalPolicy(),
child: BottomInsetScope(
inset: isMobile && floating
? NavigationDock.insetOf(context)
: 0,
child: _BodyPadding(isMobile: isMobile, child: child),
),
),
),
PositionedDirectional(
start: 0,
end: 0,
bottom: 0,
child: AnimatedVisibility.bottomNavigation(
visible: isMobile && floating,
child: _NavigationPadding(
child: NavigationDock(
destinations: [
for (final item in navigationItems)
NavigationDockDestination(
glyph: item.glyph,
label: item.label.label,
),
],
selectedIndex: state.currentIndex,
onSelected: (index) {
_handleToPage(navigationItems[index].label, ref);
},
),
),
),
),
],
),
),
AnimatedVisibility.bottomNavigation(
visible: isMobile && !floating,
child: _NavigationPadding(
child: NavigationBar(
destinations: [
for (final (index, item) in navigationItems.indexed)
NavigationDestination(
icon: AnimatedGlyph(
glyph: item.glyph,
filled: index == state.currentIndex,
),
label: item.label.label,
),
],
selectedIndex: state.currentIndex,
onDestinationSelected: (index) {
_handleToPage(navigationItems[index].label, ref);
},
),
),
),
],
),
);
}
}
class _BodyPadding extends StatelessWidget {
const _BodyPadding({required this.isMobile, required this.child});
final bool isMobile;
final Widget child;
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
removeTop: false,
removeBottom: isMobile,
removeLeft: isMobile,
removeRight: isMobile,
context: context,
child: child,
);
}
}
class _NavigationPadding extends StatelessWidget {
const _NavigationPadding({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
removeTop: true,
removeBottom: false,
removeLeft: true,
removeRight: true,
context: context,
child: child,
);
}
}
class _NavigationPage extends StatelessWidget {
const _NavigationPage({
super.key,
required this.item,
required this.isMobile,
required this.view,
});
final NavigationItem item;
final bool isMobile;
final Widget view;
@override
Widget build(BuildContext context) {
final scopedView = PageFocusScope(child: view);
final keptView = KeepScope(
key: ValueKey(item.label),
child: isMobile
? scopedView
: Navigator(
key: ValueKey('${item.label.name}_navigator'),
pages: [MaterialPage(child: scopedView)],
onDidRemovePage: (_) {},
),
);
return Consumer(
builder: (_, ref, child) {
final isActive = ref.watch(
shownPageLabelProvider.select((label) => label == item.label),
);
// A kept-alive page off screen still ticks its animations, and
// each tick asks for a frame.
return PageActivityScope(
isActive: isActive,
child: TickerMode(
enabled: isActive,
child: ExcludeFocus(
excluding: !isActive,
child: KeyboardInsetHold(child: child!),
),
),
);
},
child: keptView,
);
}
}
class _HomePageView extends ConsumerStatefulWidget {
final IndexedWidgetBuilder pageBuilder;
final List<NavigationItem> navigationItems;
const _HomePageView({
required this.pageBuilder,
required this.navigationItems,
});
@override
ConsumerState createState() => _HomePageViewState();
}
class _HomePageViewState extends ConsumerState<_HomePageView> {
late PageController _pageController;
List<int>? _order;
int _slide = 0;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _pageIndex);
ref.listenManual(currentPageLabelProvider, (prev, next) {
if (prev != next) {
_toPage(next);
}
});
}
@override
void didUpdateWidget(covariant _HomePageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(
_labelsOf(oldWidget.navigationItems),
_labelsOf(widget.navigationItems),
)) {
_order = null;
_updatePageController();
}
}
static List<PageLabel> _labelsOf(List<NavigationItem> items) => [
for (final item in items) item.label,
];
int get _pageIndex => _indexOf(ref.read(currentPageLabelProvider));
int _indexOf(PageLabel pageLabel) {
final shown = shownPageLabelOf(pageLabel, widget.navigationItems);
return widget.navigationItems.indexWhere((item) => item.label == shown);
}
Future<void> _toPage(
PageLabel pageLabel, [
bool ignoreAnimateTo = false,
]) async {
if (!mounted) {
return;
}
final index = _indexOf(pageLabel);
if (index == -1) {
return;
}
final tabAnimation = ref.read(appSettingProvider).tabAnimation;
final isMobile = ref.read(isMobileViewProvider);
final slide = ++_slide;
final page = _pageController.hasClients
? _pageController.page?.round() ?? index
: index;
final current = _order?[page] ?? page;
if (_order != null) {
setState(() => _order = null);
_pageController.jumpToPage(current);
}
if (!isMobile || ignoreAnimateTo) {
_pageController.jumpToPage(index);
return;
}
// As TabBarView does, so no page between is built and painted on the way.
if ((index - current).abs() > 1) {
final adjacent = index > current ? index - 1 : index + 1;
setState(() {
_order = List.generate(widget.navigationItems.length, (item) => item)
..[adjacent] = current
..[current] = adjacent;
});
_pageController.jumpToPage(adjacent);
}
final fade = tabAnimation == TabAnimation.fade;
await _pageController.animateToPage(
index,
duration: fade ? fadeTabDuration : kTabScrollDuration,
curve: fade ? fadeTabCurve : Curves.easeOut,
);
if (mounted && slide == _slide && _order != null) {
setState(() => _order = null);
}
}
void _updatePageController() {
final pageLabel = ref.read(currentPageLabelProvider);
_toPage(pageLabel, true);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final itemCount = ref.watch(
currentNavigationItemsStateProvider.select((state) => state.value.length),
);
final fade = ref.watch(
appSettingProvider.select(
(state) => state.tabAnimation == TabAnimation.fade,
),
);
return PageView.builder(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
itemCount: itemCount,
findChildIndexCallback: (key) {
if (key is! ValueKey<PageLabel>) {
return null;
}
final index = widget.navigationItems.indexWhere(
(item) => item.label == key.value,
);
if (index == -1) {
return null;
}
return _order?.indexOf(index) ?? index;
},
itemBuilder: (context, index) {
final page = widget.pageBuilder(context, _order?[index] ?? index);
return _FadeTabPage(
key: page.key,
controller: _pageController,
position: index,
enabled: fade,
child: page,
);
},
);
}
}
/// Cancels the page view's slide so a tab switch cross-fades in place, and
/// wraps every page even when off so switching the setting keeps their state.
class _FadeTabPage extends StatelessWidget {
const _FadeTabPage({
super.key,
required this.controller,
required this.position,
required this.enabled,
required this.child,
});
final PageController controller;
final int position;
final bool enabled;
final Widget child;
double get _delta {
if (!controller.hasClients || !controller.position.hasContentDimensions) {
return 0;
}
final page = controller.page ?? position.toDouble();
return (position - page).clamp(-1.0, 1.0);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (_, child) {
final offset = _delta;
final delta = enabled ? offset : 0.0;
return FractionalTranslation(
translation: Offset(-delta, 0),
child: Opacity(opacity: 1 - delta.abs(), child: child),
);
},
child: child,
);
}
}
class HomeBackScopeContainer extends ConsumerWidget {
final Widget child;
const HomeBackScopeContainer({super.key, required this.child});
@override
Widget build(BuildContext context, ref) {
return CommonPopScope(
onPop: (context) async {
final pageLabel = ref.read(shownPageLabelProvider);
final realContext =
GlobalObjectKey(pageLabel).currentContext ?? context;
final canPop = Navigator.canPop(realContext);
if (canPop) {
Navigator.of(realContext).pop();
} else if (system.isTV && pageLabel != PageLabel.dashboard) {
ref
.read(currentPageLabelProvider.notifier)
.toPage(PageLabel.dashboard);
} else {
await ref.read(systemActionProvider.notifier).handleClose();
}
return false;
},
child: child,
);
}
}