Aves_mio0.88/lib/widgets/navigation/nav_bar/nav_bar.dart
2026-10-03 12:51:15 +02:00

378 lines
11 KiB
Dart

// lib/widgets/navigation/nav_bar/nav_bar.dart
import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/activity/activity_files_page.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar/notifications.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/aves_app_bar.dart';
import 'package:aves/widgets/navigation/nav_bar/floating.dart';
import 'package:aves/widgets/navigation/nav_item.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum AppBottomNavBarMode {
media,
activity,
}
class AppBottomNavBar extends StatefulWidget {
final Stream<DraggableScrollbarEvent> events;
// collection loaded in the `CollectionPage`, if any
final CollectionLens? currentCollection;
final AppBottomNavBarMode mode;
/// Used only when [mode] is [AppBottomNavBarMode.activity].
///
/// Mapping:
/// - 0: activity collection
/// - 1: sport
/// - 2: favourites
/// - 3: activity albums
final int activityIndex;
/// Used only when [mode] is [AppBottomNavBarMode.activity].
final ValueChanged<int>? onActivityIndexSelected;
static double get height => kBottomNavigationBarHeight + AvesFloatingBar.margin.vertical;
const AppBottomNavBar({
super.key,
required this.events,
this.currentCollection,
this.mode = AppBottomNavBarMode.media,
this.activityIndex = 0,
this.onActivityIndexSelected,
});
@override
State<AppBottomNavBar> createState() => _AppBottomNavBarState();
}
class _AppBottomNavBarState extends State<AppBottomNavBar> {
String? _lastRoute;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant AppBottomNavBar oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(AppBottomNavBar widget) {
widget.currentCollection?.filterChangeNotifier.addListener(
_onCollectionFilterChanged,
);
}
void _unregisterWidget(AppBottomNavBar widget) {
widget.currentCollection?.filterChangeNotifier.removeListener(
_onCollectionFilterChanged,
);
}
@override
Widget build(BuildContext context) {
final mediaItems = context.select<Settings, List<AvesNavItem>>(
(v) => v.bottomNavigationActions,
);
if (mediaItems.length < 2 && widget.mode == AppBottomNavBarMode.media) {
return const SizedBox();
}
Widget child = FloatingNavBar(
scrollController: PrimaryScrollController.of(context),
events: widget.events,
childHeight:
AppBottomNavBar.height +
context.select<MediaQueryData, double>(
(mq) => mq.effectiveBottomPadding,
),
child: SafeArea(
child: AvesFloatingBar(
builder: (context, backgroundColor, child) {
final navItems = widget.mode == AppBottomNavBarMode.activity ? _buildActivityNavItems(context) : _buildMediaNavItems(context, mediaItems);
return BottomNavigationBar(
items: navItems,
onTap: (index) => _goTo(context, mediaItems, index),
currentIndex: _getCurrentIndex(context, mediaItems),
type: BottomNavigationBarType.fixed,
backgroundColor: backgroundColor,
showSelectedLabels: false,
showUnselectedLabels: false,
);
},
),
),
);
// Important:
// Do not wrap the Activity bottom bar in the same Hero as the media bottom bar.
// Otherwise, when Activity replaces Collection, both bars can compete for the
// same Hero tag and trigger GlobalKey / inactive element issues.
final animate = widget.mode == AppBottomNavBarMode.media && context.select<Settings, bool>((v) => v.animate);
if (animate) {
child = Hero(
tag: 'nav-bar',
flightShuttleBuilder:
(
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) {
return MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: toHeroContext.widget,
);
},
child: child,
);
}
return child;
}
List<BottomNavigationBarItem> _buildMediaNavItems(
BuildContext context,
List<AvesNavItem> items,
) {
return [
_buildToggleItem(context),
...items.map((item) {
final label = item.getText(context);
return BottomNavigationBarItem(
icon: item.getIcon(context),
label: label,
tooltip: label,
);
}),
];
}
List<BottomNavigationBarItem> _buildActivityNavItems(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return [
_buildToggleItem(context),
// 0 - Activity collection
BottomNavigationBarItem(
icon: Icon(
Icons.route_outlined,
color: widget.activityIndex == 0 ? scheme.primary : null,
),
activeIcon: Icon(
Icons.route,
color: scheme.primary,
),
label: 'Attività',
tooltip: 'Tutte le attività',
),
// 1 - Sport
BottomNavigationBarItem(
icon: Icon(
Icons.directions_run_outlined,
color: widget.activityIndex == 1 ? scheme.primary : null,
),
activeIcon: Icon(
Icons.directions_run,
color: scheme.primary,
),
label: 'Sport',
tooltip: 'Sport',
),
// 2 - Favourite activities
BottomNavigationBarItem(
icon: Icon(
Icons.favorite_border,
color: widget.activityIndex == 2 ? scheme.primary : null,
),
activeIcon: Icon(
Icons.favorite,
color: scheme.primary,
),
label: 'Preferiti',
tooltip: 'Attività preferite',
),
// 3 - Activity albums
BottomNavigationBarItem(
icon: Icon(
Icons.collections_bookmark_outlined,
color: widget.activityIndex == 3 ? scheme.primary : null,
),
activeIcon: Icon(
Icons.collections_bookmark,
color: scheme.primary,
),
label: 'Album',
tooltip: 'Album attività',
),
];
}
BottomNavigationBarItem _buildToggleItem(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isActivityMode = widget.mode == AppBottomNavBarMode.activity;
final label = isActivityMode ? 'Foto' : 'Attività';
return BottomNavigationBarItem(
icon: Icon(
isActivityMode ? Icons.photo_library_outlined : Icons.route_outlined,
color: isActivityMode ? scheme.primary : scheme.tertiary,
),
activeIcon: Icon(
isActivityMode ? Icons.photo_library : Icons.route,
color: isActivityMode ? scheme.primary : scheme.tertiary,
),
label: label,
tooltip: label,
);
}
void _onCollectionFilterChanged() => setState(() {});
int _getCurrentIndex(BuildContext context, List<AvesNavItem> mediaItems) {
// Activity mode:
// index 0 = toggle back to photos
// index 1..4 = activity sections
if (widget.mode == AppBottomNavBarMode.activity) {
return widget.activityIndex + 1;
}
// Media mode:
// index 0 = toggle to activity
// index 1..N = configured media nav items
// current route may be null during navigation
final currentRoute = context.currentRouteName ?? _lastRoute;
_lastRoute = currentRoute;
final currentItem = mediaItems.firstWhereOrNull((item) {
final itemRoute = item.route;
if (currentRoute != itemRoute) return false;
switch (itemRoute) {
case CollectionPage.routeName:
final currentFilters = widget.currentCollection?.filters ?? {};
return const SetEquality().equals(
currentFilters,
item.filters ?? {},
);
default:
return true;
}
});
// +1 because index 0 is the activity/photo toggle.
final currentIndex = currentItem != null ? mediaItems.indexOf(currentItem) + 1 : 1;
return currentIndex;
}
void _goTo(BuildContext context, List<AvesNavItem> mediaItems, int index) {
if (index == 0) {
_toggleActivityMode(context, mediaItems);
return;
}
if (widget.mode == AppBottomNavBarMode.activity) {
widget.onActivityIndexSelected?.call(index - 1);
return;
}
// Media mode:
// -1 because index 0 is the activity/photo toggle.
final item = mediaItems[index - 1];
item.goTo(context, topLevel: null);
}
void _toggleActivityMode(
BuildContext context,
List<AvesNavItem> mediaItems,
) {
if (widget.mode == AppBottomNavBarMode.activity) {
final collectionItem = mediaItems.firstWhereOrNull(
(item) => item.route == CollectionPage.routeName,
);
if (collectionItem != null) {
collectionItem.goTo(context, topLevel: null);
return;
}
// Fallback. In normal conditions this should not be used because the
// default bottom nav actions always contain CollectionPage.routeName.
Navigator.of(context).pushReplacement(
MaterialPageRoute(
settings: const RouteSettings(
name: CollectionPage.routeName,
),
builder: (_) => const SizedBox(),
),
);
return;
}
// Important:
// Use pushReplacement, not push, so the media Collection and its
// GridItemTracker/GlobalKeys are removed while Activity is displayed.
Navigator.of(context).pushReplacement(
MaterialPageRoute(
settings: const RouteSettings(
name: ActivityFilesPage.routeName,
),
builder: (_) => const ActivityFilesPage(),
),
);
}
}
class NavBarPaddingSliver extends StatelessWidget {
const NavBarPaddingSliver({super.key});
@override
Widget build(BuildContext context) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>(
(v) => v.value.canNavigate,
);
final enableBottomNavigationBar = context.select<Settings, bool>(
(v) => v.enableBottomNavigationBar,
);
final showBottomNavigationBar = canNavigate && enableBottomNavigationBar;
return SliverToBoxAdapter(
child: SizedBox(
height: showBottomNavigationBar ? AppBottomNavBar.height : 0,
),
);
}
}