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] ## [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 ## [v1.3.0] - 2020-12-26
### Added ### Added
- Viewer: quick scale (aka one finger zoom) - Viewer: quick scale (aka one finger zoom)

View file

@ -240,26 +240,29 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
try { try {
val metadataMap = HashMap<String, FieldMap>() val metadataMap = HashMap<String, FieldMap>()
var dirCount: Int? = null var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> if (fd == null) {
val options = TiffBitmapFactory.Options().apply { result.error("getTiffStructure-fd", "failed to get file descriptor", null)
inJustDecodeBounds = true return
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
metadataMap["0"] = tiffOptionsToMap(options)
dirCount = options.outDirectoryCount
} }
if (dirCount != null) { var options = TiffBitmapFactory.Options().apply {
for (i in 1 until dirCount!!) { inJustDecodeBounds = true
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> }
val options = TiffBitmapFactory.Options().apply { TiffBitmapFactory.decodeFileDescriptor(fd, options)
inJustDecodeBounds = true metadataMap["0"] = tiffOptionsToMap(options)
inDirectoryNumber = i val dirCount = options.outDirectoryCount
} for (i in 1 until dirCount) {
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
metadataMap["$i"] = tiffOptionsToMap(options) 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) result.success(metadataMap)
} catch (e: Exception) { } 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.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational 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.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt 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.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean 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.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff 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.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.isPanorama 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 // remove this stat as it is not actual XMP data
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) 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) { } catch (e: Exception) {
@ -348,7 +353,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// identification of spherical video (aka 360° video) // identification of spherical video (aka 360° video)
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any { 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 flags = flags or MASK_IS_360
} }

View file

@ -18,20 +18,22 @@ class TiffRegionFetcher internal constructor(
page: Int = 0, page: Int = 0,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
val resolver = context.contentResolver
try { try {
resolver.openFileDescriptor(uri, "r")?.use { descriptor -> val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
val options = TiffBitmapFactory.Options().apply { if (fd == null) {
inDirectoryNumber = page result.error("getRegion-tiff-fd", "failed to get file descriptor for uri=$uri", null)
inSampleSize = sampleSize return
inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height()) }
} val options = TiffBitmapFactory.Options().apply {
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) inDirectoryNumber = page
if (bitmap != null) { inSampleSize = sampleSize
result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height())
} else { }
result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null) 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) { } catch (e: Exception) {
result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message) 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) { private fun streamTiffImage(uri: Uri, page: Int = 0) {
val resolver = activity.contentResolver val resolver = activity.contentResolver
try { try {
var dirCount = 0 var fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
resolver.openFileDescriptor(uri, "r")?.use { descriptor -> if (fd == null) {
val options = TiffBitmapFactory.Options().apply { error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
inJustDecodeBounds = true return
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
dirCount = options.outDirectoryCount
} }
var options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(fd, options)
val dirCount = options.outDirectoryCount
// TODO TLAD handle multipage TIFF // TODO TLAD handle multipage TIFF
if (dirCount > page) { if (dirCount > page) {
resolver.openFileDescriptor(uri, "r")?.use { descriptor -> fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
val options = TiffBitmapFactory.Options().apply { if (fd == null) {
inJustDecodeBounds = false error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
inDirectoryNumber = page return
} }
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) options = TiffBitmapFactory.Options().apply {
if (bitmap != null) { inJustDecodeBounds = false
success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) inDirectoryNumber = page
} else { }
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null) 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) { } catch (e: Exception) {

View file

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

View file

@ -19,9 +19,6 @@ object Metadata {
// "+51.3328-000.7053+113.474/" (Apple) // "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*") 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 // directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // 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) { private fun fillByTiffDecode(context: Context) {
try { try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return
val options = TiffBitmapFactory.Options().apply { val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
width = options.outWidth
height = options.outHeight
} }
TiffBitmapFactory.decodeFileDescriptor(fd, options)
width = options.outWidth
height = options.outHeight
} catch (e: Exception) { } catch (e: Exception) {
// ignore // ignore
} }

View file

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

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/grid/header_generic.dart'; import 'package:aves/widgets/collection/grid/header_generic.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -143,6 +144,25 @@ class SectionedListLayout {
final top = sectionLayout.indexToLayoutOffset(listIndex); final top = sectionLayout.indexToLayoutOffset(listIndex);
return Rect.fromLTWH(left, top, tileExtent, tileExtent); 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 { class SectionLayout {
@ -184,4 +204,7 @@ class SectionLayout {
scrollOffset -= minOffset + headerExtent; scrollOffset -= minOffset + headerExtent;
return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1); 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); ViewerService.pick(entry.uri);
} }
}, },
onLongPress: () {
if (AvesApp.mode == AppMode.main) {
collection.toggleSelection(entry);
}
},
child: MetaData( child: MetaData(
metaData: ScalerMetadata(entry), metaData: ScalerMetadata(entry),
child: DecoratedThumbnail( 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 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/highlight.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/empty.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.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/list_sliver.dart';
import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget {
spacing: spacing, spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize); )..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context);
// do not replace by Provider.of<CollectionLens> // do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes // so that view updates on collection filter changes
@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget {
), ),
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier, isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context), scrollController: scrollController,
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
); );
@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget {
child: scrollView, child: scrollView,
); );
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier, valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedListLayoutProvider( builder: (context, tileExtent, child) => SectionedListLayoutProvider(
@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget {
tileExtent: tileExtent, tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier, isScrollingNotifier: _isScrollingNotifier,
), ),
child: scaler, child: selector,
), ),
); );
return sectionedListLayoutProvider; 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 { class AvesExpansionTile extends StatelessWidget {
final String title; final String title;
final Color color; final Color color;
final List<Widget> children;
final ValueNotifier<String> expandedNotifier; final ValueNotifier<String> expandedNotifier;
final bool initiallyExpanded;
final List<Widget> children;
const AvesExpansionTile({ const AvesExpansionTile({
@required this.title, @required this.title,
this.color, this.color,
this.expandedNotifier, this.expandedNotifier,
this.initiallyExpanded = false,
@required this.children, @required this.children,
}); });
@ -33,6 +35,9 @@ class AvesExpansionTile extends StatelessWidget {
enabled: enabled, enabled: enabled,
), ),
expandable: enabled, expandable: enabled,
initiallyExpanded: initiallyExpanded,
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -41,8 +46,6 @@ class AvesExpansionTile extends StatelessWidget {
if (enabled) ...children, if (enabled) ...children,
], ],
), ),
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
), ),
); );
} }

