mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-07 21:48:14 -08:00
408 lines
20 KiB
Dart
408 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:iconsax_plus/iconsax_plus.dart';
|
|
import 'package:overflow_view/overflow_view.dart';
|
|
|
|
import 'package:fladder/models/collection_types.dart';
|
|
import 'package:fladder/models/library_filter_model.dart';
|
|
import 'package:fladder/providers/settings/client_settings_provider.dart';
|
|
import 'package:fladder/providers/views_provider.dart';
|
|
import 'package:fladder/routes/auto_router.dart';
|
|
import 'package:fladder/routes/auto_router.gr.dart';
|
|
import 'package:fladder/screens/metadata/refresh_metadata.dart';
|
|
import 'package:fladder/screens/shared/animated_fade_size.dart';
|
|
import 'package:fladder/theme.dart';
|
|
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
|
|
import 'package:fladder/util/fladder_image.dart';
|
|
import 'package:fladder/util/localization_helper.dart';
|
|
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
|
|
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
|
|
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
|
|
import 'package:fladder/widgets/navigation_scaffold/components/navigation_button.dart';
|
|
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
|
|
import 'package:fladder/widgets/shared/custom_tooltip.dart';
|
|
import 'package:fladder/widgets/shared/item_actions.dart';
|
|
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
|
|
|
|
final navBarNode = FocusNode();
|
|
|
|
class SideNavigationBar extends ConsumerStatefulWidget {
|
|
final int currentIndex;
|
|
final List<DestinationModel> destinations;
|
|
final String currentLocation;
|
|
final Widget child;
|
|
final GlobalKey<ScaffoldState> scaffoldKey;
|
|
const SideNavigationBar({
|
|
required this.currentIndex,
|
|
required this.destinations,
|
|
required this.currentLocation,
|
|
required this.child,
|
|
required this.scaffoldKey,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<ConsumerStatefulWidget> createState() => _SideNavigationBarState();
|
|
}
|
|
|
|
class _SideNavigationBarState extends ConsumerState<SideNavigationBar> {
|
|
bool expandedSideBar = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final views = ref.watch(viewsProvider.select((value) => value.views));
|
|
final usePostersForLibrary = ref.watch(clientSettingsProvider.select((value) => value.usePosterForLibrary));
|
|
|
|
final expandedWidth = 200.0;
|
|
final padding = MediaQuery.paddingOf(context);
|
|
|
|
final collapsedWidth = 90 + padding.left;
|
|
final largeBar = AdaptiveLayout.layoutModeOf(context) != LayoutMode.single;
|
|
final fullyExpanded = largeBar ? expandedSideBar : false;
|
|
final shouldExpand = fullyExpanded;
|
|
final isDesktop = AdaptiveLayout.of(context).isDesktop;
|
|
|
|
final fullScreenChildRoute = fullScreenRoutes.contains(context.router.current.name);
|
|
|
|
final hasOverlay = AdaptiveLayout.layoutModeOf(context) == LayoutMode.dual ||
|
|
homeRoutes.any((element) => element.name.contains(context.router.current.name));
|
|
|
|
return Stack(
|
|
children: [
|
|
AdaptiveLayoutBuilder(
|
|
adaptiveLayout: AdaptiveLayout.of(context).copyWith(
|
|
// -0.1 offset to fix single visible pixel line
|
|
sideBarWidth: (fullyExpanded ? expandedWidth : collapsedWidth) - 0.1,
|
|
),
|
|
child: (context) => widget.child,
|
|
),
|
|
FocusTraversalGroup(
|
|
policy: _RailTraversalPolicy(),
|
|
child: IgnorePointer(
|
|
ignoring: !hasOverlay || fullScreenChildRoute,
|
|
child: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 250),
|
|
opacity: !fullScreenChildRoute ? 1 : 0,
|
|
child: Container(
|
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85),
|
|
width: shouldExpand ? expandedWidth : collapsedWidth,
|
|
child: Padding(
|
|
key: const Key('navigation_rail'),
|
|
padding: padding.copyWith(right: 0, top: isDesktop ? padding.top : null),
|
|
child: Column(
|
|
spacing: 2,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (expandedSideBar) ...[
|
|
Expanded(child: Text(context.localized.navigation)),
|
|
],
|
|
IconButton(
|
|
onPressed: !largeBar
|
|
? () => widget.scaffoldKey.currentState?.openDrawer()
|
|
: () => setState(() => expandedSideBar = !expandedSideBar),
|
|
icon: Icon(
|
|
largeBar && expandedSideBar ? IconsaxPlusLinear.sidebar_left : IconsaxPlusLinear.menu,
|
|
),
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(
|
|
alpha: largeBar && expandedSideBar ? 0.65 : 1,
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
if (largeBar) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4).copyWith(bottom: expandedSideBar ? 10 : 0),
|
|
child: AnimatedFadeSize(
|
|
duration: const Duration(milliseconds: 250),
|
|
child: shouldExpand ? actionButton(context).extended : actionButton(context).normal,
|
|
),
|
|
),
|
|
],
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: !largeBar ? MainAxisAlignment.center : MainAxisAlignment.start,
|
|
children: [
|
|
...widget.destinations.mapIndexed(
|
|
(index, destination) => CustomTooltip(
|
|
tooltipContent: expandedSideBar
|
|
? null
|
|
: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Text(
|
|
destination.label,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
),
|
|
position: TooltipPosition.right,
|
|
child: destination.toNavigationButton(
|
|
widget.currentIndex == index,
|
|
true,
|
|
navFocusNode: index == 0,
|
|
shouldExpand,
|
|
),
|
|
),
|
|
),
|
|
if (views.isNotEmpty && largeBar) ...[
|
|
const Divider(
|
|
indent: 32,
|
|
endIndent: 32,
|
|
),
|
|
Flexible(
|
|
child: OverflowView.flexible(
|
|
direction: Axis.vertical,
|
|
spacing: 4,
|
|
children: views.map(
|
|
(view) {
|
|
final selected = context.router.currentUrl.contains(view.id);
|
|
final actions = [
|
|
ItemActionButton(
|
|
label: Text(context.localized.scanLibrary),
|
|
icon: const Icon(IconsaxPlusLinear.refresh),
|
|
action: () => showRefreshPopup(context, view.id, view.name),
|
|
)
|
|
];
|
|
return CustomTooltip(
|
|
tooltipContent: expandedSideBar
|
|
? null
|
|
: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Text(
|
|
view.name,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
),
|
|
position: TooltipPosition.right,
|
|
child: view.toNavigationButton(
|
|
selected,
|
|
true,
|
|
shouldExpand,
|
|
() => context.pushRoute(
|
|
LibrarySearchRoute(
|
|
viewModelId: view.id,
|
|
).withFilter(view.collectionType.defaultFilters),
|
|
),
|
|
onLongPress: () => showBottomSheetPill(
|
|
context: context,
|
|
content: (context, scrollController) => ListView(
|
|
shrinkWrap: true,
|
|
controller: scrollController,
|
|
children: actions.listTileItems(context, useIcons: true),
|
|
),
|
|
),
|
|
customIcon: usePostersForLibrary
|
|
? ClipRRect(
|
|
borderRadius: FladderTheme.smallShape.borderRadius,
|
|
child: SizedBox.square(
|
|
dimension: 50,
|
|
child: FladderImage(
|
|
image: view.imageData?.primary,
|
|
placeHolder: Card(
|
|
child: Icon(
|
|
selected
|
|
? view.collectionType.icon
|
|
: view.collectionType.iconOutlined,
|
|
),
|
|
),
|
|
decodeHeight: 64,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
trailing: actions,
|
|
),
|
|
);
|
|
},
|
|
).toList(),
|
|
builder: (context, remaining) {
|
|
return CustomTooltip(
|
|
tooltipContent: expandedSideBar
|
|
? null
|
|
: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Text(
|
|
context.localized.moreOptions,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
),
|
|
position: TooltipPosition.right,
|
|
child: PopupMenuButton(
|
|
iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45),
|
|
padding: EdgeInsets.zero,
|
|
tooltip: "",
|
|
icon: NavigationButton(
|
|
label: context.localized.other,
|
|
selectedIcon: const Icon(IconsaxPlusLinear.arrow_square_down),
|
|
icon: const Icon(IconsaxPlusLinear.arrow_square_down),
|
|
expanded: shouldExpand,
|
|
customIcon: usePostersForLibrary
|
|
? ClipRRect(
|
|
borderRadius: FladderTheme.smallShape.borderRadius,
|
|
child: const SizedBox.square(
|
|
dimension: 50,
|
|
child: Card(
|
|
child: Icon(IconsaxPlusLinear.arrow_square_down),
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
horizontal: true,
|
|
),
|
|
itemBuilder: (context) => views
|
|
.sublist(views.length - remaining)
|
|
.map(
|
|
(e) => PopupMenuItem(
|
|
onTap: () => context.pushRoute(LibrarySearchRoute(
|
|
viewModelId: e.id,
|
|
).withFilter(e.collectionType.defaultFilters)),
|
|
child: Row(
|
|
spacing: 8,
|
|
children: [
|
|
usePostersForLibrary
|
|
? Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: ClipRRect(
|
|
borderRadius: FladderTheme.smallShape.borderRadius,
|
|
child: SizedBox.square(
|
|
dimension: 45,
|
|
child: FladderImage(
|
|
image: e.imageData?.primary,
|
|
placeHolder: Card(
|
|
child: Icon(
|
|
e.collectionType.iconOutlined,
|
|
),
|
|
),
|
|
decodeHeight: 64,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: Icon(e.collectionType.iconOutlined),
|
|
Text(e.name),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
NavigationButton(
|
|
label: context.localized.settings,
|
|
selected: widget.currentLocation.contains(const SettingsRoute().routeName),
|
|
selectedIcon: const Icon(IconsaxPlusBold.setting_3),
|
|
horizontal: true,
|
|
expanded: shouldExpand,
|
|
icon: const ExcludeFocusTraversal(child: SettingsUserIcon()),
|
|
onPressed: () {
|
|
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) {
|
|
context.router.push(const SettingsRoute());
|
|
} else {
|
|
context.router.push(const ClientSettingsRoute());
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
AdaptiveFab actionButton(BuildContext context) {
|
|
return ((widget.currentIndex >= 0 && widget.currentIndex < widget.destinations.length)
|
|
? widget.destinations[widget.currentIndex].floatingActionButton
|
|
: null) ??
|
|
AdaptiveFab(
|
|
context: context,
|
|
title: context.localized.search,
|
|
key: const Key("Search"),
|
|
onPressed: () => context.router.navigate(LibrarySearchRoute()),
|
|
child: const Icon(IconsaxPlusLinear.search_normal_1),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RailTraversalPolicy extends ReadingOrderTraversalPolicy {
|
|
_RailTraversalPolicy();
|
|
|
|
@override
|
|
bool inDirection(FocusNode currentNode, TraversalDirection direction) {
|
|
if (direction == TraversalDirection.left) {
|
|
return false;
|
|
}
|
|
if (direction == TraversalDirection.right) {
|
|
if (lastMainFocus != null && _isLaidOut(lastMainFocus!)) {
|
|
lastMainFocus!.requestFocus();
|
|
return true;
|
|
} else {
|
|
return super.inDirection(currentNode, direction);
|
|
}
|
|
}
|
|
if (direction == TraversalDirection.up || direction == TraversalDirection.down) {
|
|
final scope = currentNode.enclosingScope;
|
|
if (scope == null) {
|
|
return false;
|
|
}
|
|
|
|
final candidates = scope.traversalDescendants
|
|
.where((n) => n.canRequestFocus && FocusTraversalGroup.maybeOfNode(n) == this && _isLaidOut(n))
|
|
.toList();
|
|
|
|
if (candidates.isEmpty) return false;
|
|
|
|
final sorted = sortDescendants(candidates, currentNode).toList();
|
|
|
|
var index = sorted.indexOf(currentNode);
|
|
if (index == -1) {
|
|
index = direction == TraversalDirection.down ? -1 : sorted.length;
|
|
}
|
|
|
|
final nextIndex = direction == TraversalDirection.down ? index + 1 : index - 1;
|
|
|
|
if (nextIndex < 0 || nextIndex >= sorted.length) {
|
|
return true;
|
|
}
|
|
|
|
requestFocusCallback(sorted[nextIndex]);
|
|
return true;
|
|
}
|
|
return super.inDirection(currentNode, direction);
|
|
}
|
|
}
|
|
|
|
bool _isLaidOut(FocusNode node) {
|
|
final ro = node.context?.findRenderObject();
|
|
return ro is RenderBox && ro.hasSize;
|
|
}
|
|
|
|
bool isNodeInCurrentRoute(FocusNode node) {
|
|
if (!node.canRequestFocus) return false;
|
|
if (node.context == null) return false;
|
|
|
|
final nearestScope = FocusScope.of(node.context!);
|
|
return nearestScope.hasFocus || nearestScope.isFirstFocus;
|
|
}
|