#193 viewer: thumbnail preview
This commit is contained in:
parent
8f6ce6674b
commit
437572550a
17 changed files with 483 additions and 297 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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<int?> 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<ThumbnailScroller> {
|
|||
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<ThumbnailScroller> {
|
|||
],
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => separator,
|
||||
itemCount: entryCount + 2,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -75,6 +75,14 @@ class ViewerOverlayPage extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
Selector<Settings, bool>(
|
||||
selector: (context, s) => s.showOverlayThumbnailPreview,
|
||||
builder: (context, current, child) => SwitchListTile(
|
||||
value: current,
|
||||
onChanged: (v) => settings.showOverlayThumbnailPreview = v,
|
||||
title: Text(context.l10n.settingsViewerShowOverlayThumbnailPreview),
|
||||
),
|
||||
),
|
||||
Selector<Settings, bool>(
|
||||
selector: (context, s) => s.enableOverlayBlurEffect,
|
||||
builder: (context, current, child) => SwitchListTile(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -204,6 +204,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<ViewerBottomOverlay> {
|
|||
AvesEntry? _lastEntry;
|
||||
OverlayMetadata? _lastDetails;
|
||||
|
||||
List<AvesEntry> 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<ViewerBottomOverlay> {
|
|||
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<ViewerBottomOverlay> {
|
|||
}
|
||||
}
|
||||
|
||||
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<AvesEntry> 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<MediaQueryData, Orientation>(
|
||||
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<DurationsData, Duration>((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<double> 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<MultiPageInfo?>(
|
||||
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<MediaQueryData, bool>((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 {
|
||||
|
|
291
lib/widgets/viewer/overlay/bottom/details.dart
Normal file
291
lib/widgets/viewer/overlay/bottom/details.dart
Normal file
|
@ -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<DurationsData, Duration>((v) => v.viewerOverlayChangeAnimation);
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Selector<MediaQueryData, Orientation>(
|
||||
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<double> 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<MultiPageInfo?>(
|
||||
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<MediaQueryData, bool>((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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
66
lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart
Normal file
66
lib/widgets/viewer/overlay/bottom/thumbnail_preview.dart
Normal file
|
@ -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<AvesEntry> 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<ViewerThumbnailPreview> {
|
||||
final ValueNotifier<int> _entryIndexNotifier = ValueNotifier(0);
|
||||
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
||||
|
||||
List<AvesEntry> 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));
|
||||
}
|
|
@ -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});
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -49,6 +49,7 @@ dependencies:
|
|||
google_api_availability:
|
||||
google_maps_flutter:
|
||||
intl:
|
||||
known_extents_list_view_builder:
|
||||
latlong2:
|
||||
material_design_icons_flutter:
|
||||
overlay_support:
|
||||
|
|
|
@ -1,9 +1,31 @@
|
|||
{
|
||||
"de": [
|
||||
"entryActionConvert"
|
||||
"entryActionConvert",
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
],
|
||||
|
||||
"es": [
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
],
|
||||
|
||||
"id": [
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
],
|
||||
|
||||
"ko": [
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"entryActionConvert"
|
||||
"entryActionConvert",
|
||||
"settingsViewerShowOverlayThumbnailPreview"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue