Merge branch 'develop'
This commit is contained in:
commit
440705ba25
34 changed files with 888 additions and 291 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -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)
|
||||
|
|
|
@ -240,27 +240,30 @@ 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 {
|
||||
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||
if (fd == null) {
|
||||
result.error("getTiffStructure-fd", "failed to get file descriptor", null)
|
||||
return
|
||||
}
|
||||
var options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
metadataMap["0"] = tiffOptionsToMap(options)
|
||||
dirCount = options.outDirectoryCount
|
||||
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
|
||||
}
|
||||
if (dirCount != null) {
|
||||
for (i in 1 until dirCount!!) {
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
inDirectoryNumber = i
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
metadataMap["$i"] = tiffOptionsToMap(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(metadataMap)
|
||||
} catch (e: Exception) {
|
||||
result.error("getTiffStructure-read", "failed to read tiff", e.message)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -18,21 +18,23 @@ class TiffRegionFetcher internal constructor(
|
|||
page: Int = 0,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val resolver = context.contentResolver
|
||||
try {
|
||||
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
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(descriptor.fd, options)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -139,30 +139,35 @@ 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 {
|
||||
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(descriptor.fd, options)
|
||||
dirCount = options.outDirectoryCount
|
||||
}
|
||||
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 {
|
||||
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(descriptor.fd, options)
|
||||
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) {
|
||||
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
|
||||
}
|
||||
|
|
|
@ -48,12 +48,16 @@ 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 {
|
||||
var options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
val imageWidth = options.outWidth
|
||||
val imageHeight = options.outHeight
|
||||
if (imageHeight > height || imageWidth > width) {
|
||||
|
@ -61,17 +65,18 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va
|
|||
sampleSize *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decode
|
||||
val bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
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
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
}
|
||||
|
||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
if (bitmap == null) {
|
||||
callback.onLoadFailed(Exception("null bitmap"))
|
||||
} else {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -249,14 +249,13 @@ class SourceImageEntry {
|
|||
|
||||
private fun fillByTiffDecode(context: Context) {
|
||||
try {
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
width = options.outWidth
|
||||
height = options.outHeight
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
169
lib/widgets/collection/grid/selector.dart
Normal file
169
lib/widgets/collection/grid/selector.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
79
lib/widgets/common/basic/query_bar.dart
Normal file
79
lib/widgets/common/basic/query_bar.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
? () {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
));
|
||||
|
|
53
lib/widgets/fullscreen/info/info_app_bar.dart
Normal file
53
lib/widgets/fullscreen/info/info_app_bar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
118
lib/widgets/fullscreen/info/info_search.dart
Normal file
118
lib/widgets/fullscreen/info/info_search.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
113
lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart
Normal file
113
lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart
Normal 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']),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue