Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-01-04 21:00:58 +09:00
commit 440705ba25
34 changed files with 888 additions and 291 deletions

View file

@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.3.1] - 2021-01-04
### Added
- Collection: long press and move to select/deselect multiple entries
- Info: show Spherical Video V1 metadata
- Info: metadata search
### Fixed
- Viewer: fixed panning inertia following double-tap scaling
- Collection: fixed crash when loading TIFF files on Android 11
## [v1.3.0] - 2020-12-26
### Added
- Viewer: quick scale (aka one finger zoom)

View file

@ -240,26 +240,29 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
try {
val metadataMap = HashMap<String, FieldMap>()
var dirCount: Int? = null
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
metadataMap["0"] = tiffOptionsToMap(options)
dirCount = options.outDirectoryCount
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
return
}
if (dirCount != null) {
for (i in 1 until dirCount!!) {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = i
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
metadataMap["$i"] = tiffOptionsToMap(options)
}
var options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
metadataMap["0"] = tiffOptionsToMap(options)
val dirCount = options.outDirectoryCount
for (i in 1 until dirCount) {
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
return
}
options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
inDirectoryNumber = i
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
metadataMap["$i"] = tiffOptionsToMap(options)
}
result.success(metadataMap)
} catch (e: Exception) {

View file

@ -21,17 +21,15 @@ import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.Geotiff
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
@ -40,7 +38,6 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.isPanorama
@ -142,6 +139,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// remove this stat as it is not actual XMP data
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
}
if (dir is Mp4UuidBoxDirectory) {
if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
metadataMap.remove(dirName)
}
}
}
}
} catch (e: Exception) {
@ -348,7 +353,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// identification of spherical video (aka 360° video)
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID
}) {
flags = flags or MASK_IS_360
}

View file

@ -18,20 +18,22 @@ class TiffRegionFetcher internal constructor(
page: Int = 0,
result: MethodChannel.Result,
) {
val resolver = context.contentResolver
try {
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) {
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
}
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
result.error("getRegion-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
val options = TiffBitmapFactory.Options().apply {
inDirectoryNumber = page
inSampleSize = sampleSize
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null)
}
} catch (e: Exception) {
result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message)

View file

