import 'dart:math'; import 'package:aves/app_mode.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.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/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/overlay/multipage.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'; import 'package:tuple/tuple.dart'; class ViewerBottomOverlay extends StatefulWidget { final List entries; final int index; final CollectionLens? collection; final AnimationController animationController; final Size availableSize; final EdgeInsets? viewInsets, viewPadding; final MultiPageController? multiPageController; 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 State createState() => _ViewerBottomOverlayState(); static double actionSafeHeight(BuildContext context) { final mq = context.read(); final mqPaddingBottom = max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom); final buttonHeight = ViewerButtons.preferredHeight(context); final thumbnailHeight = (settings.showOverlayThumbnailPreview ? ViewerThumbnailPreview.preferredHeight : 0); return mqPaddingBottom + buttonHeight + thumbnailHeight; } } class _ViewerBottomOverlayState extends State { List get entries => widget.entries; AvesEntry? get entry { final index = widget.index; return index < entries.length ? entries[index] : null; } MultiPageController? get multiPageController => widget.multiPageController; @override Widget build(BuildContext context) { final mainEntry = entry; if (mainEntry == null) return const SizedBox(); Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( entries: entries, index: widget.index, mainEntry: mainEntry, pageEntry: pageEntry ?? mainEntry, collection: widget.collection, availableSize: widget.availableSize, viewInsets: widget.viewInsets, viewPadding: widget.viewPadding, multiPageController: multiPageController, animationController: widget.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, ); } } 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 Animation _buttonScale, _thumbnailOpacity; @override void initState() { super.initState(); _registerWidget(widget); } @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, ); animationController.addStatusListener(_onAnimationStatusChanged); } void _unregisterWidget(_BottomOverlayContent widget) { widget.animationController.removeStatusListener(_onAnimationStatusChanged); } @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 viewerButtonRow = FocusableActionDetector( focusNode: _buttonRowFocusScopeNode, shortcuts: device.isTelevision ? 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, 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 _onAnimationStatusChanged(AnimationStatus status) { if (status == AnimationStatus.completed) { _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 mq = context.select>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); final mqWidth = mq.item1; final mqViewInsets = mq.item2; final mqViewPadding = mq.item3; final viewInsets = this.viewInsets ?? mqViewInsets; final viewPadding = this.viewPadding ?? mqViewPadding; final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8.0); return Padding( padding: safePadding, child: SizedBox( width: mqWidth - safePadding.horizontal, child: child, ), ); } }