1100 lines
34 KiB
Dart
1100 lines
34 KiB
Dart
// lib/widgets/collection/app_bar/collection_app_bar.dart
|
||
|
||
import 'dart:async';
|
||
import 'dart:math';
|
||
|
||
import 'package:aves/app_mode.dart';
|
||
import 'package:aves/model/entry/entry.dart';
|
||
import 'package:aves/model/filters/container/dynamic_album.dart';
|
||
import 'package:aves/model/filters/container/set_and.dart';
|
||
import 'package:aves/model/filters/filters.dart';
|
||
import 'package:aves/model/filters/query.dart';
|
||
import 'package:aves/model/filters/trash.dart';
|
||
import 'package:aves/model/query.dart';
|
||
import 'package:aves/model/selection.dart';
|
||
import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
||
import 'package:aves/model/settings/settings.dart';
|
||
import 'package:aves/model/source/collection_lens.dart';
|
||
import 'package:aves/model/source/collection_source.dart';
|
||
import 'package:aves/theme/durations.dart';
|
||
import 'package:aves/theme/icons.dart';
|
||
import 'package:aves/theme/themes.dart';
|
||
import 'package:aves/view/view.dart';
|
||
import 'package:aves/widgets/aves_app.dart';
|
||
import 'package:aves/widgets/collection/collection_page.dart';
|
||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||
import 'package:aves/widgets/collection/filter_bar.dart';
|
||
import 'package:aves/widgets/collection/query_bar.dart';
|
||
import 'package:aves/widgets/common/action_controls/quick_choosers/move_button.dart';
|
||
import 'package:aves/widgets/common/action_controls/quick_choosers/rate_button.dart';
|
||
import 'package:aves/widgets/common/action_controls/quick_choosers/tag_button.dart';
|
||
import 'package:aves/widgets/common/action_controls/togglers/favourite.dart';
|
||
import 'package:aves/widgets/common/action_controls/togglers/title_search.dart';
|
||
import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart';
|
||
import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
|
||
import 'package:aves/widgets/common/basic/popup/container.dart';
|
||
import 'package:aves/widgets/common/basic/popup/expansion_panel.dart';
|
||
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
|
||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||
import 'package:aves/widgets/common/identity/aves_app_bar.dart';
|
||
import 'package:aves/widgets/common/identity/buttons/captioned_button.dart';
|
||
import 'package:aves/widgets/common/search/route.dart';
|
||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||
import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
|
||
import 'package:aves/widgets/search/collection_search_delegate.dart';
|
||
import 'package:aves/widgets/viewer/controls/notifications.dart';
|
||
import 'package:aves_model/aves_model.dart';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
// Remote
|
||
import 'package:aves/widgets/collection/remote_status_button.dart';
|
||
|
||
// Cast (modulare)
|
||
import 'cast/cast_icon_button.dart';
|
||
import 'cast/cast_bar_inline.dart';
|
||
|
||
import 'package:aves/widgets/cast/cast_controller.dart';
|
||
|
||
import 'package:aves/widgets/activity/activity_files_page.dart';
|
||
|
||
class CollectionAppBar extends StatefulWidget {
|
||
final ValueNotifier<double> appBarHeightNotifier;
|
||
final ScrollController scrollController;
|
||
final CollectionLens collection;
|
||
|
||
const CollectionAppBar({
|
||
super.key,
|
||
required this.appBarHeightNotifier,
|
||
required this.scrollController,
|
||
required this.collection,
|
||
});
|
||
|
||
@override
|
||
State<CollectionAppBar> createState() => _CollectionAppBarState();
|
||
}
|
||
|
||
class _CollectionAppBarState extends State<CollectionAppBar>
|
||
with RouteAware, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||
final Set<StreamSubscription> _subscriptions = {};
|
||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||
late AnimationController _browseToSelectAnimation;
|
||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||
final FocusNode _queryBarFocusNode = FocusNode();
|
||
late final Listenable _queryFocusRequestNotifier;
|
||
double _statusBarHeight = 0;
|
||
|
||
static const double _castBarHeight = 48;
|
||
|
||
CollectionLens get collection => widget.collection;
|
||
|
||
bool get isTrash => collection.filters.contains(TrashFilter.instance);
|
||
|
||
CollectionSource get source => collection.source;
|
||
|
||
Set<CollectionFilter> get visibleFilters => collection.filters
|
||
.where((v) => !(v is QueryFilter && v.live) && v is! TrashFilter)
|
||
.toSet();
|
||
|
||
bool get showFilterBar => visibleFilters.isNotEmpty;
|
||
|
||
void _onBackendChanged() {
|
||
if (!mounted) return;
|
||
setState(() {});
|
||
}
|
||
|
||
|
||
static const _sortOptions = [
|
||
EntrySortFactor.date,
|
||
EntrySortFactor.size,
|
||
EntrySortFactor.name,
|
||
EntrySortFactor.rating,
|
||
EntrySortFactor.duration,
|
||
EntrySortFactor.path,
|
||
];
|
||
|
||
static const _sectionOptions = [
|
||
EntrySectionFactor.album,
|
||
EntrySectionFactor.month,
|
||
EntrySectionFactor.day,
|
||
EntrySectionFactor.none,
|
||
];
|
||
|
||
static const _layoutOptions = [
|
||
TileLayout.mosaic,
|
||
TileLayout.grid,
|
||
TileLayout.list,
|
||
];
|
||
|
||
static const _trashSelectionQuickActions = [
|
||
EntrySetAction.delete,
|
||
EntrySetAction.restore,
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
CastController.instance.backendNotifier.addListener(_onBackendChanged);
|
||
|
||
|
||
final query = context.read<Query>();
|
||
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
|
||
_queryFocusRequestNotifier = query.focusRequestNotifier;
|
||
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
|
||
_queryBarFocusNode.addListener(_onQueryBarFocusChanged);
|
||
|
||
_browseToSelectAnimation = AnimationController(
|
||
duration: context.read<DurationsData>().iconAnimation,
|
||
vsync: this,
|
||
);
|
||
|
||
_isSelectingNotifier.addListener(_onActivityChanged);
|
||
|
||
// Importante: ricostruisce la AppBar quando appare/scompare la cast bar
|
||
CastController.instance.isCastingNotifier.addListener(_onCastVisibilityChanged);
|
||
|
||
_registerWidget(widget);
|
||
WidgetsBinding.instance.addObserver(this);
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_updateStatusBarHeight();
|
||
_onFilterChanged();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
final route = ModalRoute.of(context);
|
||
if (route is PageRoute) {
|
||
AvesApp.pageRouteObserver.subscribe(this, route);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant CollectionAppBar oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
_unregisterWidget(oldWidget);
|
||
_registerWidget(widget);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unregisterWidget(widget);
|
||
|
||
CastController.instance.isCastingNotifier.removeListener(_onCastVisibilityChanged);
|
||
CastController.instance.backendNotifier.removeListener(_onBackendChanged);
|
||
|
||
|
||
_queryBarFocusNode.dispose();
|
||
_queryFocusRequestNotifier.removeListener(_onQueryFocusRequest);
|
||
_queryBarFocusNode.removeListener(_onQueryBarFocusChanged);
|
||
_isSelectingNotifier.dispose();
|
||
_browseToSelectAnimation.dispose();
|
||
|
||
_subscriptions
|
||
..forEach((sub) => sub.cancel())
|
||
..clear();
|
||
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
AvesApp.pageRouteObserver.unsubscribe(this);
|
||
super.dispose();
|
||
}
|
||
|
||
void _registerWidget(CollectionAppBar widget) {
|
||
widget.collection.filterChangeNotifier.addListener(_onFilterChanged);
|
||
}
|
||
|
||
void _unregisterWidget(CollectionAppBar widget) {
|
||
widget.collection.filterChangeNotifier.removeListener(_onFilterChanged);
|
||
}
|
||
|
||
void _onCastVisibilityChanged() {
|
||
if (!mounted) return;
|
||
setState(() {});
|
||
_updateAppBarHeight();
|
||
}
|
||
|
||
void _onQueryFocusRequest() {
|
||
if (_queryFocusRequestNotifier is ValueNotifier<bool> &&
|
||
(_queryFocusRequestNotifier as ValueNotifier<bool>).value) {
|
||
_queryBarFocusNode.requestFocus();
|
||
}
|
||
}
|
||
|
||
void _onQueryBarFocusChanged() {
|
||
if (_queryBarFocusNode.hasFocus) {
|
||
_scrollToTop();
|
||
}
|
||
_updateAppBarHeight();
|
||
}
|
||
|
||
void _onActivityChanged() {
|
||
if (!mounted) return;
|
||
|
||
if (context.read<Selection<AvesEntry>>().isSelecting) {
|
||
_browseToSelectAnimation.forward();
|
||
} else {
|
||
_browseToSelectAnimation.reverse();
|
||
}
|
||
_updateAppBarHeight();
|
||
}
|
||
|
||
void _onFilterChanged() {
|
||
_updateAppBarHeight();
|
||
|
||
final filters = collection.filters;
|
||
if (filters.isNotEmpty) {
|
||
final selection = context.read<Selection<AvesEntry>>();
|
||
if (selection.isSelecting) {
|
||
final toRemove = selection.selectedItems
|
||
.where((entry) => !filters.every((f) => f.test(entry)))
|
||
.toSet();
|
||
selection.removeFromSelection(toRemove);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _scrollToTop() => widget.scrollController.jumpTo(0);
|
||
|
||
void _updateStatusBarHeight() {
|
||
if (!mounted) return;
|
||
final padding = MediaQuery.paddingOf(context);
|
||
if (_statusBarHeight != padding.top) {
|
||
setState(() => _statusBarHeight = padding.top);
|
||
_updateAppBarHeight();
|
||
}
|
||
}
|
||
|
||
// FIX PRINCIPALE: altezza reale completa della AppBar
|
||
void _updateAppBarHeight() {
|
||
widget.appBarHeightNotifier.value =
|
||
_statusBarHeight +
|
||
AvesAppBar.appBarHeightForContentHeight(appBarContentHeight);
|
||
}
|
||
|
||
@override
|
||
void didPushNext() {
|
||
_queryBarFocusNode.unfocus();
|
||
}
|
||
|
||
@override
|
||
void didChangeMetrics() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight());
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||
final selection = context.watch<Selection<AvesEntry>>();
|
||
final isSelecting = selection.isSelecting;
|
||
_isSelectingNotifier.value = isSelecting;
|
||
|
||
return NotificationListener<ScrollNotification>(
|
||
onNotification: (notification) => false,
|
||
child: AnimatedBuilder(
|
||
animation: collection.filterChangeNotifier,
|
||
builder: (context, child) {
|
||
final canRemoveFilters = appMode != AppMode.pickFilteredMediaInternal;
|
||
|
||
return Selector<Query, bool>(
|
||
selector: (context, query) => query.enabled,
|
||
builder: (context, queryEnabled, child) {
|
||
return Selector<Settings, List<EntrySetAction>>(
|
||
selector: (context, s) => s.collectionBrowsingQuickActions,
|
||
builder: (context, _, child) {
|
||
final useTvLayout = settings.useTvLayout;
|
||
final onFilterTap =
|
||
canRemoveFilters ? collection.removeFilter : null;
|
||
|
||
return AvesAppBar(
|
||
contentHeight: appBarContentHeight,
|
||
pinned: context.select<Selection<AvesEntry>, bool>(
|
||
(selection) => selection.isSelecting,
|
||
),
|
||
leading: _buildAppBarLeading(
|
||
hasDrawer: appMode.canNavigate,
|
||
isSelecting: isSelecting,
|
||
),
|
||
title: _buildAppBarTitle(isSelecting),
|
||
actions: (context, maxWidth) => _buildAppBarActions(
|
||
context: context,
|
||
selection: selection,
|
||
maxWidth: maxWidth,
|
||
useTvLayout: useTvLayout,
|
||
),
|
||
bottom: PreferredSize(
|
||
preferredSize: Size.fromHeight(
|
||
_computeBottomHeight(context, queryEnabled),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (CastController.instance.isCastingNotifier.value)
|
||
const CastBarInline(),
|
||
|
||
if (useTvLayout)
|
||
SizedBox(
|
||
height: CaptionedButton.getTelevisionButtonHeight(context),
|
||
child: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
scrollDirection: Axis.horizontal,
|
||
children: _buildActions(
|
||
context,
|
||
selection,
|
||
double.infinity,
|
||
),
|
||
),
|
||
),
|
||
|
||
if (showFilterBar)
|
||
NotificationListener(
|
||
onNotification: (notification) {
|
||
if (notification is SelectFilterNotification) {
|
||
collection.addFilters({notification.filter});
|
||
return true;
|
||
} else if (notification is DecomposeFilterNotification) {
|
||
final filter = notification.filter;
|
||
if (filter is DynamicAlbumFilter) {
|
||
final innerFilter = filter.filter;
|
||
final newFilters =
|
||
innerFilter is SetAndFilter
|
||
? innerFilter.innerFilters
|
||
: {innerFilter};
|
||
collection.addFilters(newFilters);
|
||
collection.removeFilter(filter);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
child: FilterBar(
|
||
filters: visibleFilters,
|
||
onTap: onFilterTap,
|
||
onRemove: onFilterTap,
|
||
),
|
||
),
|
||
|
||
if (queryEnabled)
|
||
EntryQueryBar(
|
||
queryNotifier:
|
||
context.select<Query, ValueNotifier<String>>(
|
||
(query) => query.queryNotifier,
|
||
),
|
||
focusNode: _queryBarFocusNode,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
transitionKey: isSelecting,
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
double _computeBottomHeight(BuildContext context, bool queryEnabled) {
|
||
double height = 0;
|
||
|
||
if (CastController.instance.isCastingNotifier.value) {
|
||
height += _castBarHeight;
|
||
}
|
||
|
||
if (settings.useTvLayout) {
|
||
height += CaptionedButton.getTelevisionButtonHeight(context);
|
||
}
|
||
|
||
if (showFilterBar) {
|
||
height += FilterBar.preferredHeight;
|
||
}
|
||
|
||
if (queryEnabled) {
|
||
final textScaler = MediaQuery.textScalerOf(context);
|
||
height += EntryQueryBar.getPreferredHeight(textScaler);
|
||
}
|
||
|
||
return height;
|
||
}
|
||
|
||
// -------------------------------------------------------------
|
||
// ACTIONS
|
||
// -------------------------------------------------------------
|
||
List<Widget> _buildAppBarActions({
|
||
required BuildContext context,
|
||
required Selection<AvesEntry> selection,
|
||
required double maxWidth,
|
||
required bool useTvLayout,
|
||
}) {
|
||
final statusButton = Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||
child: RemoteStatusButton(source: source),
|
||
);
|
||
|
||
if (useTvLayout) {
|
||
return [
|
||
statusButton,
|
||
const CastIconButton(),
|
||
];
|
||
}
|
||
|
||
|
||
/*return [
|
||
statusButton,
|
||
|
||
SizedBox(
|
||
width: 48,
|
||
height: kToolbarHeight,
|
||
child: Center(
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
// LETTERA SOPRA, IN ALTO A DESTRA, STESSO COLORE DELL’ICONA
|
||
Positioned(
|
||
top: 2,
|
||
right: 6,
|
||
child: ValueListenableBuilder(
|
||
valueListenable: CastController.instance.backendNotifier,
|
||
builder: (_, __, ___) {
|
||
return ValueListenableBuilder(
|
||
valueListenable: CastController.instance.castIconState,
|
||
builder: (_, __, ___) {
|
||
return Text(
|
||
CastController.instance.backendLetter,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.bold,
|
||
color: CastController.instance.iconColor, // ← ECCO QUI
|
||
//color: Colors.red,
|
||
height: 1.0,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
const CastIconButton(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
..._buildActions(context, selection, maxWidth),
|
||
];
|
||
*/
|
||
return [
|
||
statusButton,
|
||
|
||
SizedBox(
|
||
width: 48,
|
||
height: kToolbarHeight,
|
||
child: Center(
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
// SCRITTA IN BASSO, CENTRATA, STESSO COLORE DELL’ICONA
|
||
Positioned(
|
||
bottom: 0, // leggermente sotto l’icona
|
||
child: ValueListenableBuilder(
|
||
|
||
valueListenable: CastController.instance.backendNotifier,
|
||
builder: (_, __, ___) {
|
||
return ValueListenableBuilder(
|
||
valueListenable: CastController.instance.castIconState,
|
||
builder: (_, __, ___) {
|
||
return Text(
|
||
CastController.instance.backendNotifier.value.name,
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w600,
|
||
color: CastController.instance.castIconState.value == CastIconState.connected
|
||
? Colors.blueAccent
|
||
: Colors.black,
|
||
height: 1.0,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
),
|
||
),
|
||
|
||
const CastIconButton(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
..._buildActions(context, selection, maxWidth),
|
||
];
|
||
|
||
}
|
||
|
||
double get appBarContentHeight {
|
||
final textScaler = MediaQuery.textScalerOf(context);
|
||
double height = textScaler.scale(kToolbarHeight);
|
||
|
||
if (settings.useTvLayout) {
|
||
height += CaptionedButton.getTelevisionButtonHeight(context);
|
||
}
|
||
if (showFilterBar) {
|
||
height += FilterBar.preferredHeight;
|
||
}
|
||
if (context.read<Query>().enabled) {
|
||
height += EntryQueryBar.getPreferredHeight(textScaler);
|
||
}
|
||
|
||
if (CastController.instance.isCastingNotifier.value) {
|
||
height += _castBarHeight;
|
||
}
|
||
|
||
return height;
|
||
}
|
||
|
||
Widget? _buildAppBarLeading({
|
||
required bool hasDrawer,
|
||
required bool isSelecting,
|
||
}) {
|
||
if (settings.useTvLayout) return null;
|
||
|
||
if (!hasDrawer) {
|
||
return const CloseButton();
|
||
}
|
||
|
||
VoidCallback? onPressed;
|
||
String? tooltip;
|
||
|
||
if (isSelecting) {
|
||
onPressed = () => context.read<Selection<AvesEntry>>().browse();
|
||
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
|
||
} else {
|
||
onPressed = Scaffold.of(context).openDrawer;
|
||
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
||
}
|
||
|
||
return IconButton(
|
||
key: const Key('appbar-leading-button'),
|
||
icon: AnimatedIcon(
|
||
icon: AnimatedIcons.menu_arrow,
|
||
progress: _browseToSelectAnimation,
|
||
),
|
||
onPressed: onPressed,
|
||
tooltip: tooltip,
|
||
);
|
||
}
|
||
|
||
Widget _buildAppBarTitle(bool isSelecting) {
|
||
final l10n = context.l10n;
|
||
|
||
if (isSelecting) {
|
||
return Selector<Selection<AvesEntry>?, int>(
|
||
selector: (context, selection) => selection?.selectedItems.length ?? 0,
|
||
builder: (context, count, child) => Text(
|
||
count == 0 ? l10n.collectionSelectPageTitle : l10n.itemCount(count),
|
||
softWrap: false,
|
||
overflow: TextOverflow.fade,
|
||
maxLines: 1,
|
||
),
|
||
);
|
||
} else {
|
||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||
|
||
Widget title = Text(
|
||
appMode.isPickingMedia
|
||
? l10n.collectionPickPageTitle
|
||
: (isTrash ? l10n.binPageTitle : l10n.collectionPageTitle),
|
||
softWrap: false,
|
||
overflow: TextOverflow.fade,
|
||
maxLines: 1,
|
||
);
|
||
|
||
if (appMode == AppMode.main) {
|
||
title = SourceStateAwareAppBarTitle(
|
||
title: title,
|
||
source: source,
|
||
);
|
||
}
|
||
|
||
return InteractiveAppBarTitle(
|
||
onTap: appMode.canNavigate ? _goToSearch : null,
|
||
child: title,
|
||
);
|
||
}
|
||
}
|
||
|
||
void _goToSearch() {
|
||
Navigator.maybeOf(context)?.push(
|
||
SearchPageRoute(
|
||
delegate: CollectionSearchDelegate(
|
||
searchFieldLabel: context.l10n.searchCollectionFieldHint,
|
||
searchFieldStyle: Themes.searchFieldStyle(context),
|
||
source: collection.source,
|
||
parentCollection: collection,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildActions(
|
||
BuildContext context, Selection<AvesEntry> selection, double maxWidth) {
|
||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||
final isSelecting = selection.isSelecting;
|
||
final selectedItemCount = selection.selectedItems.length;
|
||
|
||
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
|
||
action,
|
||
appMode: appMode,
|
||
isSelecting: isSelecting,
|
||
itemCount: collection.entryCount,
|
||
selectedItemCount: selectedItemCount,
|
||
isTrash: isTrash,
|
||
);
|
||
|
||
bool canApply(EntrySetAction action) => _actionDelegate.canApply(
|
||
action,
|
||
isSelecting: isSelecting,
|
||
collection: collection,
|
||
selectedItemCount: selectedItemCount,
|
||
);
|
||
|
||
return settings.useTvLayout
|
||
? _buildTelevisionActions(
|
||
context: context,
|
||
appMode: appMode,
|
||
selection: selection,
|
||
isVisible: isVisible,
|
||
canApply: canApply,
|
||
)
|
||
: _buildMobileActions(
|
||
context: context,
|
||
appMode: appMode,
|
||
selection: selection,
|
||
maxWidth: maxWidth,
|
||
isVisible: isVisible,
|
||
canApply: canApply,
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildTelevisionActions({
|
||
required BuildContext context,
|
||
required AppMode appMode,
|
||
required Selection<AvesEntry> selection,
|
||
required bool Function(EntrySetAction action) isVisible,
|
||
required bool Function(EntrySetAction action) canApply,
|
||
}) {
|
||
final isSelecting = selection.isSelecting;
|
||
|
||
return [
|
||
...EntrySetActions.general,
|
||
...isSelecting
|
||
? EntrySetActions.pageSelection
|
||
: EntrySetActions.pageBrowsing,
|
||
].nonNulls.where(isVisible).map((action) {
|
||
final enabled = canApply(action);
|
||
return CaptionedButton(
|
||
iconButtonBuilder: (context, focusNode) => _buildButtonIcon(
|
||
context,
|
||
action,
|
||
enabled: enabled,
|
||
selection: selection,
|
||
focusNode: focusNode,
|
||
),
|
||
captionText: _buildButtonCaption(
|
||
context,
|
||
action,
|
||
enabled: enabled,
|
||
),
|
||
onPressed: enabled ? () => _onActionSelected(action) : null,
|
||
);
|
||
}).toList();
|
||
}
|
||
|
||
static double _iconButtonWidth(BuildContext context) {
|
||
const defaultPadding = EdgeInsets.all(8);
|
||
const defaultIconSize = 24.0;
|
||
return defaultPadding.horizontal +
|
||
MediaQuery.textScalerOf(context).scale(defaultIconSize);
|
||
}
|
||
|
||
List<Widget> _buildMobileActions({
|
||
required BuildContext context,
|
||
required AppMode appMode,
|
||
required Selection<AvesEntry> selection,
|
||
required double maxWidth,
|
||
required bool Function(EntrySetAction action) isVisible,
|
||
required bool Function(EntrySetAction action) canApply,
|
||
}) {
|
||
final availableCount = (maxWidth / _iconButtonWidth(context)).floor();
|
||
|
||
final isSelecting = selection.isSelecting;
|
||
final selectedItemCount = selection.selectedItems.length;
|
||
final hasSelection = selectedItemCount > 0;
|
||
|
||
final browsingQuickActions = settings.collectionBrowsingQuickActions;
|
||
final selectionQuickActions =
|
||
isTrash ? _trashSelectionQuickActions : settings.collectionSelectionQuickActions;
|
||
|
||
final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions)
|
||
.take(max(0, availableCount - 1))
|
||
.toList();
|
||
|
||
final quickActionButtons = quickActions
|
||
.where(isVisible)
|
||
.map((action) => _buildButtonIcon(
|
||
context,
|
||
action,
|
||
enabled: canApply(action),
|
||
selection: selection,
|
||
));
|
||
|
||
final animations =
|
||
context.select<Settings, AccessibilityAnimations>((v) => v.accessibilityAnimations);
|
||
|
||
return [
|
||
...quickActionButtons,
|
||
PopupMenuButton<EntrySetAction>(
|
||
key: const Key('appbar-menu-button'),
|
||
itemBuilder: (context) {
|
||
bool isValidForMenu(EntrySetAction? v) =>
|
||
v == null || (!quickActions.contains(v) && isVisible(v));
|
||
|
||
final generalMenuItems = EntrySetActions.general
|
||
.where(isValidForMenu)
|
||
.map((action) =>
|
||
_toMenuItem(action, enabled: canApply(action), selection: selection));
|
||
|
||
final allContextualActions =
|
||
isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing;
|
||
|
||
final contextualMenuActions =
|
||
allContextualActions.where(isValidForMenu).fold<List<EntrySetAction?>>(
|
||
[],
|
||
(prev, v) {
|
||
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
|
||
return [...prev, v];
|
||
},
|
||
);
|
||
|
||
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) {
|
||
contextualMenuActions.removeLast();
|
||
}
|
||
|
||
final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[
|
||
...contextualMenuActions.map((action) {
|
||
if (action == null) return const PopupMenuDivider();
|
||
return _toMenuItem(
|
||
action,
|
||
enabled: canApply(action),
|
||
selection: selection,
|
||
);
|
||
}),
|
||
if (isSelecting &&
|
||
!settings.isReadOnly &&
|
||
appMode == AppMode.main &&
|
||
!isTrash)
|
||
PopupMenuExpansionPanel<EntrySetAction>(
|
||
enabled: hasSelection,
|
||
value: 'edit',
|
||
icon: AIcons.edit,
|
||
title: context.l10n.collectionActionEdit,
|
||
items: [
|
||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||
...EntrySetActions.edit
|
||
.where((v) => isVisible(v) && !quickActions.contains(v))
|
||
.map((action) => _toMenuItem(
|
||
action,
|
||
enabled: canApply(action),
|
||
selection: selection,
|
||
)),
|
||
],
|
||
),
|
||
];
|
||
|
||
return [
|
||
...generalMenuItems,
|
||
if (contextualMenuItems.isNotEmpty) ...[
|
||
const PopupMenuDivider(),
|
||
...contextualMenuItems,
|
||
],
|
||
];
|
||
},
|
||
onSelected: (action) async {
|
||
await Future.delayed(animations.popUpAnimationDelay * timeDilation);
|
||
await _onActionSelected(action);
|
||
},
|
||
popUpAnimationStyle: animations.popUpAnimationStyle,
|
||
),
|
||
];
|
||
}
|
||
|
||
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
|
||
return selection.selectedItems
|
||
.expand((entry) => entry.stackedEntries ?? {entry})
|
||
.toSet();
|
||
}
|
||
|
||
Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}');
|
||
|
||
Widget _buildButtonIcon(
|
||
BuildContext context,
|
||
EntrySetAction action, {
|
||
required bool enabled,
|
||
FocusNode? focusNode,
|
||
required Selection<AvesEntry> selection,
|
||
}) {
|
||
final blurred = settings.enableBlurEffect;
|
||
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
||
|
||
switch (action) {
|
||
case EntrySetAction.toggleTitleSearch:
|
||
return Selector<Query?, bool>(
|
||
selector: (context, query) => query?.enabled ?? false,
|
||
builder: (context, queryEnabled, child) {
|
||
return TitleSearchToggler(
|
||
queryEnabled: queryEnabled,
|
||
onPressed: onPressed,
|
||
focusNode: focusNode,
|
||
);
|
||
},
|
||
);
|
||
|
||
case EntrySetAction.copy:
|
||
return MoveButton(
|
||
copy: true,
|
||
blurred: blurred,
|
||
onChooserValue: (album) =>
|
||
_actionDelegate.quickMove(context, album, copy: true),
|
||
onPressed: onPressed,
|
||
);
|
||
|
||
case EntrySetAction.move:
|
||
return MoveButton(
|
||
copy: false,
|
||
blurred: blurred,
|
||
onChooserValue: (album) =>
|
||
_actionDelegate.quickMove(context, album, copy: false),
|
||
onPressed: onPressed,
|
||
);
|
||
|
||
case EntrySetAction.editRating:
|
||
return RateButton(
|
||
blurred: blurred,
|
||
onChooserValue: (rating) => _actionDelegate.quickRate(context, rating),
|
||
focusNode: focusNode,
|
||
onPressed: onPressed,
|
||
);
|
||
|
||
case EntrySetAction.editTags:
|
||
return TagButton(
|
||
blurred: blurred,
|
||
onChooserValue: (filter) => _actionDelegate.quickTag(context, filter),
|
||
focusNode: focusNode,
|
||
onPressed: onPressed,
|
||
);
|
||
|
||
case EntrySetAction.toggleFavourite:
|
||
return FavouriteToggler(
|
||
entries: _getExpandedSelectedItems(selection),
|
||
focusNode: focusNode,
|
||
onPressed: onPressed,
|
||
);
|
||
|
||
default:
|
||
return IconButton(
|
||
key: _getActionKey(action),
|
||
icon: action.getIcon(),
|
||
onPressed: onPressed,
|
||
focusNode: focusNode,
|
||
tooltip: action.getText(context),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildButtonCaption(
|
||
BuildContext context,
|
||
EntrySetAction action, {
|
||
required bool enabled,
|
||
}) {
|
||
switch (action) {
|
||
case EntrySetAction.toggleTitleSearch:
|
||
return TitleSearchTogglerCaption(enabled: enabled);
|
||
default:
|
||
return CaptionedButtonText(
|
||
text: action.getText(context),
|
||
enabled: enabled,
|
||
);
|
||
}
|
||
}
|
||
|
||
PopupMenuItem<EntrySetAction> _toMenuItem(
|
||
EntrySetAction action, {
|
||
required bool enabled,
|
||
required Selection<AvesEntry> selection,
|
||
}) {
|
||
late Widget child;
|
||
|
||
switch (action) {
|
||
case EntrySetAction.toggleTitleSearch:
|
||
child = TitleSearchToggler(
|
||
queryEnabled: context.read<Query>().enabled,
|
||
isMenuItem: true,
|
||
);
|
||
break;
|
||
|
||
case EntrySetAction.toggleFavourite:
|
||
child = FavouriteToggler(
|
||
entries: _getExpandedSelectedItems(selection),
|
||
isMenuItem: true,
|
||
);
|
||
break;
|
||
|
||
default:
|
||
child = MenuRow(
|
||
text: action.getText(context),
|
||
icon: action.getIcon(),
|
||
);
|
||
}
|
||
|
||
return PopupMenuItem(
|
||
key: _getActionKey(action),
|
||
value: action,
|
||
enabled: enabled,
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
PopupMenuEntry<EntrySetAction> _buildRotateAndFlipMenuItems(
|
||
BuildContext context, {
|
||
required bool Function(EntrySetAction action) canApply,
|
||
}) {
|
||
Widget buildDivider() => const SizedBox(
|
||
height: 16,
|
||
child: VerticalDivider(width: 1, thickness: 1),
|
||
);
|
||
|
||
Widget buildItem(EntrySetAction action) => Expanded(
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: PopupMenuItem(
|
||
value: action,
|
||
enabled: canApply(action),
|
||
child: Tooltip(
|
||
message: action.getText(context),
|
||
child: Center(child: action.getIcon()),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
return PopupMenuItemContainer(
|
||
child: Row(
|
||
children: [
|
||
buildDivider(),
|
||
buildItem(EntrySetAction.rotateCCW),
|
||
buildDivider(),
|
||
buildItem(EntrySetAction.rotateCW),
|
||
buildDivider(),
|
||
buildItem(EntrySetAction.flip),
|
||
buildDivider(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _onActionSelected(EntrySetAction action) async {
|
||
switch (action) {
|
||
case EntrySetAction.configureView:
|
||
await _configureView();
|
||
break;
|
||
|
||
case EntrySetAction.select:
|
||
context.read<Selection<AvesEntry>>().select();
|
||
break;
|
||
|
||
case EntrySetAction.selectAll:
|
||
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
|
||
break;
|
||
|
||
case EntrySetAction.selectNone:
|
||
context.read<Selection<AvesEntry>>().clearSelection();
|
||
break;
|
||
|
||
case EntrySetAction.openGpx:
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => const ActivityFilesPage(),
|
||
),
|
||
);
|
||
break;
|
||
|
||
default:
|
||
_actionDelegate.onActionSelected(context, action);
|
||
}
|
||
}
|
||
|
||
Future<void> _configureView() async {
|
||
final initialValue = (
|
||
settings.collectionSortFactor,
|
||
settings.collectionSectionFactor,
|
||
settings.getTileLayout(CollectionPage.routeName),
|
||
settings.collectionSortReverse,
|
||
);
|
||
|
||
final extentController = context.read<TileExtentController>();
|
||
final value =
|
||
await showDialog<(EntrySortFactor?, EntrySectionFactor?, TileLayout?, bool)>(
|
||
context: context,
|
||
builder: (context) {
|
||
return TileViewDialog<EntrySortFactor, EntrySectionFactor, TileLayout>(
|
||
initialValue: initialValue,
|
||
sortOptions: _sortOptions
|
||
.map((v) => TileViewDialogOption(
|
||
value: v,
|
||
title: v.getName(context),
|
||
icon: v.icon,
|
||
))
|
||
.toList(),
|
||
sectionOptions: _sectionOptions
|
||
.map((v) => TileViewDialogOption(
|
||
value: v,
|
||
title: v.getName(context),
|
||
icon: v.icon,
|
||
))
|
||
.toList(),
|
||
layoutOptions: _layoutOptions
|
||
.map((v) => TileViewDialogOption(
|
||
value: v,
|
||
title: v.getName(context),
|
||
icon: v.icon,
|
||
))
|
||
.toList(),
|
||
sortOrder: (factor, reverse) => factor.getOrderName(context, reverse),
|
||
canSection: (s, g, l) => s == EntrySortFactor.date,
|
||
tileExtentController: extentController,
|
||
);
|
||
},
|
||
routeSettings: const RouteSettings(name: TileViewDialog.routeName),
|
||
);
|
||
|
||
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
|
||
|
||
if (value != null && initialValue != value) {
|
||
settings.collectionSortFactor = value.$1!;
|
||
settings.collectionSectionFactor = value.$2!;
|
||
settings.setTileLayout(CollectionPage.routeName, value.$3!);
|
||
settings.collectionSortReverse = value.$4;
|
||
}
|
||
}
|
||
}
|