@ -139,28 +139,33 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamTiffImage(uri: Uri, page: Int = 0) {
val resolver = activity.contentResolver
try {
var dirCount = 0
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
dirCount = options.outDirectoryCount
var fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
var options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val dirCount = options.outDirectoryCount
// TODO TLAD handle multipage TIFF
if (dirCount > page) {
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = page
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
}
fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
return
}
options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = page
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
}
}
} catch (e: Exception) {

View file

@ -48,30 +48,35 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
val uri = model.uri
// determine sample size
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
callback.onLoadFailed(Exception("null file descriptor"))
return
}
var sampleSize = 1
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2
}
var options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2
}
}
// decode
val bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
if (fd == null) {
callback.onLoadFailed(Exception("null file descriptor"))
return
}
options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap"))
} else {

View file

@ -19,9 +19,6 @@ object Metadata {
// "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
// cf https://github.com/google/spatial-media
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
// directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // from metadata-extractor

View file

@ -0,0 +1,109 @@
package deckers.thibault.aves.metadata
import android.util.Log
import android.util.Xml
import deckers.thibault.aves.utils.LogUtils
import org.xmlpull.v1.XmlPullParser
import java.io.ByteArrayInputStream
// `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
class GSpherical(xmlBytes: ByteArray) {
var spherical: Boolean = false
var stitched: Boolean = false
var stitchingSoftware: String = ""
var projectionType: String = ""
var stereoMode: String? = null
var sourceCount: Int? = null
var initialViewHeadingDegrees: Int? = null
var initialViewPitchDegrees: Int? = null
var initialViewRollDegrees: Int? = null
var timestamp: Int? = null
var fullPanoWidthPixels: Int? = null
var fullPanoHeightPixels: Int? = null
var croppedAreaImageWidthPixels: Int? = null
var croppedAreaImageHeightPixels: Int? = null
var croppedAreaLeftPixels: Int? = null
var croppedAreaTopPixels: Int? = null
init {
try {
ByteArrayInputStream(xmlBytes).use {
val parser = Xml.newPullParser().apply {
setInput(it, null)
nextTag()
require(XmlPullParser.START_TAG, RDF_NS, "SphericalVideo")
}
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
if (parser.namespace == GSPHERICAL_NS) {
when (val tag = parser.name) {
"Spherical" -> spherical = readTag(parser, tag) == "true"
"Stitched" -> stitched = readTag(parser, tag) == "true"
"StitchingSoftware" -> stitchingSoftware = readTag(parser, tag)
"ProjectionType" -> projectionType = readTag(parser, tag)
"StereoMode" -> stereoMode = readTag(parser, tag)
"SourceCount" -> sourceCount = Integer.parseInt(readTag(parser, tag))
"InitialViewHeadingDegrees" -> initialViewHeadingDegrees = Integer.parseInt(readTag(parser, tag))
"InitialViewPitchDegrees" -> initialViewPitchDegrees = Integer.parseInt(readTag(parser, tag))
"InitialViewRollDegrees" -> initialViewRollDegrees = Integer.parseInt(readTag(parser, tag))
"Timestamp" -> timestamp = Integer.parseInt(readTag(parser, tag))
"FullPanoWidthPixels" -> fullPanoWidthPixels = Integer.parseInt(readTag(parser, tag))
"FullPanoHeightPixels" -> fullPanoHeightPixels = Integer.parseInt(readTag(parser, tag))
"CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = Integer.parseInt(readTag(parser, tag))
"CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = Integer.parseInt(readTag(parser, tag))
"CroppedAreaLeftPixels" -> croppedAreaLeftPixels = Integer.parseInt(readTag(parser, tag))
"CroppedAreaTopPixels" -> croppedAreaTopPixels = Integer.parseInt(readTag(parser, tag))
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to parse XML", e)
}
}
fun describe(): Map<String, String?> = hashMapOf(
"Spherical" to spherical.toString(),
"Stitched" to stitched.toString(),
"Stitching Software" to stitchingSoftware,
"Projection Type" to projectionType,
"Stereo Mode" to stereoMode,
"Source Count" to sourceCount?.toString(),
"Initial View Heading Degrees" to initialViewHeadingDegrees?.toString(),
"Initial View Pitch Degrees" to initialViewPitchDegrees?.toString(),
"Initial View Roll Degrees" to initialViewRollDegrees?.toString(),
"Timestamp" to timestamp?.toString(),
"Full Panorama Width Pixels" to fullPanoWidthPixels?.toString(),
"Full Panorama Height Pixels" to fullPanoHeightPixels?.toString(),
"Cropped Area Image Width Pixels" to croppedAreaImageWidthPixels?.toString(),
"Cropped Area Image Height Pixels" to croppedAreaImageHeightPixels?.toString(),
"Cropped Area Left Pixels" to croppedAreaLeftPixels?.toString(),
"Cropped Area Top Pixels" to croppedAreaTopPixels?.toString(),
).filterValues { it != null }
companion object SphericalVideo {
private val LOG_TAG = LogUtils.createTag(SphericalVideo::class.java)
// cf https://github.com/google/spatial-media
const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd"
const val RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
const val GSPHERICAL_NS = "http://ns.google.com/videos/1.0/spherical/"
private fun readText(parser: XmlPullParser): String {
var text = ""
if (parser.next() == XmlPullParser.TEXT) {
text = parser.text
parser.nextTag()
}
return text
}
private fun readTag(parser: XmlPullParser, tag: String): String {
parser.require(XmlPullParser.START_TAG, GSPHERICAL_NS, tag)
val text = readText(parser)
parser.require(XmlPullParser.END_TAG, GSPHERICAL_NS, tag)
return text
}
}
}

View file

@ -249,14 +249,13 @@ class SourceImageEntry {
private fun fillByTiffDecode(context: Context) {
try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
width = options.outWidth
height = options.outHeight
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
width = options.outWidth
height = options.outHeight
} catch (e: Exception) {
// ignore
}

View file

@ -172,7 +172,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
List<Widget> _buildActions() {
return [
if (collection.isBrowsing)
SearchButton(
CollectionSearchButton(
source,
parentCollection: collection,
),
@ -361,7 +361,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
delegate: CollectionSearchDelegate(
source: collection.source,
parentCollection: collection,
),

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -143,6 +144,25 @@ class SectionedListLayout {
final top = sectionLayout.indexToLayoutOffset(listIndex);
return Rect.fromLTWH(left, top, tileExtent, tileExtent);
}
ImageEntry getEntryAt(Offset position) {
var dy = position.dy;
final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null);
if (sectionLayout == null) return null;
final section = collection.sections[sectionLayout.sectionKey];
if (section == null) return null;
dy -= sectionLayout.minOffset + sectionLayout.headerExtent;
if (dy < 0) return null;
final row = dy ~/ tileExtent;
final column = position.dx ~/ tileExtent;
final index = row * columnCount + column;
if (index >= section.length) return null;
return section[index];
}
}
class SectionLayout {
@ -184,4 +204,7 @@ class SectionLayout {
scrollOffset -= minOffset + headerExtent;
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent}';
}

View file

@ -65,11 +65,6 @@ class GridThumbnail extends StatelessWidget {
ViewerService.pick(entry.uri);
}
},
onLongPress: () {
if (AvesApp.mode == AppMode.main) {
collection.toggleSelection(entry);
}
},
child: MetaData(
metaData: ScalerMetadata(entry),
child: DecoratedThumbnail(

View file

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class GridSelectionGestureDetector extends StatefulWidget {
final bool selectable;
final CollectionLens collection;
final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier;
final Widget child;
const GridSelectionGestureDetector({
this.selectable = true,
@required this.collection,
@required this.scrollController,
@required this.appBarHeightNotifier,
@required this.child,
});
@override
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState();
}
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
bool _pressing, _selecting;
int _fromIndex, _lastToIndex;
Offset _localPosition;
EdgeInsets _scrollableInsets;
double _scrollSpeedFactor;
Timer _updateTimer;
CollectionLens get collection => widget.collection;
List<ImageEntry> get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController;
double get appBarHeight => widget.appBarHeightNotifier.value;
static const double scrollEdgeRatio = .15;
static const double scrollMaxPixelPerSecond = 600.0;
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPressStart: widget.selectable
? (details) {
final fromEntry = _getEntryAt(details.localPosition);
if (fromEntry == null) return;
collection.toggleSelection(fromEntry);
_selecting = collection.isSelected([fromEntry]);
_fromIndex = entries.indexOf(fromEntry);
_lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only(
top: appBarHeight,
bottom: context.read<MediaQueryData>().viewInsets.bottom,
);
_scrollSpeedFactor = 0;
_pressing = true;
}
: null,
onLongPressMoveUpdate: widget.selectable
? (details) {
if (!_pressing) return;
_localPosition = details.localPosition;
_onLongPressUpdate();
}
: null,
onLongPressEnd: widget.selectable
? (details) {
if (!_pressing) return;
_setScrollSpeed(0);
_pressing = false;
}
: null,
child: widget.child,
);
}
void _onLongPressUpdate() {
final dy = _localPosition.dy;
final height = scrollController.position.viewportDimension;
final top = dy < height / 2;
final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom);
final threshold = height * scrollEdgeRatio;
if (distanceToEdge < threshold) {
_setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1));
} else {
_setScrollSpeed(0);
}
final toEntry = _getEntryAt(_localPosition);
_toggleSelectionToIndex(entries.indexOf(toEntry));
}
void _setScrollSpeed(double speedFactor) {
if (speedFactor == _scrollSpeedFactor) return;
_scrollSpeedFactor = speedFactor;
_updateTimer?.cancel();
final current = scrollController.offset;
if (speedFactor == 0) {
scrollController.jumpTo(current);
return;
}
final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0;
if (target != current) {
final distance = target - current;
final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor;
scrollController.animateTo(
target,
duration: Duration(milliseconds: millis.round()),
curve: Curves.linear,
);
// use a timer to update the entry selection, because `onLongPressMoveUpdate`
// is not called when the pointer stays still while the view is scrolling
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
}
}
ImageEntry _getEntryAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
return context.read<SectionedListLayout>().getEntryAt(offset);
}
void _toggleSelectionToIndex(int toIndex) {
if (toIndex == -1) return;
if (_selecting) {
if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) {
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_fromIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1));
}
} else if (_lastToIndex < toIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
}
} else if (_fromIndex < toIndex) {
if (_lastToIndex < toIndex) {
collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
if (_lastToIndex < _fromIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
}
} else if (toIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
}
}
_lastToIndex = toIndex;
} else {
collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
}
}
}

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/highlight.dart';
@ -13,6 +14,7 @@ import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
import 'package:aves/widgets/collection/grid/list_sliver.dart';
import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget {
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context);
// do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes
@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget {
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context),
scrollController: scrollController,
cacheExtent: cacheExtent,
);
@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget {
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedListLayoutProvider(
@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget {
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: scaler,
child: selector,
),
);
return sectionedListLayoutProvider;

View file

@ -0,0 +1,79 @@
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class QueryBar extends StatefulWidget {
final ValueNotifier<String> filterNotifier;
const QueryBar({@required this.filterNotifier});
@override
_QueryBarState createState() => _QueryBarState();
}
class _QueryBarState extends State<QueryBar> {
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
TextEditingController _controller;
ValueNotifier<String> get filterNotifier => widget.filterNotifier;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: filterNotifier.value);
}
@override
Widget build(BuildContext context) {
final clearButton = IconButton(
icon: Icon(AIcons.clear),
onPressed: () {
_controller.clear();
filterNotifier.value = '';
},
tooltip: 'Clear',
);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
icon: Padding(
padding: EdgeInsetsDirectional.only(start: 16),
child: Icon(AIcons.search),
),
hintText: MaterialLocalizations.of(context).searchFieldLabel,
hintStyle: Theme.of(context).inputDecorationTheme.hintStyle,
),
textInputAction: TextInputAction.search,
onChanged: (s) => _debouncer(() => filterNotifier.value = s),
),
),
ConstrainedBox(
constraints: BoxConstraints(minWidth: 16),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, child) => AnimatedSwitcher(
duration: Durations.appBarActionChangeAnimation,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: child,
),
),
child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
),
),
)
],
);
}
}