View file

@ -168,6 +168,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
child: InkWell( 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, onTapDown: (details) => _tapPosition = details.globalPosition,
onTap: widget.onTap != null onTap: widget.onTap != null
? () { ? () {

View file

@ -43,7 +43,7 @@ class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMi
Offset _startFocalPoint, _lastViewportFocalPosition; Offset _startFocalPoint, _lastViewportFocalPosition;
double _startScale, _quickScaleLastY, _quickScaleLastDistance; double _startScale, _quickScaleLastY, _quickScaleLastDistance;
bool _doubleTap, _quickScaleMoved; bool _doubleTap, _quickScaleMoved;
DateTime _lastScaleGestureDate; DateTime _lastScaleGestureDate = DateTime.now();
AnimationController _scaleAnimationController; AnimationController _scaleAnimationController;
Animation<double> _scaleAnimation; 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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/collection/empty.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/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.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; final ValueNotifier<String> filterNotifier;
static const preferredHeight = kToolbarHeight; static const preferredHeight = kToolbarHeight;
const AlbumFilterBar({@required this.filterNotifier}); const AlbumFilterBar({
@required this.filterNotifier,
});
@override @override
Size get preferredSize => Size.fromHeight(preferredHeight); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final clearButton = IconButton(
icon: Icon(AIcons.clear),
onPressed: () {
_controller.clear();
filterNotifier.value = '';
},
tooltip: 'Clear',
);
return Container( return Container(
height: AlbumFilterBar.preferredHeight, height: AlbumFilterBar.preferredHeight,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Row( child: QueryBar(
crossAxisAlignment: CrossAxisAlignment.start, filterNotifier: filterNotifier,
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(),
),
),
)
],
), ),
); );
} }

View file

