378 lines
11 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|