View file

@ -5,13 +5,15 @@ import 'package:flutter/material.dart';
class AvesExpansionTile extends StatelessWidget {
final String title;
final Color color;
final List<Widget> children;
final ValueNotifier<String> expandedNotifier;
final bool initiallyExpanded;
final List<Widget> children;
const AvesExpansionTile({
@required this.title,
this.color,
this.expandedNotifier,
this.initiallyExpanded = false,
@required this.children,
});
@ -33,6 +35,9 @@ class AvesExpansionTile extends StatelessWidget {
enabled: enabled,
),
expandable: enabled,
initiallyExpanded: initiallyExpanded,
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -41,8 +46,6 @@ class AvesExpansionTile extends StatelessWidget {
if (enabled) ...children,
],
),
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
),
);
}

View file

@ -168,6 +168,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: (details) => _tapPosition = details.globalPosition,
onTap: widget.onTap != null
? () {

View file

@ -43,7 +43,7 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
Offset _startFocalPoint, _lastViewportFocalPosition;
double _startScale, _quickScaleLastY, _quickScaleLastDistance;
bool _doubleTap, _quickScaleMoved;
DateTime _lastScaleGestureDate;
DateTime _lastScaleGestureDate = DateTime.now();
AnimationController _scaleAnimationController;
Animation<double> _scaleAnimation;

View file

@ -3,10 +3,9 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
@ -116,84 +115,25 @@ class AlbumPickAppBar extends StatelessWidget {
}
}
class AlbumFilterBar extends StatefulWidget implements PreferredSizeWidget {
class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget {
final ValueNotifier<String> filterNotifier;
static const preferredHeight = kToolbarHeight;
const AlbumFilterBar({@required this.filterNotifier});
const AlbumFilterBar({
@required this.filterNotifier,
});
@override
Size get preferredSize => Size.fromHeight(preferredHeight);
@override
_AlbumFilterBarState createState() => _AlbumFilterBarState();
}
class _AlbumFilterBarState extends State<AlbumFilterBar> {
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
TextEditingController _controller;
ValueNotifier<String> get filterNotifier => widget.filterNotifier;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: filterNotifier.value);
}
@override
Widget build(BuildContext context) {
final clearButton = IconButton(
icon: Icon(AIcons.clear),
onPressed: () {
_controller.clear();
filterNotifier.value = '';
},
tooltip: 'Clear',
);
return Container(
height: AlbumFilterBar.preferredHeight,
alignment: Alignment.topCenter,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon(AIcons.search),
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
icon: Padding(
padding: EdgeInsetsDirectional.only(start: 16),
child: Icon(AIcons.search),
),
// border: OutlineInputBorder(),
hintText: MaterialLocalizations.of(context).searchFieldLabel,
hintStyle: Theme.of(context).inputDecorationTheme.hintStyle,
),
textInputAction: TextInputAction.search,
onChanged: (s) => _debouncer(() => filterNotifier.value = s),
),
),
ConstrainedBox(
constraints: BoxConstraints(minWidth: 16),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, child) => AnimatedSwitcher(
duration: Durations.appBarActionChangeAnimation,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: child,
),
),
child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
),
),
)
],
child: QueryBar(
filterNotifier: filterNotifier,
),
);
}

