diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 37598546e..45e47689a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -613,6 +613,7 @@ "settingsViewerShowInformation": "Show information", "settingsViewerShowInformationSubtitle": "Show title, date, location, etc.", "settingsViewerShowShootingDetails": "Show shooting details", + "settingsViewerShowOverlayThumbnailPreview": "Show thumbnail preview", "settingsViewerEnableOverlayBlurEffect": "Blur effect", "settingsVideoPageTitle": "Video Settings", diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index e3c2de204..4e0255916 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -67,6 +67,7 @@ class SettingsDefaults { static const showOverlayMinimap = false; static const showOverlayInfo = true; static const showOverlayShootingDetails = false; + static const showOverlayThumbnailPreview = false; static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value static const viewerUseCutout = true; static const viewerMaxBrightness = false; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 4d32bff5a..f8b0dbd59 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -82,6 +82,7 @@ class Settings extends ChangeNotifier { static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; + static const showOverlayThumbnailPreviewKey = 'show_overlay_thumbnail_preview'; static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect'; static const viewerUseCutoutKey = 'viewer_use_cutout'; static const viewerMaxBrightnessKey = 'viewer_max_brightness'; @@ -389,6 +390,10 @@ class Settings extends ChangeNotifier { set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); + bool get showOverlayThumbnailPreview => getBoolOrDefault(showOverlayThumbnailPreviewKey, SettingsDefaults.showOverlayThumbnailPreview); + + set showOverlayThumbnailPreview(bool newValue) => setAndNotify(showOverlayThumbnailPreviewKey, newValue); + bool get enableOverlayBlurEffect => getBoolOrDefault(enableOverlayBlurEffectKey, SettingsDefaults.enableOverlayBlurEffect); set enableOverlayBlurEffect(bool newValue) => setAndNotify(enableOverlayBlurEffectKey, newValue); @@ -642,6 +647,7 @@ class Settings extends ChangeNotifier { case showOverlayMinimapKey: case showOverlayInfoKey: case showOverlayShootingDetailsKey: + case showOverlayThumbnailPreviewKey: case enableOverlayBlurEffectKey: case viewerUseCutoutKey: case viewerMaxBrightnessKey: diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 9d8266c28..500967f63 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -227,6 +227,11 @@ class Constants { license: 'MIT', sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations', ), + Dependency( + name: 'Known Extents List View Builder', + license: 'BSD 3-Clause', + sourceUrl: 'https://github.com/bendelonlee/known_extents_list_view_builder', + ), Dependency( name: 'Material Design Icons Flutter', license: 'MIT', diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart index 6b8e14a74..ab59d3764 100644 --- a/lib/widgets/common/thumbnail/scroller.dart +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -1,10 +1,12 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/thumbnail/decorated.dart'; import 'package:flutter/material.dart'; +import 'package:known_extents_list_view_builder/known_extents_list_view_builder.dart'; class ThumbnailScroller extends StatefulWidget { final double availableWidth; @@ -13,7 +15,7 @@ class ThumbnailScroller extends StatefulWidget { final ValueNotifier indexNotifier; final void Function(int index)? onTap; final Object? Function(AvesEntry entry)? heroTagger; - final bool highlightable; + final bool highlightable, showLocation; const ThumbnailScroller({ Key? key, @@ -24,6 +26,7 @@ class ThumbnailScroller extends StatefulWidget { this.onTap, this.heroTagger, this.highlightable = false, + this.showLocation = true, }) : super(key: key); @override @@ -81,15 +84,22 @@ class _ThumbnailScrollerState extends State { Widget build(BuildContext context) { final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth); final horizontalMargin = SizedBox(width: marginWidth); - const separator = SizedBox(width: separatorWidth); + + const regularExtent = extent + separatorWidth; + final itemExtents = List.generate(entryCount, (index) => regularExtent) + ..insert(entryCount, marginWidth) + ..insert(0, marginWidth + separatorWidth); return GridTheme( extent: extent, - showLocation: false, + showLocation: widget.showLocation && settings.showThumbnailLocation, showTrash: false, child: SizedBox( height: extent, - child: ListView.separated( + // as of Flutter v2.10.2, using `jumpTo` with a `ListView` is prohibitively inefficient + // for large lists of items with variable height, so we use a `KnownExtentsListView` instead + child: KnownExtentsListView.builder( + itemExtents: itemExtents, scrollDirection: Axis.horizontal, controller: _scrollController, // default padding in scroll direction matches `MediaQuery.viewPadding`, @@ -136,7 +146,6 @@ class _ThumbnailScrollerState extends State { ], ); }, - separatorBuilder: (context, index) => separator, itemCount: entryCount + 2, ), ), diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 29a2db70d..4fe6aafe2 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -270,6 +270,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin onTap: _onThumbnailTap, heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.id]), highlightable: true, + showLocation: false, ); }, ); diff --git a/lib/widgets/settings/viewer/overlay.dart b/lib/widgets/settings/viewer/overlay.dart index 38c85808f..f6f448003 100644 --- a/lib/widgets/settings/viewer/overlay.dart +++ b/lib/widgets/settings/viewer/overlay.dart @@ -75,6 +75,14 @@ class ViewerOverlayPage extends StatelessWidget { ); }, ), + Selector( + selector: (context, s) => s.showOverlayThumbnailPreview, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showOverlayThumbnailPreview = v, + title: Text(context.l10n.settingsViewerShowOverlayThumbnailPreview), + ), + ), Selector( selector: (context, s) => s.enableOverlayBlurEffect, builder: (context, current, child) => SwitchListTile( diff --git a/lib/widgets/viewer/embedded/notifications.dart b/lib/widgets/viewer/embedded/notifications.dart index d58aba26b..d320e6d85 100644 --- a/lib/widgets/viewer/embedded/notifications.dart +++ b/lib/widgets/viewer/embedded/notifications.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp } +@immutable class OpenEmbeddedDataNotification extends Notification { final EmbeddedDataSource source; final String? propPath; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index d4d6d1bf9..22ddaf3bf 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -204,6 +204,11 @@ class _EntryViewerStackState extends State with FeedbackMixin, // remove focus, if any, to prevent viewer shortcuts activation from the Info page FocusManager.instance.primaryFocus?.unfocus(); _goToVerticalPage(infoPage); + } else if (notification is ViewEntryNotification) { + final index = notification.index; + if (_currentHorizontalPage != index) { + _horizontalPager.jumpToPage(index); + } } else { return false; } diff --git a/lib/widgets/viewer/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart index e46ced611..38e96d70e 100644 --- a/lib/widgets/viewer/info/notifications.dart +++ b/lib/widgets/viewer/info/notifications.dart @@ -2,10 +2,13 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:flutter/widgets.dart'; +@immutable class ShowImageNotification extends Notification {} +@immutable class ShowInfoNotification extends Notification {} +@immutable class FilterSelectedNotification extends Notification { final CollectionFilter filter; @@ -13,6 +16,7 @@ class FilterSelectedNotification extends Notification { } // deleted or moved to another album +@immutable class EntryRemovedNotification extends Notification { final AvesEntry entry; diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 775c418dd..2270dd361 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -2,25 +2,19 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/overlay.dart'; -import 'package:aves/model/multipage.dart'; -import 'package:aves/model/settings/enums/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/format.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:aves/widgets/viewer/overlay/bottom/details.dart'; import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart'; +import 'package:aves/widgets/viewer/overlay/bottom/thumbnail_preview.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; -import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -50,8 +44,9 @@ class _ViewerBottomOverlayState extends State { AvesEntry? _lastEntry; OverlayMetadata? _lastDetails; + List get entries => widget.entries; + AvesEntry? get entry { - final entries = widget.entries; final index = widget.index; return index < entries.length ? entries[index] : null; } @@ -120,11 +115,13 @@ class _ViewerBottomOverlayState extends State { final mainEntry = _lastEntry!; Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( + entries: entries, + index: widget.index, mainEntry: mainEntry, pageEntry: pageEntry ?? mainEntry, details: _lastDetails, - position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, - availableWidth: availableWidth, + showPosition: widget.showPosition, + safeWidth: availableWidth, multiPageController: multiPageController, ); @@ -143,28 +140,25 @@ class _ViewerBottomOverlayState extends State { } } -const double _iconPadding = 8.0; -const double _iconSize = 16.0; -const double _interRowPadding = 2.0; -const double _subRowMinWidth = 300.0; - class _BottomOverlayContent extends AnimatedWidget { + final List entries; + final int index; final AvesEntry mainEntry, pageEntry; final OverlayMetadata? details; - final String? position; - final double availableWidth; + final bool showPosition; + final double safeWidth; final MultiPageController? multiPageController; - static const infoPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); - _BottomOverlayContent({ Key? key, + required this.entries, + required this.index, required this.mainEntry, required this.pageEntry, - this.details, - this.position, - required this.availableWidth, - this.multiPageController, + required this.details, + required this.showPosition, + required this.safeWidth, + required this.multiPageController, }) : super( key: key, listenable: Listenable.merge([ @@ -183,279 +177,35 @@ class _BottomOverlayContent extends AnimatedWidget { overflow: TextOverflow.fade, maxLines: 1, child: SizedBox( - width: availableWidth, - child: Selector( - selector: (context, mq) => mq.orientation, - builder: (context, orientation, child) { - Widget? infoColumn; - - if (settings.showOverlayInfo) { - infoColumn = _buildInfoColumn(context, orientation); - } - - if (mainEntry.isMultiPage && multiPageController != null) { - infoColumn = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MultiPageOverlay( - controller: multiPageController!, - availableWidth: availableWidth, - ), - if (infoColumn != null) infoColumn, - ], - ); - } - - return infoColumn ?? const SizedBox(); - }, - ), - ), - ); - } - - Widget _buildInfoColumn(BuildContext context, Orientation orientation) { - final infoMaxWidth = availableWidth - infoPadding.horizontal; - final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; - final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; - final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController); - final hasShootingDetails = details != null && !details!.isEmpty && settings.showOverlayShootingDetails; - final animationDuration = context.select((v) => v.viewerOverlayChangeAnimation); - - return Padding( - padding: infoPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (positionTitle.isNotEmpty) positionTitle, - _buildSoloLocationRow(animationDuration), - if (twoColumns) - Padding( - padding: const EdgeInsets.only(top: _interRowPadding), - child: Row( - children: [ - SizedBox( - width: subRowWidth, - child: _DateRow( - entry: pageEntry, - multiPageController: multiPageController, - )), - _buildDuoShootingRow(subRowWidth, hasShootingDetails, animationDuration), - ], + width: safeWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (mainEntry.isMultiPage && multiPageController != null) + MultiPageOverlay( + controller: multiPageController!, + availableWidth: safeWidth, ), - ) - else ...[ - Container( - padding: const EdgeInsets.only(top: _interRowPadding), - width: subRowWidth, - child: _DateRow( - entry: pageEntry, + if (settings.showOverlayInfo) + ViewerDetailOverlay( + pageEntry: pageEntry, + details: details, + position: showPosition ? '${index + 1}/${entries.length}' : null, + availableWidth: safeWidth, multiPageController: multiPageController, ), - ), - _buildSoloShootingRow(subRowWidth, hasShootingDetails, animationDuration), + if (settings.showOverlayThumbnailPreview) + ViewerThumbnailPreview( + availableWidth: safeWidth, + displayedIndex: index, + entries: entries, + ), ], - ], + ), ), ); } - - Widget _buildSoloLocationRow(Duration animationDuration) => AnimatedSwitcher( - duration: animationDuration, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: _soloTransition, - child: pageEntry.hasGps - ? Container( - padding: const EdgeInsets.only(top: _interRowPadding), - child: _LocationRow(entry: pageEntry), - ) - : const SizedBox(), - ); - - Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails, Duration animationDuration) => AnimatedSwitcher( - duration: animationDuration, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: _soloTransition, - child: hasShootingDetails - ? Container( - padding: const EdgeInsets.only(top: _interRowPadding), - width: subRowWidth, - child: _ShootingRow(details!), - ) - : const SizedBox(), - ); - - Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails, Duration animationDuration) => AnimatedSwitcher( - duration: animationDuration, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: child, - ), - child: hasShootingDetails - ? SizedBox( - width: subRowWidth, - child: _ShootingRow(details!), - ) - : const SizedBox(), - ); - - static Widget _soloTransition(Widget child, Animation animation) => FadeTransition( - opacity: animation, - child: SizeTransition( - axisAlignment: 1, - sizeFactor: animation, - child: child, - ), - ); -} - -class _LocationRow extends AnimatedWidget { - final AvesEntry entry; - - _LocationRow({ - Key? key, - required this.entry, - }) : super(key: key, listenable: entry.addressChangeNotifier); - - @override - Widget build(BuildContext context) { - late final String location; - if (entry.hasAddress) { - location = entry.shortAddress; - } else { - final latLng = entry.latLng; - if (latLng != null) { - location = settings.coordinateFormat.format(context.l10n, latLng); - } else { - location = ''; - } - } - return Row( - children: [ - const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: _iconSize), - const SizedBox(width: _iconPadding), - Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), - ], - ); - } -} - -class _PositionTitleRow extends StatelessWidget { - final AvesEntry entry; - final String? collectionPosition; - final MultiPageController? multiPageController; - - const _PositionTitleRow({ - required this.entry, - required this.collectionPosition, - required this.multiPageController, - }); - - String? get title => entry.bestTitle; - - bool get isNotEmpty => collectionPosition != null || multiPageController != null || title != null; - - static const separator = ' • '; - - @override - Widget build(BuildContext context) { - Text toText({String? pagePosition}) => Text( - [ - if (collectionPosition != null) collectionPosition, - if (pagePosition != null) pagePosition, - if (title != null) '${Constants.fsi}$title${Constants.pdi}', - ].join(separator), - strutStyle: Constants.overflowStrutStyle); - - if (multiPageController == null) return toText(); - - return StreamBuilder( - stream: multiPageController!.infoStream, - builder: (context, snapshot) { - final multiPageInfo = multiPageController!.info; - String? pagePosition; - if (multiPageInfo != null) { - // page count may be 0 when we know an entry to have multiple pages - // but fail to get information about these pages - final pageCount = multiPageInfo.pageCount; - if (pageCount > 0) { - final page = multiPageInfo.getById(entry.pageId ?? entry.id) ?? multiPageInfo.defaultPage; - pagePosition = '${(page?.index ?? 0) + 1}/$pageCount'; - } - } - return toText(pagePosition: pagePosition); - }, - ); - } -} - -class _DateRow extends StatelessWidget { - final AvesEntry entry; - final MultiPageController? multiPageController; - - const _DateRow({ - required this.entry, - required this.multiPageController, - }); - - @override - Widget build(BuildContext context) { - final locale = context.l10n.localeName; - final use24hour = context.select((v) => v.alwaysUse24HourFormat); - - final date = entry.bestDate; - final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; - final resolutionText = entry.isSvg - ? entry.aspectRatioText - : entry.isSized - ? entry.resolutionText - : ''; - - return Row( - children: [ - const DecoratedIcon(AIcons.date, shadows: Constants.embossShadows, size: _iconSize), - const SizedBox(width: _iconPadding), - Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), - Expanded(flex: 2, child: Text(resolutionText, strutStyle: Constants.overflowStrutStyle)), - ], - ); - } -} - -class _ShootingRow extends StatelessWidget { - final OverlayMetadata details; - - const _ShootingRow(this.details); - - @override - Widget build(BuildContext context) { - final locale = context.l10n.localeName; - - final aperture = details.aperture; - final apertureText = aperture != null ? 'ƒ/${NumberFormat('0.0', locale).format(aperture)}' : Constants.overlayUnknown; - - final focalLength = details.focalLength; - final focalLengthText = focalLength != null ? context.l10n.focalLength(NumberFormat('0.#', locale).format(focalLength)) : Constants.overlayUnknown; - - final iso = details.iso; - final isoText = iso != null ? 'ISO$iso' : Constants.overlayUnknown; - - return Row( - children: [ - const DecoratedIcon(AIcons.shooting, shadows: Constants.embossShadows, size: _iconSize), - const SizedBox(width: _iconPadding), - Expanded(child: Text(apertureText, strutStyle: Constants.overflowStrutStyle)), - Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), - Expanded(child: Text(focalLengthText, strutStyle: Constants.overflowStrutStyle)), - Expanded(child: Text(isoText, strutStyle: Constants.overflowStrutStyle)), - ], - ); - } } class ExtraBottomOverlay extends StatelessWidget { diff --git a/lib/widgets/viewer/overlay/bottom/details.dart b/lib/widgets/viewer/overlay/bottom/details.dart new file mode 100644 index 000000000..ec8235588 --- /dev/null +++ b/lib/widgets/viewer/overlay/bottom/details.dart @@ -0,0 +1,291 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/overlay.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/model/settings/enums/coordinate_format.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/format.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:decorated_icon/decorated_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +const double _iconPadding = 8.0; +const double _iconSize = 16.0; +const double _interRowPadding = 2.0; +const double _subRowMinWidth = 300.0; + +class ViewerDetailOverlay extends StatelessWidget { + final AvesEntry pageEntry; + final OverlayMetadata? details; + final String? position; + final double availableWidth; + final MultiPageController? multiPageController; + + static const padding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); + + const ViewerDetailOverlay({ + Key? key, + required this.pageEntry, + required this.details, + required this.position, + required this.availableWidth, + required this.multiPageController, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final infoMaxWidth = availableWidth - padding.horizontal; + final positionTitle = _PositionTitleRow(entry: pageEntry, collectionPosition: position, multiPageController: multiPageController); + final hasShootingDetails = details != null && !details!.isEmpty && settings.showOverlayShootingDetails; + final animationDuration = context.select((v) => v.viewerOverlayChangeAnimation); + + return Padding( + padding: padding, + child: Selector( + selector: (context, mq) => mq.orientation, + builder: (context, orientation, child) { + final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; + final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (positionTitle.isNotEmpty) positionTitle, + _buildSoloLocationRow(animationDuration), + if (twoColumns) + Padding( + padding: const EdgeInsets.only(top: _interRowPadding), + child: Row( + children: [ + SizedBox( + width: subRowWidth, + child: _DateRow( + entry: pageEntry, + multiPageController: multiPageController, + )), + _buildDuoShootingRow(subRowWidth, hasShootingDetails, animationDuration), + ], + ), + ) + else ...[ + Container( + padding: const EdgeInsets.only(top: _interRowPadding), + width: subRowWidth, + child: _DateRow( + entry: pageEntry, + multiPageController: multiPageController, + ), + ), + _buildSoloShootingRow(subRowWidth, hasShootingDetails, animationDuration), + ], + ], + ); + }, + ), + ); + } + + Widget _buildSoloLocationRow(Duration animationDuration) => AnimatedSwitcher( + duration: animationDuration, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: _soloTransition, + child: pageEntry.hasGps + ? Container( + padding: const EdgeInsets.only(top: _interRowPadding), + child: _LocationRow(entry: pageEntry), + ) + : const SizedBox(), + ); + + Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails, Duration animationDuration) => AnimatedSwitcher( + duration: animationDuration, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: _soloTransition, + child: hasShootingDetails + ? Container( + padding: const EdgeInsets.only(top: _interRowPadding), + width: subRowWidth, + child: _ShootingRow(details!), + ) + : const SizedBox(), + ); + + Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails, Duration animationDuration) => AnimatedSwitcher( + duration: animationDuration, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: hasShootingDetails + ? SizedBox( + width: subRowWidth, + child: _ShootingRow(details!), + ) + : const SizedBox(), + ); + + static Widget _soloTransition(Widget child, Animation animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + axisAlignment: 1, + sizeFactor: animation, + child: child, + ), + ); +} + +class _LocationRow extends AnimatedWidget { + final AvesEntry entry; + + _LocationRow({ + Key? key, + required this.entry, + }) : super(key: key, listenable: entry.addressChangeNotifier); + + @override + Widget build(BuildContext context) { + late final String location; + if (entry.hasAddress) { + location = entry.shortAddress; + } else { + final latLng = entry.latLng; + if (latLng != null) { + location = settings.coordinateFormat.format(context.l10n, latLng); + } else { + location = ''; + } + } + return Row( + children: [ + const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: _iconSize), + const SizedBox(width: _iconPadding), + Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), + ], + ); + } +} + +class _PositionTitleRow extends StatelessWidget { + final AvesEntry entry; + final String? collectionPosition; + final MultiPageController? multiPageController; + + const _PositionTitleRow({ + required this.entry, + required this.collectionPosition, + required this.multiPageController, + }); + + String? get title => entry.bestTitle; + + bool get isNotEmpty => collectionPosition != null || multiPageController != null || title != null; + + static const separator = ' • '; + + @override + Widget build(BuildContext context) { + Text toText({String? pagePosition}) => Text( + [ + if (collectionPosition != null) collectionPosition, + if (pagePosition != null) pagePosition, + if (title != null) '${Constants.fsi}$title${Constants.pdi}', + ].join(separator), + strutStyle: Constants.overflowStrutStyle); + + if (multiPageController == null) return toText(); + + return StreamBuilder( + stream: multiPageController!.infoStream, + builder: (context, snapshot) { + final multiPageInfo = multiPageController!.info; + String? pagePosition; + if (multiPageInfo != null) { + // page count may be 0 when we know an entry to have multiple pages + // but fail to get information about these pages + final pageCount = multiPageInfo.pageCount; + if (pageCount > 0) { + final page = multiPageInfo.getById(entry.pageId ?? entry.id) ?? multiPageInfo.defaultPage; + pagePosition = '${(page?.index ?? 0) + 1}/$pageCount'; + } + } + return toText(pagePosition: pagePosition); + }, + ); + } +} + +class _DateRow extends StatelessWidget { + final AvesEntry entry; + final MultiPageController? multiPageController; + + const _DateRow({ + required this.entry, + required this.multiPageController, + }); + + @override + Widget build(BuildContext context) { + final locale = context.l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + + final date = entry.bestDate; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; + final resolutionText = entry.isSvg + ? entry.aspectRatioText + : entry.isSized + ? entry.resolutionText + : ''; + + return Row( + children: [ + const DecoratedIcon(AIcons.date, shadows: Constants.embossShadows, size: _iconSize), + const SizedBox(width: _iconPadding), + Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), + Expanded(flex: 2, child: Text(resolutionText, strutStyle: Constants.overflowStrutStyle)), + ], + ); + } +} + +class _ShootingRow extends StatelessWidget { + final OverlayMetadata details; + + const _ShootingRow(this.details); + + @override + Widget build(BuildContext context) { + final locale = context.l10n.localeName; + + final aperture = details.aperture; + final apertureText = aperture != null ? 'ƒ/${NumberFormat('0.0', locale).format(aperture)}' : Constants.overlayUnknown; + + final focalLength = details.focalLength; + final focalLengthText = focalLength != null ? context.l10n.focalLength(NumberFormat('0.#', locale).format(focalLength)) : Constants.overlayUnknown; + + final iso = details.iso; + final isoText = iso != null ? 'ISO$iso' : Constants.overlayUnknown; + + return Row( + children: [ + const DecoratedIcon(AIcons.shooting, shadows: Constants.embossShadows, size: _iconSize), + const SizedBox(width: _iconPadding), + Expanded(child: Text(apertureText, strutStyle: Constants.overflowStrutStyle)), + Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), + Expanded(child: Text(focalLengthText, strutStyle: Constants.overflowStrutStyle)), + Expanded(child: Text(isoText, strutStyle: Constants.overflowStrutStyle)), + ], + ); + } +} diff --git a/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart b/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart new file mode 100644 index 000000000..a9b419455 --- /dev/null +++ b/lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart @@ -0,0 +1,66 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/thumbnail/scroller.dart'; +import 'package:aves/widgets/viewer/overlay/notifications.dart'; +import 'package:flutter/material.dart'; + +class ViewerThumbnailPreview extends StatefulWidget { + final List entries; + final int displayedIndex; + final double availableWidth; + + const ViewerThumbnailPreview({ + Key? key, + required this.entries, + required this.displayedIndex, + required this.availableWidth, + }) : super(key: key); + + @override + _ViewerThumbnailPreviewState createState() => _ViewerThumbnailPreviewState(); +} + +class _ViewerThumbnailPreviewState extends State { + final ValueNotifier _entryIndexNotifier = ValueNotifier(0); + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + + List get entries => widget.entries; + + int get entryCount => entries.length; + + @override + void initState() { + super.initState(); + _entryIndexNotifier.value = widget.displayedIndex; + _entryIndexNotifier.addListener(_onScrollerIndexChange); + } + + @override + void didUpdateWidget(covariant ViewerThumbnailPreview oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.displayedIndex != widget.displayedIndex) { + _entryIndexNotifier.value = widget.displayedIndex; + } + } + + @override + void dispose() { + _entryIndexNotifier.removeListener(_onScrollerIndexChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ThumbnailScroller( + availableWidth: widget.availableWidth, + entryCount: entryCount, + entryBuilder: (index) => index < entryCount ? entries[index] : null, + indexNotifier: _entryIndexNotifier, + onTap: (index) => ViewEntryNotification(index: index).dispatch(context), + ); + } + + void _onScrollerIndexChange() => _debouncer(() => ViewEntryNotification(index: _entryIndexNotifier.value).dispatch(context)); +} diff --git a/lib/widgets/viewer/overlay/notifications.dart b/lib/widgets/viewer/overlay/notifications.dart index 4b7e26dc3..8444b9a61 100644 --- a/lib/widgets/viewer/overlay/notifications.dart +++ b/lib/widgets/viewer/overlay/notifications.dart @@ -1,7 +1,15 @@ import 'package:flutter/material.dart'; +@immutable class ToggleOverlayNotification extends Notification { final bool? visible; const ToggleOverlayNotification({this.visible}); } + +@immutable +class ViewEntryNotification extends Notification { + final int index; + + const ViewEntryNotification({required this.index}); +} diff --git a/pubspec.lock b/pubspec.lock index 532b1ddf8..413cc518c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,6 +525,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" + known_extents_list_view_builder: + dependency: "direct main" + description: + name: known_extents_list_view_builder + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" latlong2: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e45286bb8..29f9110f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: google_api_availability: google_maps_flutter: intl: + known_extents_list_view_builder: latlong2: material_design_icons_flutter: overlay_support: diff --git a/untranslated.json b/untranslated.json index be3e30d53..2ab420419 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,9 +1,31 @@ { "de": [ - "entryActionConvert" + "entryActionConvert", + "settingsViewerShowOverlayThumbnailPreview" + ], + + "es": [ + "settingsViewerShowOverlayThumbnailPreview" + ], + + "fr": [ + "settingsViewerShowOverlayThumbnailPreview" + ], + + "id": [ + "settingsViewerShowOverlayThumbnailPreview" + ], + + "ko": [ + "settingsViewerShowOverlayThumbnailPreview" + ], + + "pt": [ + "settingsViewerShowOverlayThumbnailPreview" ], "ru": [ - "entryActionConvert" + "entryActionConvert", + "settingsViewerShowOverlayThumbnailPreview" ] }