@ -85,6 +85,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async { Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
final RenderBox overlay = Overlay.of(context).context.findRenderObject(); final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40); final touchArea = Size(40, 40);
// TODO TLAD show menu within safe area
final selectedAction = await showMenu<ChipAction>( final selectedAction = await showMenu<ChipAction>(
context: context, context: context,
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), 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) { List<Widget> _buildActions(BuildContext context) {
return [ return [
SearchButton(source), CollectionSearchButton(source),
PopupMenuButton<ChipSetAction>( PopupMenuButton<ChipSetAction>(
key: Key('appbar-menu-button'), key: Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
@ -136,7 +137,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( SearchPageRoute(
delegate: ImageSearchDelegate( delegate: CollectionSearchDelegate(
source: source, 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/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/fullscreen/info/basic_section.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/location_section.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart';
@ -38,17 +38,6 @@ class InfoPageState extends State<InfoPage> {
@override @override
Widget build(BuildContext context) { 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( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SafeArea( body: SafeArea(
@ -68,9 +57,9 @@ class InfoPageState extends State<InfoPage> {
entry: entry, entry: entry,
visibleNotifier: widget.visibleNotifier, visibleNotifier: widget.visibleNotifier,
scrollController: _scrollController, scrollController: _scrollController,
appBar: appBar,
split: mqWidth > 400, split: mqWidth > 400,
mqViewInsetsBottom: mqViewInsetsBottom, mqViewInsetsBottom: mqViewInsetsBottom,
goToViewer: _goToViewer,
) )
: SizedBox.shrink(); : SizedBox.shrink();
}, },
@ -97,7 +86,7 @@ class InfoPageState extends State<InfoPage> {
_scrollStartFromTop = false; _scrollStartFromTop = false;
} else if (notification is OverscrollNotification) { } else if (notification is OverscrollNotification) {
if (notification.overscroll < 0) { if (notification.overscroll < 0) {
_goToImage(); _goToViewer();
_scrollStartFromTop = false; _scrollStartFromTop = false;
} }
} }
@ -106,7 +95,7 @@ class InfoPageState extends State<InfoPage> {
return false; return false;
} }
void _goToImage() { void _goToViewer() {
BackUpNotification().dispatch(context); BackUpNotification().dispatch(context);
_scrollController.animateTo( _scrollController.animateTo(
0, 0,
@ -121,9 +110,9 @@ class _InfoPageContent extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final ValueNotifier<bool> visibleNotifier; final ValueNotifier<bool> visibleNotifier;
final ScrollController scrollController; final ScrollController scrollController;
final SliverAppBar appBar;
final bool split; final bool split;
final double mqViewInsetsBottom; final double mqViewInsetsBottom;
final VoidCallback goToViewer;
const _InfoPageContent({ const _InfoPageContent({
Key key, Key key,
@ -131,9 +120,9 @@ class _InfoPageContent extends StatefulWidget {
@required this.entry, @required this.entry,
@required this.visibleNotifier, @required this.visibleNotifier,
@required this.scrollController, @required this.scrollController,
@required this.appBar,
@required this.split, @required this.split,
@required this.mqViewInsetsBottom, @required this.mqViewInsetsBottom,
@required this.goToViewer,
}) : super(key: key); }) : super(key: key);
@override @override
@ -143,6 +132,8 @@ class _InfoPageContent extends StatefulWidget {
class _InfoPageContentState extends State<_InfoPageContent> { class _InfoPageContentState extends State<_InfoPageContent> {
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
@ -178,13 +169,18 @@ class _InfoPageContentState extends State<_InfoPageContent> {
); );
final metadataSliver = MetadataSectionSliver( final metadataSliver = MetadataSectionSliver(
entry: entry, entry: entry,
metadataNotifier: _metadataNotifier,
visibleNotifier: widget.visibleNotifier, visibleNotifier: widget.visibleNotifier,
); );
return CustomScrollView( return CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
slivers: [ slivers: [
widget.appBar, InfoAppBar(
entry: entry,
metadataNotifier: _metadataNotifier,
onBackPressed: widget.goToViewer,
),
SliverPadding( SliverPadding(
padding: horizontalPadding + EdgeInsets.only(top: 8), padding: horizontalPadding + EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver, 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 'dart:collection';
import 'package:aves/model/image_entry.dart'; 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/metadata_service.dart';
import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -21,10 +15,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
class MetadataSectionSliver extends StatefulWidget { class MetadataSectionSliver extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final ValueNotifier<bool> visibleNotifier; final ValueNotifier<bool> visibleNotifier;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
const MetadataSectionSliver({ const MetadataSectionSliver({
@required this.entry, @required this.entry,
@required this.visibleNotifier, @required this.visibleNotifier,
@required this.metadataNotifier,
}); });
@override @override
@ -32,7 +28,6 @@ class MetadataSectionSliver extends StatefulWidget {
} }
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin { class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
Map<String, _MetadataDirectory> _metadata = {};
final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null); final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null);
final ValueNotifier<String> _expandedDirectoryNotifier = 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; bool get isVisible => widget.visibleNotifier.value;
// special directory names ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
// directory names may contain the name of their parent directory // directory names may contain the name of their parent directory
// if so, they are separated by this character // if so, they are separated by this character
@ -53,6 +47,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
void initState() { void initState() {
super.initState(); super.initState();
_registerWidget(widget); _registerWidget(widget);
metadataNotifier.value = {};
_getMetadata(); _getMetadata();
} }
@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
valueListenable: _loadedMetadataUri, valueListenable: _loadedMetadataUri,
builder: (context, uri, child) { builder: (context, uri, child) {
Widget content; Widget content;
if (_metadata.isEmpty) { if (metadata.isEmpty) {
content = SizedBox.shrink(); content = SizedBox.shrink();
} else { } else {
content = Column( content = Column(
@ -111,7 +106,12 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
), ),
children: [ children: [
SectionRow(AIcons.info), 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() { void _onMetadataChanged() {
_loadedMetadataUri.value = null; _loadedMetadataUri.value = null;
_metadata = {}; metadataNotifier.value = {};
_getMetadata(); _getMetadata();
} }
@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
final tagName = tagKV.key as String ?? ''; final tagName = tagKV.key as String ?? '';
return MapEntry(tagName, value); return MapEntry(tagName, value);
}).where((kv) => kv != null))); }).where((kv) => kv != null)));
return _MetadataDirectory(directoryName, parent, tags); return MetadataDirectory(directoryName, parent, tags);
}).toList(); }).toList();
final titledDirectories = directories.map((dir) { final titledDirectories = directories.map((dir) {
@ -222,42 +167,36 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return MapEntry(title, dir); return MapEntry(title, dir);
}).toList() }).toList()
..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); ..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
_metadata = Map.fromEntries(titledDirectories); metadataNotifier.value = Map.fromEntries(titledDirectories);
_loadedMetadataUri.value = entry.uri; _loadedMetadataUri.value = entry.uri;
} else { } else {
_metadata = {}; metadataNotifier.value = {};
_loadedMetadataUri.value = null; _loadedMetadataUri.value = null;
} }
_expandedDirectoryNotifier.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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class _MetadataDirectory { class MetadataDirectory {
final String name; final String name;
final String parent; final String parent;
final SplayTreeMap<String, String> allTags;
final SplayTreeMap<String, String> tags; 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 derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)');
static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); 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 derivedFrom = <String, String>{};
final history = <int, Map<String, String>>{}; final history = <int, Map<String, String>>{};
final ingredients = <int, Map<String, String>>{};
final pantry = <int, Map<String, String>>{};
XmpMMNamespace() : super(ns); XmpMMNamespace() : super(ns);
@ -63,7 +67,9 @@ class XmpMMNamespace extends XmpNamespace {
@override @override
bool extractData(XmpProp prop) { bool extractData(XmpProp prop) {
final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom); 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; return hasStructs || hasIndexedStructs;
} }
@ -79,6 +85,16 @@ class XmpMMNamespace extends XmpNamespace {
title: 'History', title: 'History',
structByIndex: history, structByIndex: history,
), ),
if (ingredients.isNotEmpty)
XmpStructArrayCard(
title: 'Ingredients',
structByIndex: ingredients,
),
if (pantry.isNotEmpty)
XmpStructArrayCard(
title: 'Pantry',
structByIndex: pantry,
),
]; ];
@override @override

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
class SearchPage extends StatefulWidget { class SearchPage extends StatefulWidget {
static const routeName = '/search'; static const routeName = '/search';
final ImageSearchDelegate delegate; final CollectionSearchDelegate delegate;
final Animation<double> animation; final Animation<double> animation;
const SearchPage({ const SearchPage({
@ -115,7 +115,7 @@ class _SearchPageState extends State<SearchPage> {
onSubmitted: (_) => widget.delegate.showResults(context), onSubmitted: (_) => widget.delegate.showResults(context),
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel, hintText: 'Search collection',
hintStyle: theme.inputDecorationTheme.hintStyle, 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 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): # brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184) # - does not support TIFF with JPEG compression (issue #184)

View file

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