View file

@ -85,6 +85,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40);
// TODO TLAD show menu within safe area
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
@ -103,7 +104,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
List<Widget> _buildActions(BuildContext context) {
return [
SearchButton(source),
CollectionSearchButton(source),
PopupMenuButton<ChipSetAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
@ -136,7 +137,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
delegate: CollectionSearchDelegate(
source: source,
),
));

View file

@ -0,0 +1,53 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/fullscreen/info/info_search.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
import 'package:flutter/material.dart';
class InfoAppBar extends StatelessWidget {
final ImageEntry entry;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
final VoidCallback onBackPressed;
const InfoAppBar({
@required this.entry,
@required this.metadataNotifier,
@required this.onBackPressed,
});
@override
Widget build(BuildContext context) {
return SliverAppBar(
leading: IconButton(
key: Key('back-button'),
icon: Icon(AIcons.goUp),
onPressed: onBackPressed,
tooltip: 'Back to viewer',
),
title: TappableAppBarTitle(
onTap: () => _goToSearch(context),
child: Text('Info'),
),
actions: [
IconButton(
icon: Icon(AIcons.search),
onPressed: () => _goToSearch(context),
tooltip: 'Search',
),
],
titleSpacing: 0,
floating: true,
);
}
void _goToSearch(BuildContext context) {
showSearch(
context: context,
delegate: InfoSearchDelegate(
entry: entry,
metadataNotifier: metadataNotifier,
),
);
}
}

