diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 81639692d..7fc0a5c69 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -38,7 +38,7 @@ class FullscreenBody extends StatefulWidget { } class FullscreenBodyState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { - ImageEntry _entry; + final ValueNotifier _entryNotifier = ValueNotifier(null); int _currentHorizontalPage; ValueNotifier _currentVerticalPage; PageController _horizontalPager, _verticalPager; @@ -57,19 +57,18 @@ class FullscreenBodyState extends State with SingleTickerProvide List get entries => hasCollection ? collection.sortedEntries : [widget.initialEntry]; - List get pages => ['transition', 'image', 'info']; + static const int transitionPage = 0; - int get transitionPage => pages.indexOf('transition'); + static const int imagePage = 1; - int get imagePage => pages.indexOf('image'); - - int get infoPage => pages.indexOf('info'); + static const int infoPage = 2; @override void initState() { super.initState(); - _entry = widget.initialEntry; - _currentHorizontalPage = max(0, entries.indexOf(_entry)); + final entry = widget.initialEntry; + _entryNotifier.value = entry; + _currentHorizontalPage = max(0, entries.indexOf(entry)); _currentVerticalPage = ValueNotifier(imagePage); _horizontalPager = PageController(initialPage: _currentHorizontalPage); _verticalPager = PageController(initialPage: _currentVerticalPage.value)..addListener(_onVerticalPageControllerChange); @@ -138,76 +137,6 @@ class FullscreenBodyState extends State with SingleTickerProvide @override Widget build(BuildContext context) { - final topOverlay = ValueListenableBuilder( - valueListenable: _currentVerticalPage, - builder: (context, page, child) { - final showOverlay = _entry != null && page == imagePage; - return showOverlay - ? FullscreenTopOverlay( - entries: entries, - index: _currentHorizontalPage, - scale: _topOverlayScale, - canToggleFavourite: hasCollection, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - onActionSelected: (action) => _actionDelegate.onActionSelected(context, _entry, action), - ) - : const SizedBox.shrink(); - }, - ); - - final videoController = _entry != null && _entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == _entry.uri, orElse: () => null)?.item2 : null; - Widget bottomOverlay = Column( - children: [ - if (videoController != null) - VideoControlOverlay( - entry: _entry, - controller: videoController, - scale: _bottomOverlayScale, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - ), - SlideTransition( - position: _bottomOverlayOffset, - child: FullscreenBottomOverlay( - entries: entries, - index: _currentHorizontalPage, - showPosition: hasCollection, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - ), - ), - ], - ); - bottomOverlay = ValueListenableBuilder( - valueListenable: _overlayAnimationController, - builder: (context, animation, child) { - return Visibility( - visible: _entry != null && _overlayAnimationController.status != AnimationStatus.dismissed, - child: child, - ); - }, - child: bottomOverlay, - ); - - bottomOverlay = Selector( - selector: (c, mq) => mq.size.height, - builder: (c, mqHeight, child) { - // when orientation change, the `PageController` offset is not updated right away - // and it does not trigger its listeners when it does, so we force a refresh in the next frame - WidgetsBinding.instance.addPostFrameCallback((_) => _onVerticalPageControllerChange()); - return AnimatedBuilder( - animation: _verticalScrollNotifier, - builder: (context, child) => Positioned( - bottom: (_verticalPager.offset ?? 0) - mqHeight, - child: child, - ), - child: child, - ); - }, - child: bottomOverlay, - ); - return WillPopScope( onWillPop: () { if (_currentVerticalPage.value == infoPage) { @@ -227,7 +156,7 @@ class FullscreenBodyState extends State with SingleTickerProvide children: [ FullscreenVerticalPageView( collection: collection, - entry: _entry, + entryNotifier: _entryNotifier, videoControllers: _videoControllers, verticalPager: _verticalPager, horizontalPager: _horizontalPager, @@ -236,14 +165,106 @@ class FullscreenBodyState extends State with SingleTickerProvide onImageTap: () => _overlayVisible.value = !_overlayVisible.value, onImagePageRequested: () => _goToVerticalPage(imagePage), ), - topOverlay, - bottomOverlay, + _buildTopOverlay(), + _buildBottomOverlay(), ], ), ), ); } + Widget _buildTopOverlay() { + final child = ValueListenableBuilder( + valueListenable: _entryNotifier, + builder: (context, entry, child) { + if (entry == null) return const SizedBox.shrink(); + return FullscreenTopOverlay( + entry: entry, + scale: _topOverlayScale, + canToggleFavourite: hasCollection, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), + ); + }, + ); + return ValueListenableBuilder( + valueListenable: _currentVerticalPage, + builder: (context, page, child) { + return Visibility( + visible: page == imagePage, + child: child, + ); + }, + child: child, + ); + } + + Widget _buildBottomOverlay() { + Widget bottomOverlay = ValueListenableBuilder( + valueListenable: _entryNotifier, + builder: (context, entry, child) { + Widget videoOverlay; + if (entry != null) { + final videoController = entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null; + if (videoController != null) { + videoOverlay = VideoControlOverlay( + entry: entry, + controller: videoController, + scale: _bottomOverlayScale, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + ); + } + } + final child = Column( + children: [ + if (videoOverlay != null) videoOverlay, + SlideTransition( + position: _bottomOverlayOffset, + child: FullscreenBottomOverlay( + entries: entries, + index: _currentHorizontalPage, + showPosition: hasCollection, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + ), + ), + ], + ); + return ValueListenableBuilder( + valueListenable: _overlayAnimationController, + builder: (context, animation, child) { + return Visibility( + visible: entry != null && _overlayAnimationController.status != AnimationStatus.dismissed, + child: child, + ); + }, + child: child, + ); + }, + ); + + bottomOverlay = Selector( + selector: (c, mq) => mq.size.height, + builder: (c, mqHeight, child) { + // when orientation change, the `PageController` offset is not updated right away + // and it does not trigger its listeners when it does, so we force a refresh in the next frame + WidgetsBinding.instance.addPostFrameCallback((_) => _onVerticalPageControllerChange()); + return AnimatedBuilder( + animation: _verticalScrollNotifier, + builder: (context, child) => Positioned( + bottom: (_verticalPager.offset ?? 0) - mqHeight, + child: child, + ), + child: child, + ); + }, + child: bottomOverlay, + ); + return bottomOverlay; + } + void _onVerticalPageControllerChange() { _verticalScrollNotifier.notifyListeners(); } @@ -286,11 +307,10 @@ class FullscreenBodyState extends State with SingleTickerProvide void _updateEntry() { final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; - if (_entry == newEntry) return; - _entry = newEntry; + if (_entryNotifier.value == newEntry) return; + _entryNotifier.value = newEntry; _pauseVideoControllers(); _initVideoController(); - setState(() {}); } void _onLeave() { @@ -301,13 +321,13 @@ class FullscreenBodyState extends State with SingleTickerProvide _showSystemUI(); } - // system UI +// system UI static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); static void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); - // overlay +// overlay Future _initOverlay() async { // wait for MaterialPageRoute.transitionDuration @@ -348,9 +368,10 @@ class FullscreenBodyState extends State with SingleTickerProvide void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); Future _initVideoController() async { - if (_entry == null || !_entry.isVideo) return; + final entry = _entryNotifier.value; + if (entry == null || !entry.isVideo) return; - final uri = _entry.uri; + final uri = entry.uri; var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); if (controllerEntry != null) { _videoControllers.remove(controllerEntry); @@ -362,12 +383,13 @@ class FullscreenBodyState extends State with SingleTickerProvide while (_videoControllers.length > 3) { _videoControllers.removeLast().item2.dispose(); } + setState(() {}); } } class FullscreenVerticalPageView extends StatefulWidget { final CollectionLens collection; - final ImageEntry entry; + final ValueNotifier entryNotifier; final List> videoControllers; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; @@ -375,7 +397,7 @@ class FullscreenVerticalPageView extends StatefulWidget { const FullscreenVerticalPageView({ @required this.collection, - @required this.entry, + @required this.entryNotifier, @required this.videoControllers, @required this.verticalPager, @required this.horizontalPager, @@ -392,12 +414,13 @@ class FullscreenVerticalPageView extends StatefulWidget { class _FullscreenVerticalPageViewState extends State { final ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); final ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); + ImageEntry _oldEntry; CollectionLens get collection => widget.collection; bool get hasCollection => collection != null; - ImageEntry get entry => widget.entry; + ImageEntry get entry => widget.entryNotifier.value; @override void initState() { @@ -420,12 +443,12 @@ class _FullscreenVerticalPageViewState extends State void _registerWidget(FullscreenVerticalPageView widget) { widget.verticalPager.addListener(_onVerticalPageControllerChanged); - widget.entry?.imageChangeNotifier?.addListener(_onImageChanged); + widget.entryNotifier.addListener(_onEntryChanged); } void _unregisterWidget(FullscreenVerticalPageView widget) { widget.verticalPager.removeListener(_onVerticalPageControllerChanged); - widget.entry?.imageChangeNotifier?.removeListener(_onImageChanged); + widget.entryNotifier.removeListener(_onEntryChanged); } @override @@ -453,7 +476,7 @@ class _FullscreenVerticalPageViewState extends State }, child: InfoPage( collection: collection, - entry: entry, + entryNotifier: widget.entryNotifier, visibleNotifier: _infoPageVisibleNotifier, ), ), @@ -482,6 +505,14 @@ class _FullscreenVerticalPageViewState extends State _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity); } + // when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted) + void _onEntryChanged() { + _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); + entry?.imageChangeNotifier?.addListener(_onImageChanged); + _oldEntry = entry; + } + + // when the entry image itself changed (e.g. after rotation) void _onImageChanged() async { await UriImage(uri: entry.uri, mimeType: entry.mimeType).evict(); // TODO TLAD also evict `ThumbnailProvider` with specified extents diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index df806f7f3..73e46de4a 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -14,14 +14,14 @@ import 'package:tuple/tuple.dart'; class InfoPage extends StatefulWidget { final CollectionLens collection; - final ImageEntry entry; + final ValueNotifier entryNotifier; final ValueNotifier visibleNotifier; const InfoPage({ Key key, @required this.collection, - @required this.entry, - this.visibleNotifier, + @required this.entryNotifier, + @required this.visibleNotifier, }) : super(key: key); @override @@ -34,12 +34,31 @@ class InfoPageState extends State { CollectionLens get collection => widget.collection; - ImageEntry get entry => widget.entry; - @override void initState() { super.initState(); - entry.locate(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(InfoPage oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(InfoPage widget) { + widget.entryNotifier.addListener(_onEntryChanged); + } + + void _unregisterWidget(InfoPage widget) { + widget.entryNotifier.removeListener(_onEntryChanged); } @override @@ -67,52 +86,57 @@ class InfoPageState extends State { final mqWidth = mq.item1; final mqViewInsetsBottom = mq.item2; final split = mqWidth > 400; - final locationAtTop = split && entry.hasGps; - final locationSection = LocationSection( - collection: collection, - entry: entry, - showTitle: !locationAtTop, - visibleNotifier: widget.visibleNotifier, - onFilter: _goToCollection, - ); - final basicAndLocationSliver = locationAtTop - ? SliverToBoxAdapter( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)), - const SizedBox(width: 8), - Expanded(child: locationSection), - ], - ), - ) - : SliverList( - delegate: SliverChildListDelegate.fixed( - [ - BasicSection(entry: entry, collection: collection, onFilter: _goToCollection), - locationSection, - ], - ), - ); - final metadataSliver = MetadataSectionSliver( - entry: entry, - visibleNotifier: widget.visibleNotifier, - ); + return ValueListenableBuilder( + valueListenable: widget.entryNotifier, + builder: (context, entry, child) { + final locationAtTop = split && entry.hasGps; + final locationSection = LocationSection( + collection: collection, + entry: entry, + showTitle: !locationAtTop, + visibleNotifier: widget.visibleNotifier, + onFilter: _goToCollection, + ); + final basicAndLocationSliver = locationAtTop + ? SliverToBoxAdapter( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)), + const SizedBox(width: 8), + Expanded(child: locationSection), + ], + ), + ) + : SliverList( + delegate: SliverChildListDelegate.fixed( + [ + BasicSection(entry: entry, collection: collection, onFilter: _goToCollection), + locationSection, + ], + ), + ); + final metadataSliver = MetadataSectionSliver( + entry: entry, + visibleNotifier: widget.visibleNotifier, + ); - return CustomScrollView( - controller: _scrollController, - slivers: [ - appBar, - SliverPadding( - padding: horizontalPadding + const EdgeInsets.only(top: 8), - sliver: basicAndLocationSliver, - ), - SliverPadding( - padding: horizontalPadding + EdgeInsets.only(bottom: 8 + mqViewInsetsBottom), - sliver: metadataSliver, - ), - ], + return CustomScrollView( + controller: _scrollController, + slivers: [ + appBar, + SliverPadding( + padding: horizontalPadding + const EdgeInsets.only(top: 8), + sliver: basicAndLocationSliver, + ), + SliverPadding( + padding: horizontalPadding + EdgeInsets.only(bottom: 8 + mqViewInsetsBottom), + sliver: metadataSliver, + ), + ], + ); + }, ); }, ), @@ -123,6 +147,10 @@ class InfoPageState extends State { ); } + void _onEntryChanged() { + widget.entryNotifier.value?.locate(); + } + bool _handleTopScroll(Notification notification) { if (notification is ScrollNotification) { if (notification is ScrollStartNotification) { diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index 1d2b84617..ef4ed54d3 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -9,17 +9,15 @@ import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; class FullscreenTopOverlay extends StatelessWidget { - final List entries; - final int index; + final ImageEntry entry; final Animation scale; final EdgeInsets viewInsets, viewPadding; final Function(EntryAction value) onActionSelected; final bool canToggleFavourite; - ImageEntry get entry => entries[index]; - static const double padding = 8; static const int landscapeActionCount = 3; @@ -28,13 +26,12 @@ class FullscreenTopOverlay extends StatelessWidget { const FullscreenTopOverlay({ Key key, - @required this.entries, - @required this.index, + @required this.entry, @required this.scale, - this.canToggleFavourite = false, - this.viewInsets, - this.viewPadding, - this.onActionSelected, + @required this.canToggleFavourite, + @required this.viewInsets, + @required this.viewPadding, + @required this.onActionSelected, }) : super(key: key); @override @@ -43,49 +40,31 @@ class FullscreenTopOverlay extends StatelessWidget { minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero), child: Padding( padding: const EdgeInsets.all(padding), - child: Selector( - selector: (c, mq) => mq.orientation, - builder: (c, orientation, child) { - final targetCount = orientation == Orientation.landscape ? landscapeActionCount : portraitActionCount; - return LayoutBuilder( - builder: (context, constraints) { - final availableCount = (constraints.maxWidth / (kMinInteractiveDimension + padding)).floor() - 2; - final quickActionCount = min(targetCount, availableCount); + child: Selector>( + selector: (c, mq) => Tuple2(mq.size.width, mq.orientation), + builder: (c, mq, child) { + final mqWidth = mq.item1; + final mqOrientation = mq.item2; - final quickActions = [ - EntryAction.toggleFavourite, - EntryAction.share, - EntryAction.delete, - ].where(_canDo).take(quickActionCount); - final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo); - final externalAppActions = EntryActions.externalApp.where(_canDo); + final targetCount = mqOrientation == Orientation.landscape ? landscapeActionCount : portraitActionCount; + final availableCount = (mqWidth / (kMinInteractiveDimension + padding)).floor() - 2; + final quickActionCount = min(targetCount, availableCount); - return Row( - children: [ - OverlayButton( - scale: scale, - child: ModalRoute.of(context)?.canPop ?? true ? const BackButton() : const CloseButton(), - ), - const Spacer(), - ...quickActions.map(_buildOverlayButton), - OverlayButton( - scale: scale, - child: PopupMenuButton( - itemBuilder: (context) => [ - ...inAppActions.map(_buildPopupMenuItem), - const PopupMenuDivider(), - ...externalAppActions.map(_buildPopupMenuItem), - if (kDebugMode) ...[ - const PopupMenuDivider(), - _buildPopupMenuItem(EntryAction.debug), - ] - ], - onSelected: onActionSelected, - ), - ), - ], - ); - }, + final quickActions = [ + EntryAction.toggleFavourite, + EntryAction.share, + EntryAction.delete, + ].where(_canDo).take(quickActionCount).toList(); + final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); + final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); + + return _TopOverlayRow( + quickActions: quickActions, + inAppActions: inAppActions, + externalAppActions: externalAppActions, + scale: scale, + isFavouriteNotifier: entry.isFavouriteNotifier, + onActionSelected: onActionSelected, ); }, ), @@ -118,6 +97,56 @@ class FullscreenTopOverlay extends StatelessWidget { } return false; } +} + +class _TopOverlayRow extends StatelessWidget { + final List quickActions; + final List inAppActions; + final List externalAppActions; + final Animation scale; + final ValueNotifier isFavouriteNotifier; + final Function(EntryAction value) onActionSelected; + + const _TopOverlayRow({ + Key key, + @required this.quickActions, + @required this.inAppActions, + @required this.externalAppActions, + @required this.scale, + @required this.isFavouriteNotifier, + @required this.onActionSelected, + }) : super(key: key); + + static const double padding = 8; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + OverlayButton( + scale: scale, + child: ModalRoute.of(context)?.canPop ?? true ? const BackButton() : const CloseButton(), + ), + const Spacer(), + ...quickActions.map(_buildOverlayButton), + OverlayButton( + scale: scale, + child: PopupMenuButton( + itemBuilder: (context) => [ + ...inAppActions.map(_buildPopupMenuItem), + const PopupMenuDivider(), + ...externalAppActions.map(_buildPopupMenuItem), + if (kDebugMode) ...[ + const PopupMenuDivider(), + _buildPopupMenuItem(EntryAction.debug), + ] + ], + onSelected: onActionSelected, + ), + ), + ], + ); + } Widget _buildOverlayButton(EntryAction action) { Widget child; @@ -125,7 +154,7 @@ class FullscreenTopOverlay extends StatelessWidget { switch (action) { case EntryAction.toggleFavourite: child = ValueListenableBuilder( - valueListenable: entry.isFavouriteNotifier, + valueListenable: isFavouriteNotifier, builder: (context, isFavourite, child) => Stack( alignment: Alignment.center, children: [ @@ -136,7 +165,7 @@ class FullscreenTopOverlay extends StatelessWidget { ), Sweeper( builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), - toggledNotifier: entry.isFavouriteNotifier, + toggledNotifier: isFavouriteNotifier, ), ], ), @@ -178,7 +207,7 @@ class FullscreenTopOverlay extends StatelessWidget { switch (action) { // in app actions case EntryAction.toggleFavourite: - child = entry.isFavouriteNotifier.value + child = isFavouriteNotifier.value ? const MenuRow( text: 'Remove from favourites', icon: AIcons.favouriteActive,