import 'dart:math'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/viewer/controls/intents.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/overlay/multipage.dart'; import 'package:aves/widgets/viewer/overlay/selection_button.dart'; import 'package:aves/widgets/viewer/overlay/thumbnail_preview.dart'; import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart'; import 'package:aves/widgets/viewer/overlay/wallpaper_buttons.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; class ViewerBottomOverlay extends StatelessWidget { final List entries; final int index; final CollectionLens? collection; final AnimationController animationController; final Size availableSize; final EdgeInsets? viewInsets, viewPadding; final MultiPageController? multiPageController; // always keep action buttons in the lower right corner, even with RTL locales static const actionsDirection = TextDirection.ltr; AvesEntry? get entry { return index < entries.length ? entries[index] : null; } const ViewerBottomOverlay({ super.key, required this.entries, required this.index, required this.collection, required this.animationController, required this.availableSize, this.viewInsets, this.viewPadding, required this.multiPageController, }); @override Widget build(BuildContext context) { final mainEntry = entry; if (mainEntry == null) return const SizedBox(); Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( entries: entries, index: index, mainEntry: mainEntry, pageEntry: pageEntry ?? mainEntry, collection: collection, availableSize: availableSize, viewInsets: viewInsets, viewPadding: viewPadding, multiPageController: multiPageController, animationController: animationController, ); Widget child = multiPageController != null ? PageEntryBuilder( multiPageController: multiPageController!, builder: (pageEntry) => _buildContent(pageEntry: pageEntry), ) : _buildContent(); return Selector( selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), builder: (context, mqPaddingBottom, child) { return Padding( padding: EdgeInsets.only(bottom: mqPaddingBottom), child: child, ); }, child: child, ); } static double actionSafeHeight(BuildContext context) { final mqPaddingBottom = context.select((mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom)); final buttonHeight = ViewerButtons.preferredHeight(context); final thumbnailHeight = (settings.showOverlayThumbnailPreview ? ViewerThumbnailPreview.preferredHeight : 0); return mqPaddingBottom + buttonHeight + thumbnailHeight; } } class _BottomOverlayContent extends StatefulWidget { final List entries; final int index; final AvesEntry mainEntry, pageEntry; final CollectionLens? collection; final Size availableSize; final EdgeInsets? viewInsets, viewPadding; final MultiPageController? multiPageController; final AnimationController animationController; const _BottomOverlayContent({ required this.entries, required this.index, required this.mainEntry, required this.pageEntry, required this.collection, required this.availableSize, required this.viewInsets, required this.viewPadding, required this.multiPageController, required this.animationController, }); @override State<_BottomOverlayContent> createState() => _BottomOverlayContentState(); } class _BottomOverlayContentState extends State<_BottomOverlayContent> { final FocusScopeNode _buttonRowFocusScopeNode = FocusScopeNode(); late CurvedAnimation _buttonScale, _thumbnailOpacity; @override void initState() { super.initState(); _registerWidget(widget); WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus()); } @override void didUpdateWidget(covariant _BottomOverlayContent oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); } @override void dispose() { _unregisterWidget(widget); _buttonRowFocusScopeNode.dispose(); super.dispose(); } void _registerWidget(_BottomOverlayContent widget) { final animationController = widget.animationController; _buttonScale = CurvedAnimation( parent: animationController, // a little bounce at the top curve: Curves.easeOutBack, ); _thumbnailOpacity = CurvedAnimation( parent: animationController, curve: Curves.easeOutQuad, ); } void _unregisterWidget(_BottomOverlayContent widget) { _buttonScale.dispose(); _thumbnailOpacity.dispose(); } @override Widget build(BuildContext context) { final mainEntry = widget.mainEntry; final pageEntry = widget.pageEntry; final multiPageController = widget.multiPageController; final isWallpaperMode = context.read>().value == AppMode.setWallpaper; return AnimatedBuilder( animation: Listenable.merge([ mainEntry.metadataChangeNotifier, pageEntry.metadataChangeNotifier, ]), builder: (context, child) { final viewInsetsPadding = (widget.viewInsets ?? EdgeInsets.zero) + (widget.viewPadding ?? EdgeInsets.zero); final selection = context.read?>(); final viewerButtonRow = (selection?.isSelecting ?? false) ? SelectionButton( mainEntry: mainEntry, scale: _buttonScale, ) : FocusableActionDetector( focusNode: _buttonRowFocusScopeNode, shortcuts: settings.useTvLayout ? const { SingleActivator(LogicalKeyboardKey.arrowUp): TvShowLessInfoIntent(), } : null, actions: { TvShowLessInfoIntent: CallbackAction(onInvoke: (intent) => TvShowLessInfoNotification().dispatch(context)), }, child: SafeArea( top: false, bottom: false, minimum: EdgeInsets.only( left: viewInsetsPadding.left, right: viewInsetsPadding.right, ), child: isWallpaperMode ? WallpaperButtons( entry: pageEntry, scale: _buttonScale, ) : ViewerButtons( mainEntry: mainEntry, pageEntry: pageEntry, collection: widget.collection, scale: _buttonScale, ), ), ); final showMultiPageOverlay = mainEntry.isMultiPage && multiPageController != null; final collapsedPageScroller = mainEntry.isMotionPhoto; final availableWidth = widget.availableSize.width; return SizedBox( width: availableWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showMultiPageOverlay && !collapsedPageScroller) Padding( padding: const EdgeInsets.only(bottom: 8), child: FadeTransition( opacity: _thumbnailOpacity, child: MultiPageOverlay( controller: multiPageController, availableWidth: availableWidth, scrollable: true, ), ), ), (showMultiPageOverlay && collapsedPageScroller) ? Row( crossAxisAlignment: CrossAxisAlignment.center, textDirection: ViewerBottomOverlay.actionsDirection, children: [ SafeArea( top: false, bottom: false, child: Padding( padding: const EdgeInsets.only(bottom: 8), child: MultiPageOverlay( controller: multiPageController, availableWidth: availableWidth, scrollable: false, ), ), ), Expanded(child: viewerButtonRow), ], ) : viewerButtonRow, if (settings.showOverlayThumbnailPreview && !isWallpaperMode) FadeTransition( opacity: _thumbnailOpacity, child: ViewerThumbnailPreview( availableWidth: availableWidth, displayedIndex: widget.index, entries: widget.entries, ), ), ], ), ); }, ); } void _requestFocus() => _buttonRowFocusScopeNode.children.firstOrNull?.requestFocus(); } class ExtraBottomOverlay extends StatelessWidget { final EdgeInsets? viewInsets, viewPadding; final Widget child; const ExtraBottomOverlay({ super.key, this.viewInsets, this.viewPadding, required this.child, }); @override Widget build(BuildContext context) { final viewInsets = this.viewInsets ?? MediaQuery.viewInsetsOf(context); final viewPadding = this.viewPadding ?? MediaQuery.viewPaddingOf(context); final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8); return Padding( padding: safePadding, child: SizedBox( width: MediaQuery.sizeOf(context).width - safePadding.horizontal, child: child, ), ); } }