View file

@ -2,9 +2,9 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/fullscreen/info/basic_section.dart';
import 'package:aves/widgets/fullscreen/info/info_app_bar.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart';
@ -38,17 +38,6 @@ class InfoPageState extends State<InfoPage> {
@override
Widget build(BuildContext context) {
final appBar = SliverAppBar(
leading: IconButton(
key: Key('back-button'),
icon: Icon(AIcons.goUp),
onPressed: _goToImage,
tooltip: 'Back to viewer',
),
title: Text('Info'),
floating: true,
);
return MediaQueryDataProvider(
child: Scaffold(
body: SafeArea(
@ -68,9 +57,9 @@ class InfoPageState extends State<InfoPage> {
entry: entry,
visibleNotifier: widget.visibleNotifier,
scrollController: _scrollController,
appBar: appBar,
split: mqWidth > 400,
mqViewInsetsBottom: mqViewInsetsBottom,
goToViewer: _goToViewer,
)
: SizedBox.shrink();
},
@ -97,7 +86,7 @@ class InfoPageState extends State<InfoPage> {
_scrollStartFromTop = false;
} else if (notification is OverscrollNotification) {
if (notification.overscroll < 0) {
_goToImage();
_goToViewer();
_scrollStartFromTop = false;
}
}
@ -106,7 +95,7 @@ class InfoPageState extends State<InfoPage> {
return false;
}
void _goToImage() {
void _goToViewer() {
BackUpNotification().dispatch(context);
_scrollController.animateTo(
0,
@ -121,9 +110,9 @@ class _InfoPageContent extends StatefulWidget {
final ImageEntry entry;
final ValueNotifier<bool> visibleNotifier;
final ScrollController scrollController;
final SliverAppBar appBar;
final bool split;
final double mqViewInsetsBottom;
final VoidCallback goToViewer;
const _InfoPageContent({
Key key,
@ -131,9 +120,9 @@ class _InfoPageContent extends StatefulWidget {
@required this.entry,
@required this.visibleNotifier,
@required this.scrollController,
@required this.appBar,
@required this.split,
@required this.mqViewInsetsBottom,
@required this.goToViewer,
}) : super(key: key);
@override
@ -143,6 +132,8 @@ class _InfoPageContent extends StatefulWidget {
class _InfoPageContentState extends State<_InfoPageContent> {
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
CollectionLens get collection => widget.collection;
ImageEntry get entry => widget.entry;
@ -178,13 +169,18 @@ class _InfoPageContentState extends State<_InfoPageContent> {
);
final metadataSliver = MetadataSectionSliver(
entry: entry,
metadataNotifier: _metadataNotifier,
visibleNotifier: widget.visibleNotifier,
);
return CustomScrollView(
controller: widget.scrollController,
slivers: [
widget.appBar,
InfoAppBar(
entry: entry,
metadataNotifier: _metadataNotifier,
onBackPressed: widget.goToViewer,
),
SliverPadding(
padding: horizontalPadding + EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver,

View file

@ -0,0 +1,118 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
class InfoSearchDelegate extends SearchDelegate {
final ImageEntry entry;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
static const suggestions = {
'Date & time': 'date or time or when -timer -uptime -exposure -timeline',
'Description': 'abstract or description or comment',
'Dimensions': 'width or height or dimension or framesize or imagelength',
'Resolution': 'resolution',
'Rights': 'rights or copyright or artist or creator or by-line or credit -tool',
};
InfoSearchDelegate({
@required this.entry,
@required this.metadataNotifier,
}) : super(
searchFieldLabel: 'Search metadata',
);
@override
ThemeData appBarTheme(BuildContext context) {
return Theme.of(context);
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () => Navigator.pop(context),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
@override
List<Widget> buildActions(BuildContext context) {
return [
if (query.isNotEmpty)
IconButton(
icon: Icon(AIcons.clear),
onPressed: () {
query = '';
showSuggestions(context);
},
tooltip: 'Clear',
),
];
}
@override
Widget buildSuggestions(BuildContext context) => ListView(
children: suggestions.entries
.map((kv) => ListTile(
title: Text(kv.key),
onTap: () {
query = kv.value;
showResults(context);
},
))
.toList(),
);
@override
Widget buildResults(BuildContext context) {
if (query.isEmpty) {
showSuggestions(context);
return SizedBox();
}
final queryParts = query.toUpperCase().split(' ')..removeWhere((s) => s.isEmpty);
final queryExcludeIncludeGroups = groupBy<String, bool>(queryParts, (s) => s.startsWith('-'));
final queryExcludeAll = (queryExcludeIncludeGroups[true] ?? []).map((s) => s.substring(1));
final queryIncludeAny = (queryExcludeIncludeGroups[false] ?? []).join(' ').split(' OR ');
bool testKey(String key) {
key = key.toUpperCase();
return queryIncludeAny.any(key.contains) && queryExcludeAll.every((q) => !key.contains(q));
}
final filteredMetadata = Map.fromEntries(metadata.entries.map((kv) {
final filteredDir = kv.value.filterKeys(testKey);
return MapEntry(kv.key, filteredDir);
}));
final tiles = filteredMetadata.entries
.where((kv) => kv.value.tags.isNotEmpty)
.map((kv) => MetadataDirTile(
entry: entry,
title: kv.key,
dir: kv.value,
initiallyExpanded: true,
showPrefixChildren: false,
))
.toList();
return tiles.isEmpty
? EmptyContent(
icon: AIcons.info,
text: 'No matching keys',
)
: ListView.builder(
padding: EdgeInsets.all(8),
itemBuilder: (context, index) => tiles[index],
itemCount: tiles.length,
);
}
}

View file

@ -0,0 +1,113 @@
import 'dart:collection';
import 'package:aves/model/image_entry.dart';
import 'package:aves/ref/brand_colors.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MetadataDirTile extends StatelessWidget {
final ImageEntry entry;
final String title;
final MetadataDirectory dir;
final ValueNotifier<String> expandedDirectoryNotifier;
final bool initiallyExpanded, showPrefixChildren;
const MetadataDirTile({
@required this.entry,
@required this.title,
@required this.dir,
this.expandedDirectoryNotifier,
this.initiallyExpanded = false,
this.showPrefixChildren = true,
});
@override
Widget build(BuildContext context) {
final tags = dir.tags;
if (tags.isEmpty) return SizedBox.shrink();
final dirName = dir.name;
if (dirName == MetadataDirectory.xmpDirectory) {
return XmpDirTile(
entry: entry,
tags: tags,
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
);
}
Widget thumbnail;
final prefixChildren = <Widget>[];
if (showPrefixChildren) {
switch (dirName) {
case MetadataDirectory.exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
break;
case MetadataDirectory.mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
Widget builder(IconData data) => Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Icon(data),
);
if (tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
if (tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
if (tags['Has Image'] == 'yes') {
int count;
if (tags.containsKey('Image Count')) {
count = int.tryParse(tags['Image Count']);
}
prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image)));
}
break;
}
}
return AvesExpansionTile(
title: title,
color: BrandColors.get(dirName) ?? stringToColor(dirName),
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
children: [
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
tags,
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null,
),
),
],
);
}
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
return {
'Metadata': InfoLinkHandler(
linkText: 'View XML',
onTap: (context) {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(
loader: () => SynchronousFuture(tags['Metadata']),
),
),
);
},
),
};
}
}

View file

@ -1,18 +1,12 @@
import 'dart:collection';
import 'package:aves/model/image_entry.dart';
import 'package:aves/ref/brand_colors.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -21,10 +15,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
class MetadataSectionSliver extends StatefulWidget {
final ImageEntry entry;
final ValueNotifier<bool> visibleNotifier;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
const MetadataSectionSliver({
@required this.entry,
@required this.visibleNotifier,
@required this.metadataNotifier,
});
@override
@ -32,7 +28,6 @@ class MetadataSectionSliver extends StatefulWidget {
}
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
Map<String, _MetadataDirectory> _metadata = {};
final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null);
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
@ -40,10 +35,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
bool get isVisible => widget.visibleNotifier.value;
// special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
// directory names may contain the name of their parent directory
// if so, they are separated by this character
@ -53,6 +47,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
void initState() {
super.initState();
_registerWidget(widget);
metadataNotifier.value = {};
_getMetadata();
}
@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
valueListenable: _loadedMetadataUri,
builder: (context, uri, child) {
Widget content;
if (_metadata.isEmpty) {
if (metadata.isEmpty) {
content = SizedBox.shrink();
} else {
content = Column(
@ -111,7 +106,12 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
),
children: [
SectionRow(AIcons.info),
..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)),
...metadata.entries.map((kv) => MetadataDirTile(
entry: entry,
title: kv.key,
dir: kv.value,
expandedDirectoryNotifier: _expandedDirectoryNotifier,
)),
],
),
);
@ -128,64 +128,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
);
}
Widget _buildDirTile(String title, _MetadataDirectory dir) {
if (dir.tags.isEmpty) return SizedBox.shrink();
final dirName = dir.name;
if (dirName == xmpDirectory) {
return XmpDirTile(
entry: entry,
tags: dir.tags,
expandedNotifier: _expandedDirectoryNotifier,
);
}
Widget thumbnail;
final prefixChildren = <Widget>[];
switch (dirName) {
case exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
break;
case mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
Widget builder(IconData data) => Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Icon(data),
);
if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
if (dir.tags['Has Image'] == 'yes') {
int count;
if (dir.tags.containsKey('Image Count')) {
count = int.tryParse(dir.tags['Image Count']);
}
prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image)));
}
break;
}
return AvesExpansionTile(
title: title,
color: BrandColors.get(dirName) ?? stringToColor(dirName),
expandedNotifier: _expandedDirectoryNotifier,
children: [
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
dir.tags,
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null,
),
),
],
);
}
void _onMetadataChanged() {
_loadedMetadataUri.value = null;
_metadata = {};
metadataNotifier.value = {};
_getMetadata();
}
@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
final tagName = tagKV.key as String ?? '';
return MapEntry(tagName, value);
}).where((kv) => kv != null)));
return _MetadataDirectory(directoryName, parent, tags);
return MetadataDirectory(directoryName, parent, tags);
}).toList();
final titledDirectories = directories.map((dir) {
@ -222,42 +167,36 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return MapEntry(title, dir);
}).toList()
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
_metadata = Map.fromEntries(titledDirectories);
metadataNotifier.value = Map.fromEntries(titledDirectories);
_loadedMetadataUri.value = entry.uri;
} else {
_metadata = {};
metadataNotifier.value = {};
_loadedMetadataUri.value = null;
}
_expandedDirectoryNotifier.value = null;
}
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
return {
'Metadata': InfoLinkHandler(
linkText: 'View XML',
onTap: (context) {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(
loader: () => SynchronousFuture(tags['Metadata']),
),
),
);
},
),
};
}
@override
bool get wantKeepAlive => true;
}
class _MetadataDirectory {
class MetadataDirectory {
final String name;
final String parent;
final SplayTreeMap<String, String> allTags;
final SplayTreeMap<String, String> tags;
const _MetadataDirectory(this.name, this.parent, this.tags);
// special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> tags})
: allTags = allTags,
tags = tags ?? allTags;
MetadataDirectory filterKeys(bool Function(String key) testKey) {
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
return MetadataDirectory(name, parent, tags, tags: filteredTags);
}
}

View file

@ -51,9 +51,13 @@ class XmpMMNamespace extends XmpNamespace {
static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)');
static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)');
static final ingredientsPattern = RegExp(r'xmpMM:Ingredients\[(\d+)\]/(.*)');
static final pantryPattern = RegExp(r'xmpMM:Pantry\[(\d+)\]/(.*)');
final derivedFrom = <String, String>{};
final history = <int, Map<String, String>>{};
final ingredients = <int, Map<String, String>>{};
final pantry = <int, Map<String, String>>{};
XmpMMNamespace() : super(ns);
@ -63,7 +67,9 @@ class XmpMMNamespace extends XmpNamespace {
@override
bool extractData(XmpProp prop) {
final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom);
final hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history);
var hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history);
hasIndexedStructs |= extractIndexedStruct(prop, ingredientsPattern, ingredients);
hasIndexedStructs |= extractIndexedStruct(prop, pantryPattern, pantry);
return hasStructs || hasIndexedStructs;
}
@ -79,6 +85,16 @@ class XmpMMNamespace extends XmpNamespace {
title: 'History',
structByIndex: history,
),
if (ingredients.isNotEmpty)
XmpStructArrayCard(
title: 'Ingredients',
structByIndex: ingredients,
),
if (pantry.isNotEmpty)
XmpStructArrayCard(
title: 'Pantry',
structByIndex: pantry,
),
];
@override

View file

@ -89,7 +89,7 @@ class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
// without clipping the text
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
structs[_index],
structs[_index] ?? {},
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: widget.linkifier?.call(_index + 1),
),

View file

@ -25,11 +25,13 @@ class XmpDirTile extends StatefulWidget {
final ImageEntry entry;
final SplayTreeMap<String, String> tags;
final ValueNotifier<String> expandedNotifier;
final bool initiallyExpanded;
const XmpDirTile({
@required this.entry,
@required this.tags,
@required this.expandedNotifier,
@required this.initiallyExpanded,
});
@override
@ -76,6 +78,7 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
return AvesExpansionTile(
title: 'XMP',
expandedNotifier: widget.expandedNotifier,
initiallyExpanded: widget.initiallyExpanded,
children: [
NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) {

View file

@ -142,7 +142,7 @@ class _HomePageState extends State<HomePage> {
);
case SearchPage.routeName:
return SearchPageRoute(
delegate: ImageSearchDelegate(source: _mediaStore),
delegate: CollectionSearchDelegate(source: _mediaStore),
);
case CollectionPage.routeName:
default:

View file

@ -4,11 +4,11 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart';
class SearchButton extends StatelessWidget {
class CollectionSearchButton extends StatelessWidget {
final CollectionSource source;
final CollectionLens parentCollection;
const SearchButton(this.source, {this.parentCollection});
const CollectionSearchButton(this.source, {this.parentCollection});
@override
Widget build(BuildContext context) {
@ -24,7 +24,7 @@ class SearchButton extends StatelessWidget {
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
delegate: CollectionSearchDelegate(
source: source,
parentCollection: parentCollection,
),

View file

@ -20,14 +20,14 @@ import 'package:aves/widgets/search/search_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class ImageSearchDelegate {
class CollectionSearchDelegate {
final CollectionSource source;
final CollectionLens parentCollection;
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
static const searchHistoryCount = 10;
ImageSearchDelegate({@required this.source, this.parentCollection});
CollectionSearchDelegate({@required this.source, this.parentCollection});
ThemeData appBarTheme(BuildContext context) {
return Theme.of(context);
@ -289,7 +289,7 @@ class SearchPageRoute<T> extends PageRoute<T> {
delegate.route = this;
}
final ImageSearchDelegate delegate;
final CollectionSearchDelegate delegate;
@override
Color get barrierColor => null;

View file

@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
class SearchPage extends StatefulWidget {
static const routeName = '/search';
final ImageSearchDelegate delegate;
final CollectionSearchDelegate delegate;
final Animation<double> animation;
const SearchPage({
@ -115,7 +115,7 @@ class _SearchPageState extends State<SearchPage> {
onSubmitted: (_) => widget.delegate.showResults(context),
decoration: InputDecoration(
border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel,
hintText: 'Search collection',
hintStyle: theme.inputDecorationTheme.hintStyle,
),
),

View file

@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.3.0+36
version: 1.3.1+37
# brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184)

View file

@ -1,5 +1,6 @@
Thanks for using Aves!
v1.3.0:
- added quick scale (aka one finger zoom) gesture to the viewer
- fixed zoom focus with double-tap or pinch-to-zoom gestures
v1.3.1:
- long press and move to select/deselect multiple entries
- metadata search in the Info page
- fixed crash when opening a collection with TIFF files on Android 11
Full changelog available on Github