Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-11-15 00:04:03 +09:00
commit f678587d6f
78 changed files with 2414 additions and 841 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '1.22.3' flutter-version: '1.22.4'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '1.22.3' flutter-version: '1.22.4'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441 # https://issuetracker.google.com/issues/144111441
@ -50,8 +50,8 @@ jobs:
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
flutter build apk --bundle-sksl-path shaders_1.22.3.sksl.json flutter build apk --bundle-sksl-path shaders_1.22.4.sksl.json
flutter build appbundle --bundle-sksl-path shaders_1.22.3.sksl.json flutter build appbundle --bundle-sksl-path shaders_1.22.4.sksl.json
rm $AVES_STORE_FILE rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks AVES_STORE_FILE: ${{ github.workspace }}/key.jks

41
CHANGELOG.md Normal file
View file

@ -0,0 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.2.6] - 2020-11-15
### Added
- Support for TIFF images (single page)
- Viewer overlay: minimap (optional)
### Changed
- Upgraded Flutter to stable v1.22.4
- Viewer: use subsampling and tiling to display large images
### Fixed
- Fixed finding dimensions of entries with incorrect EXIF
## [v1.2.5] - 2020-11-01
### Added
- Search: show recently used filters (optional)
- Search: show filter for entries with no XMP tags
- Search: show filter for entries with no location information
- Analytics: use Firebase Analytics (along Firebase Crashlytics)
### Changed
- Upgraded Flutter to stable v1.22.3
- Viewer overlay: showing shooting details is now optional
### Fixed
- Viewer: leave when the loaded entry is deleted and it is the last one
- Viewer: refresh the viewer overlay and info page when the loaded image is modified
- Info: prevent reporting a "Media" section for images other than HEIC/HEIF
- Fixed opening entries shared via a "file" media content URI
### Removed
- Dependencies: removed Guava as a direct dependency in Android
## [v1.2.4] - 2020-11-01 [YANKED]
## [v1.2.3] - 2020-10-22
...

View file

@ -88,9 +88,8 @@ flutter {
} }
repositories { repositories {
maven { maven { url 'https://jitpack.io' }
url "https://s3.amazonaws.com/repo.commonsware.com" maven { url "https://s3.amazonaws.com/repo.commonsware.com" }
}
} }
dependencies { dependencies {
@ -99,6 +98,11 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.3.1' implementation 'androidx.exifinterface:exifinterface:1.3.1'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0' implementation 'com.drewnoakes:metadata-extractor:2.15.0'
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
// as of 20201113, its `q_support` branch allows decoding TIFF without a `File`, but is not released
// we forked it to bypass official releases, upgrading its Android/Gradle structure to make it compatible with JitPack
// JitPack build result is available at https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/<commit>/build.log
implementation 'com.github.deckerst:Android-TiffBitmapFactory:7efb450636'
implementation 'com.github.bumptech.glide:glide:4.11.0' implementation 'com.github.bumptech.glide:glide:4.11.0'
kapt 'androidx.annotation:annotation:1.1.0' kapt 'androidx.annotation:annotation:1.1.0'

View file

@ -45,7 +45,6 @@
</queries> </queries>
<application <application
android:name="io.flutter.app.FlutterApplication"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
@ -55,11 +54,16 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/AppTheme" android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame" android:name="io.flutter.embedding.android.NormalTheme"
android:value="false" /> android:resource="@style/NormalTheme"
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -105,11 +109,11 @@
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="${googleApiKey}" /> android:value="${googleApiKey}" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="firebase_crashlytics_collection_enabled" android:name="firebase_crashlytics_collection_enabled"
android:value="false" /> android:value="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />

View file

@ -6,20 +6,19 @@ import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils.createTag import deckers.thibault.aves.utils.LogUtils.createTag
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
@ -134,12 +133,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.submit(size, size) .submit(size, size)
try { try {
val bitmap = target.get() data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
if (bitmap != null) {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
data = stream.toByteArray()
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e) Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
} }

View file

@ -1,7 +1,9 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity import android.app.Activity
import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Size
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.FieldMap
@ -19,11 +21,14 @@ import kotlin.math.roundToInt
class ImageFileHandler(private val activity: Activity) : MethodCallHandler { class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
private val density = activity.resources.displayMetrics.density private val density = activity.resources.displayMetrics.density
private val regionFetcher = RegionFetcher(activity)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) } "getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) }
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) } "getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) }
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) } "getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) }
"getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) }
"clearSizedThumbnailDiskCache" -> { "clearSizedThumbnailDiskCache" -> {
GlobalScope.launch { Glide.get(activity).clearDiskCache() } GlobalScope.launch { Glide.get(activity).clearDiskCache() }
result.success(null) result.success(null)
@ -53,26 +58,51 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
val widthDip = call.argument<Double>("widthDip") val widthDip = call.argument<Double>("widthDip")
val heightDip = call.argument<Double>("heightDip") val heightDip = call.argument<Double>("heightDip")
val defaultSizeDip = call.argument<Double>("defaultSizeDip") val defaultSizeDip = call.argument<Double>("defaultSizeDip")
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) { if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null) result.error("getThumbnail-args", "failed because of missing arguments", null)
return return
} }
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter // convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
GlobalScope.launch { ThumbnailFetcher(
ThumbnailFetcher( activity,
activity, uri,
uri, mimeType,
mimeType, dateModifiedSecs,
dateModifiedSecs, rotationDegrees,
rotationDegrees, isFlipped,
isFlipped, width = (widthDip * density).roundToInt(),
width = (widthDip * density).roundToInt(), height = (heightDip * density).roundToInt(),
height = (heightDip * density).roundToInt(), defaultSize = (defaultSizeDip * density).roundToInt(),
defaultSize = (defaultSizeDip * density).roundToInt(), result,
Coresult(result), ).fetch()
).fetch() }
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
val sampleSize = call.argument<Int>("sampleSize")
val x = call.argument<Int>("regionX")
val y = call.argument<Int>("regionY")
val width = call.argument<Int>("regionWidth")
val height = call.argument<Int>("regionHeight")
val imageWidth = call.argument<Int>("imageWidth")
val imageHeight = call.argument<Int>("imageHeight")
if (uri == null || mimeType == null || sampleSize == null || x == null || y == null || width == null || height == null || imageWidth == null || imageHeight == null) {
result.error("getRegion-args", "failed because of missing arguments", null)
return
} }
regionFetcher.fetch(
uri,
mimeType,
sampleSize,
Rect(x, y, x + width, y + height),
Size(imageWidth, imageHeight),
result,
)
} }
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) { private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {

View file

@ -4,7 +4,7 @@ import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.graphics.Bitmap import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -28,7 +28,9 @@ import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper
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.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper 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
@ -38,26 +40,27 @@ 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
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt 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.XMP import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream import java.io.IOException
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -70,6 +73,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
"getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) }
@ -182,12 +187,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val path = call.argument<String>("path")
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null) result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return return
} }
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType)) val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path))
if (isVideo(mimeType)) { if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)) metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
} }
@ -196,7 +202,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap) result.success(metadataMap)
} }
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String): Map<String, Any> { private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, path: String?): Map<String, Any> {
val metadataMap = HashMap<String, Any>() val metadataMap = HashMap<String, Any>()
var foundExif = false var foundExif = false
@ -209,14 +215,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// File type // File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`) // * `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`)
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`) // * the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// `context.getContentResolver().getType()` sometimes return incorrect value // * `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` // * `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// file extension is unreliable // * file extension is unreliable
// in the end, `metadata-extractor` is the most reliable, unless it reports `tiff` // In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { // in which case we trust the file extension
if (it != MimeTypes.TIFF) { if (path?.matches(tiffExtensionPattern) == true) {
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
} else {
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
metadataMap[KEY_MIME_TYPE] = it metadataMap[KEY_MIME_TYPE] = it
} }
} }
@ -351,37 +360,62 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
val metadataMap = HashMap<String, Any>() val metadataMap = HashMap<String, Any>()
if (isVideo(mimeType) || !isSupportedByMetadataExtractor(mimeType)) { if (isVideo(mimeType)) {
result.success(metadataMap) result.success(metadataMap)
return return
} }
try {
StorageUtils.openInputStream(context, uri)?.use { input -> val saveExposureTime: (value: Rational) -> Unit = {
val metadata = ImageMetadataReader.readMetadata(input) // `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { // so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it } // and process it to make sure the numerator is `1` when the ratio value is less than 1
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it } val num = it.numerator
dir.getSafeDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = "ISO$it" } val denom = it.denominator
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) { metadataMap[KEY_EXPOSURE_TIME] = when {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal num >= denom -> "${it.toSimpleString(true)}"
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000) num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
// and process it to make sure the numerator is `1` when the ratio value is less than 1 else -> it.toString()
val num = it.numerator }
val denom = it.denominator }
metadataMap[KEY_EXPOSURE_TIME] = when {
num >= denom -> "${it.toSimpleString(true)}" var foundExif = false
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString() if (isSupportedByMetadataExtractor(mimeType)) {
else -> it.toString() try {
} StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
foundExif = true
dir.getSafeRational(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it.numerator.toDouble() / it.denominator }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME, saveExposureTime)
dir.getSafeRational(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it.numerator.toDouble() / it.denominator }
dir.getSafeInt(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = it }
} }
} }
result.success(metadataMap) } catch (e: Exception) {
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null) Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: Exception) { } catch (e: NoClassDefFoundError) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message) Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) { }
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} }
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
exif.getSafeDouble(ExifInterface.TAG_F_NUMBER) { metadataMap[KEY_APERTURE] = it }
exif.getSafeRational(ExifInterface.TAG_EXPOSURE_TIME, saveExposureTime)
exif.getSafeDouble(ExifInterface.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
exif.getSafeInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) { metadataMap[KEY_ISO] = it }
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
result.success(metadataMap)
} }
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
@ -483,6 +517,77 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap) result.success(metadataMap)
} }
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getBitmapDecoderInfo-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options)
options.outMimeType?.let { metadataMap["MimeType"] = it }
options.outWidth.takeIf { it >= 0 }?.let { metadataMap["Width"] = it.toString() }
options.outHeight.takeIf { it >= 0 }?.let { metadataMap["Height"] = it.toString() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
options.outColorSpace?.let { metadataMap["ColorSpace"] = it.toString() }
options.outConfig?.let { metadataMap["Config"] = it.toString() }
}
}
} catch (e: IOException) {
// ignore
}
result.success(metadataMap)
}
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getMetadataExtractorSummary-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
try {
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
metadataMap["mimeType"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
} else ""
}
metadataMap["typeName"] = metadata.getDirectoriesOfType(FileTypeDirectory::class.java).joinToString { dir ->
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)) {
dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_TYPE_NAME)
} else ""
}
for (dir in metadata.directories) {
val dirName = dir.name ?: ""
var index = 0
while (metadataMap.containsKey("$dirName ($index)")) index++
var value = "${dir.tagCount} tags"
dir.parent?.let { value += ", parent: ${it.name}" }
metadataMap["$dirName ($index)"] = value
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
if (metadataMap.isNotEmpty()) {
result.success(metadataMap)
} else {
result.error("getMetadataExtractorSummary-failure", "failed to get metadata for uri=$uri", null)
}
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
@ -517,14 +622,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
StorageUtils.openInputStream(context, uri)?.use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let { exif.thumbnailBitmap?.let { bitmap ->
val bitmap = TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), it, orientation) TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
if (bitmap != null) { thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
thumbnails.add(stream.toByteArray())
} }
} }
} }

View file

@ -0,0 +1,82 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.util.Size
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodChannel
import kotlin.math.roundToInt
class RegionFetcher internal constructor(
private val context: Context,
) {
private var lastDecoderRef: LastDecoderRef? = null
fun fetch(
uri: Uri,
mimeType: String,
sampleSize: Int,
regionRect: Rect,
imageSize: Size,
result: MethodChannel.Result,
) {
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize
}
var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
currentDecoderRef.decoder.recycle()
currentDecoderRef = null
}
try {
if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
BitmapRegionDecoder.newInstance(input, false)
}
if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
return
}
currentDecoderRef = LastDecoderRef(uri, newDecoder)
}
val decoder = currentDecoderRef.decoder
lastDecoderRef = currentDecoderRef
// with raw images, the known image size may not match the decoded image size
// so we scale the requested region accordingly
val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) {
val xf = decoder.width.toDouble() / imageSize.width
val yf = decoder.height.toDouble() / imageSize.height
Rect(
(regionRect.left * xf).roundToInt(),
(regionRect.top * yf).roundToInt(),
(regionRect.right * xf).roundToInt(),
(regionRect.bottom * yf).roundToInt(),
)
} else {
regionRect
}
val bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true))
} else {
result.error("getRegion-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
}
} catch (e: Exception) {
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
}
}
}
private data class LastDecoderRef(
val uri: Uri,
val decoder: BitmapRegionDecoder,
)

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.ContentUris import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -15,14 +15,16 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream import org.beyka.tiffbitmapfactory.TiffBitmapFactory
class ThumbnailFetcher internal constructor( class ThumbnailFetcher internal constructor(
private val activity: Activity, private val context: Context,
uri: String, uri: String,
private val mimeType: String, private val mimeType: String,
private val dateModifiedSecs: Long, private val dateModifiedSecs: Long,
@ -39,59 +41,57 @@ class ThumbnailFetcher internal constructor(
fun fetch() { fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var recycle = true
var exception: Exception? = null var exception: Exception? = null
// fetch low quality thumbnails when size is not specified try {
if ((width == defaultSize || height == defaultSize) && !isFlipped) { if (mimeType == MimeTypes.TIFF) {
// as of Android R, the Media Store content resolver may return a thumbnail bitmap = getTiff()
// that is automatically rotated according to EXIF orientation, } else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
// but not flipped when necessary // Fetch low quality thumbnails when size is not specified.
// so we skip this step for flipped entries // As of Android R, the Media Store content resolver may return a thumbnail
try { // that is automatically rotated according to EXIF orientation, but not flipped,
// so we skip this step for flipped entries.
bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore() bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore()
} catch (e: Exception) {
exception = e
} }
} catch (e: Exception) {
exception = e
} }
// fallback if the native methods failed or for higher quality thumbnails // fallback if the native methods failed or for higher quality thumbnails
if (bitmap == null) { if (bitmap == null) {
try { try {
bitmap = getByGlide() bitmap = getByGlide()
recycle = false
} catch (e: Exception) { } catch (e: Exception) {
exception = e exception = e
} }
} }
if (bitmap != null) { if (bitmap != null) {
val stream = ByteArrayOutputStream() result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle))
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes } else {
// Bitmap.CompressFormat.PNG is slower than JPEG var errorDetails: String? = exception?.message
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) if (errorDetails?.isNotEmpty() == true) {
result.success(stream.toByteArray()) errorDetails = errorDetails.split("\n".toRegex(), 2).first()
return }
result.error("getThumbnail-null", "failed to get thumbnail for uri=$uri", errorDetails)
} }
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
result.error("getThumbnail-null", "failed to get thumbnail for uri=$uri", errorDetails)
} }
@RequiresApi(api = Build.VERSION_CODES.Q) @RequiresApi(api = Build.VERSION_CODES.Q)
private fun getByResolver(): Bitmap? { private fun getByResolver(): Bitmap? {
val resolver = activity.contentResolver val resolver = context.contentResolver
var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null) var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null)
if (needRotationAfterContentResolverThumbnail(mimeType)) { if (needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
return bitmap return bitmap
} }
private fun getByMediaStore(): Bitmap? { private fun getByMediaStore(): Bitmap? {
val contentId = ContentUris.parseId(uri) val contentId = ContentUris.parseId(uri)
val resolver = activity.contentResolver val resolver = context.contentResolver
return if (isVideo(mimeType)) { return if (isVideo(mimeType)) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null) MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
@ -100,7 +100,7 @@ class ThumbnailFetcher internal constructor(
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null) var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
// from Android Q, returned thumbnail is already rotated according to EXIF orientation // from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
bitmap bitmap
} }
@ -115,13 +115,13 @@ class ThumbnailFetcher internal constructor(
val target = if (isVideo(mimeType)) { val target = if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE) options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(activity) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(VideoThumbnail(activity, uri)) .load(VideoThumbnail(context, uri))
.submit(width, height) .submit(width, height)
} else { } else {
Glide.with(activity) Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(uri) .load(uri)
@ -131,11 +131,38 @@ class ThumbnailFetcher internal constructor(
return try { return try {
var bitmap = target.get() var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
bitmap bitmap
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(context).clear(target)
}
}
private fun getTiff(): Bitmap? {
// determine sample size
var sampleSize = 1
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) {
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
sampleSize *= 2
}
}
}
// decode
return context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sampleSize
}
return TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
} }
} }
} }

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.app.Activity import android.app.Activity
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@ -11,16 +10,17 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.MimeTypes.canHaveAlpha import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils.openInputStream import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@ -72,6 +72,8 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
if (isVideo(mimeType)) { if (isVideo(mimeType)) {
streamVideoByGlide(uri) streamVideoByGlide(uri)
} else if (mimeType == MimeTypes.TIFF) {
streamTiffImage(uri)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) { } else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter // decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped) streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamImageAsIs(uri: Uri) { private fun streamImageAsIs(uri: Uri) {
try { try {
openInputStream(activity, uri).use { input -> input?.let { streamBytes(it) } } StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
} catch (e: IOException) { } catch (e: IOException) {
error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message) error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message)
} }
@ -102,24 +104,12 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
} }
if (bitmap != null) { if (bitmap != null) {
val stream = ByteArrayOutputStream() success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
if (canHaveAlpha(mimeType)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
}
success(stream.toByteArray())
} else { } else {
error("streamImage-image-decode-null", "failed to get image from uri=$uri", null) error("streamImage-image-decode-null", "failed to get image from uri=$uri", null)
} }
} catch (e: Exception) { } catch (e: Exception) {
var errorDetails = e.message error("streamImage-image-decode-exception", "failed to get image from uri=$uri", toErrorDetails(e))
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", errorDetails)
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(activity).clear(target)
} }
@ -134,11 +124,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
try { try {
val bitmap = target.get() val bitmap = target.get()
if (bitmap != null) { if (bitmap != null) {
val stream = ByteArrayOutputStream() success(bitmap.getBytes(canHaveAlpha = false, recycle = false))
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
success(stream.toByteArray())
} else { } else {
error("streamImage-video-null", "failed to get image from uri=$uri", null) error("streamImage-video-null", "failed to get image from uri=$uri", null)
} }
@ -149,6 +135,48 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} }
} }
private fun streamTiffImage(uri: Uri) {
val resolver = activity.contentResolver
try {
var dirCount = 0
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = true
}
TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
dirCount = options.outDirectoryCount
}
// TODO TLAD handle multipage TIFF
if (dirCount > 0) {
val i = 0
resolver.openFileDescriptor(uri, "r")?.use { descriptor ->
val options = TiffBitmapFactory.Options().apply {
inJustDecodeBounds = false
inDirectoryNumber = i
}
val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options)
if (bitmap != null) {
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
} else {
error("streamImage-tiff-null", "failed to get tiff image (dir=$i) from uri=$uri", null)
}
}
}
} catch (e: Exception) {
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
}
}
private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) {
errorDetails.split("\n".toRegex(), 2).first()
} else {
errorDetails
}
}
private fun streamBytes(inputStream: InputStream) { private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
var len: Int var len: Int

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.decoder package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
@ -16,9 +15,9 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
@GlideModule @GlideModule
@ -49,16 +48,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
val retriever = openMetadataRetriever(model.context, model.uri) val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) { if (retriever != null) {
try { try {
var picture = retriever.embeddedPicture val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false)
if (picture == null) { if (picture != null) {
// not ideal: bitmap -> byte[] -> bitmap callback.onDataReady(ByteArrayInputStream(picture))
// but simple fallback and we cache result } else {
val stream = ByteArrayOutputStream() callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
val bitmap = retriever.frameAtTime
bitmap?.compress(Bitmap.CompressFormat.PNG, 0, stream)
picture = stream.toByteArray()
} }
callback.onDataReady(ByteArrayInputStream(picture))
} catch (e: Exception) { } catch (e: Exception) {
callback.onLoadFailed(e) callback.onLoadFailed(e)
} finally { } finally {

View file

@ -10,12 +10,15 @@ import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirector
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import java.util.* import java.util.*
import kotlin.math.abs
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToLong import kotlin.math.roundToLong
object ExifInterfaceHelper { object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java) private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
private const val precisionErrorTolerance = 1e-10
// ExifInterface always states it has the following attributes // ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing // and returns "0" instead of "null" when they are actually missing
private val neverNullTags = listOf( private val neverNullTags = listOf(
@ -279,7 +282,7 @@ object ExifInterfaceHelper {
private fun toRational(s: String?): Rational? { private fun toRational(s: String?): Rational? {
s ?: return null s ?: return null
// convert "12345/100" // e.g. "12345/100" to Rational(12345, 100)
val parts = s.split("/") val parts = s.split("/")
if (parts.size == 2) { if (parts.size == 2) {
val numerator = parts[0].toLongOrNull() ?: return null val numerator = parts[0].toLongOrNull() ?: return null
@ -287,9 +290,20 @@ object ExifInterfaceHelper {
return Rational(numerator, denominator) return Rational(numerator, denominator)
} }
// convert "123.45"
var d = s.toDoubleOrNull() ?: return null var d = s.toDoubleOrNull() ?: return null
if (d == 0.0) return Rational(0, 1) if (d == 0.0) return Rational(0, 1)
// e.g. "0.02564102564102564" to Rational(1, 39)
if (d < 1) {
val numerator = 1L
val f = numerator / d
val denominator = f.roundToLong()
if (abs(f - denominator) < precisionErrorTolerance) {
return Rational(numerator, denominator)
}
}
// e.g. "123.45" to Rational(12345, 100)
var denominator: Long = 1 var denominator: Long = 1
while (d != floor(d)) { while (d != floor(d)) {
denominator *= 10 denominator *= 10
@ -321,6 +335,24 @@ object ExifInterfaceHelper {
} }
} }
fun ExifInterface.getSafeDouble(tag: String, save: (value: Double) -> Unit) {
if (this.hasAttribute(tag)) {
val value = this.getAttributeDouble(tag, Double.NaN)
if (!value.isNaN()) {
save(value)
}
}
}
fun ExifInterface.getSafeRational(tag: String, save: (value: Rational) -> Unit) {
if (this.hasAttribute(tag)) {
val value = toRational(this.getAttribute(tag))
if (value != null) {
save(value)
}
}
}
fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) { fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) { if (this.hasAttribute(tag)) {
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long // TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long

View file

@ -123,7 +123,8 @@ class SourceImageEntry {
fillVideoByMediaMetadataRetriever(context) fillVideoByMediaMetadataRetriever(context)
if (isSized && hasDuration) return this if (isSized && hasDuration) return this
} }
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { // skip metadata-extractor for raw images because it reports the decoded dimensions instead of the raw dimensions
if (!MimeTypes.isRaw(sourceMimeType) && MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
fillByMetadataExtractor(context) fillByMetadataExtractor(context)
if (isSized && foundExif) return this if (isSized && foundExif) return this
} }
@ -176,7 +177,6 @@ class SourceImageEntry {
dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it } dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it }
} }
} else { } else {
// EXIF, if defined, should override metadata found in other directories
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
foundExif = true foundExif = true
dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it } dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it }
@ -185,15 +185,15 @@ class SourceImageEntry {
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it } dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it }
} }
if (!foundExif) { // dimensions reported in EXIF do not always match the image
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) { // so we fetch them from the format directory if available
dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it } for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it } dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it }
} dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it }
for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) { }
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it } for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it } dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it }
} dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it }
} }
} }
} }
@ -225,8 +225,9 @@ class SourceImageEntry {
private fun fillByBitmapDecode(context: Context) { private fun fillByBitmapDecode(context: Context) {
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options() val options = BitmapFactory.Options().apply {
options.inJustDecodeBounds = true inJustDecodeBounds = true
}
BitmapFactory.decodeStream(input, null, options) BitmapFactory.decodeStream(input, null, options)
width = options.outWidth width = options.outWidth
height = options.outHeight height = options.outHeight

View file

@ -144,11 +144,14 @@ class MediaStoreImageProvider : ImageProvider() {
"contentId" to contentId, "contentId" to contentId,
) )
if ((width <= 0 || height <= 0) && needSize(mimeType) if (MimeTypes.isRaw(mimeType)
|| (width <= 0 || height <= 0) && needSize(mimeType)
|| durationMillis == 0L && needDuration || durationMillis == 0L && needDuration
) { ) {
// some images are incorrectly registered in the Media Store, // Some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation // missing some attributes such as width, height, orientation.
// Also, the reported size of raw images is inconsistent across devices
// and Android versions (sometimes the raw size, sometimes the decoded size).
val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context) val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context)
entryMap = entry.toMap() entryMap = entry.toMap()
} }

View file

@ -5,8 +5,22 @@ import android.graphics.Bitmap
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode import deckers.thibault.aves.metadata.Metadata.getExifCode
import java.io.ByteArrayOutputStream
object BitmapUtils { object BitmapUtils {
fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean = true): ByteArray {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Flutter cannot decode the raw bytes
// `Bitmap.CompressFormat.PNG` is slower than `JPEG`, but it allows transparency
if (canHaveAlpha) {
this.compress(Bitmap.CompressFormat.PNG, quality, stream)
} else {
this.compress(Bitmap.CompressFormat.JPEG, quality, stream)
}
if (recycle) this.recycle()
return stream.toByteArray()
}
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? { fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap if (rotationDegrees == 0 && !isFlipped) return bitmap

View file

@ -16,7 +16,16 @@ object MimeTypes {
const val WEBP = "image/webp" const val WEBP = "image/webp"
// raw raster // raw raster
private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2"
private const val DNG = "image/x-adobe-dng" private const val DNG = "image/x-adobe-dng"
private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf"
private const val PEF = "image/x-pentax-pef"
private const val RAF = "image/x-fuji-raf"
private const val RW2 = "image/x-panasonic-rw2"
private const val SRW = "image/x-samsung-srw"
// vector // vector
const val SVG = "image/svg+xml" const val SVG = "image/svg+xml"
@ -35,6 +44,13 @@ object MimeTypes {
else -> isVideo(mimeType) else -> isVideo(mimeType)
} }
fun isRaw(mimeType: String?): Boolean {
return when (mimeType) {
ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true
else -> false
}
}
// returns whether the specified MIME type represents // returns whether the specified MIME type represents
// a raster image format that allows an alpha channel // a raster image format that allows an alpha channel
fun canHaveAlpha(mimeType: String?) = when (mimeType) { fun canHaveAlpha(mimeType: String?) = when (mimeType) {
@ -42,7 +58,7 @@ object MimeTypes {
else -> false else -> false
} }
// as of Flutter v1.22.0 // as of Flutter v1.22.0, with additional custom handling for SVG
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
@ -71,4 +87,8 @@ object MimeTypes {
DNG, PNG -> true DNG, PNG -> true
else -> false else -> false
} }
// extensions
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
} }

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags --> <item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen --> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
</style> </style>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags --> <item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
</style> </style>
</resources> </resources>

View file

@ -43,7 +43,7 @@ class LocationFilter extends CollectionFilter {
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
final flag = countryCodeToFlag(_countryCode); final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.0-12.1.pre emoji shadows are rendered as colorful duplicates, // as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them // not filled with the shadow color as expected, so we remove them
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: [])); if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size); return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart'; 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/mime_types.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -34,7 +35,7 @@ class MimeFilter extends CollectionFilter {
_label ??= lowMime.split('/')[0].toUpperCase(); _label ??= lowMime.split('/')[0].toUpperCase();
} else { } else {
_filter = (entry) => entry.mimeType == lowMime; _filter = (entry) => entry.mimeType == lowMime;
_label = displayType(lowMime); _label = MimeTypes.displayType(lowMime);
} }
_icon ??= AIcons.vector; _icon ??= AIcons.vector;
} }
@ -50,18 +51,6 @@ class MimeFilter extends CollectionFilter {
'mime': mime, 'mime': mime,
}; };
static String displayType(String mime) {
final patterns = [
RegExp('.*/'), // remove type, keep subtype
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
'+XML', // noisy suffix
RegExp('ADOBE\\\.'), // for PSD
];
mime = mime.toUpperCase();
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
return mime;
}
@override @override
bool filter(ImageEntry entry) => _filter(entry); bool filter(ImageEntry entry) => _filter(entry);

View file

@ -169,6 +169,27 @@ class ImageEntry {
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels) // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw; bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
// Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported"
// but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below,
// and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested.
bool get canTile =>
[
MimeTypes.heic,
MimeTypes.heif,
MimeTypes.jpeg,
MimeTypes.webp,
MimeTypes.arw,
MimeTypes.cr2,
MimeTypes.nef,
MimeTypes.nrw,
MimeTypes.orf,
MimeTypes.pef,
MimeTypes.raf,
MimeTypes.rw2,
MimeTypes.srw,
].contains(mimeType) &&
!isAnimated;
bool get isRaw => MimeTypes.rawImages.contains(mimeType); bool get isRaw => MimeTypes.rawImages.contains(mimeType);
bool get isVideo => mimeType.startsWith('video'); bool get isVideo => mimeType.startsWith('video');

View file

@ -1,5 +1,6 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:geocoder/model.dart'; import 'package:geocoder/model.dart';
import 'package:intl/intl.dart';
class DateMetadata { class DateMetadata {
final int contentId, dateMillis; final int contentId, dateMillis;
@ -109,23 +110,29 @@ class CatalogMetadata {
class OverlayMetadata { class OverlayMetadata {
final String aperture, exposureTime, focalLength, iso; final String aperture, exposureTime, focalLength, iso;
static final apertureFormat = NumberFormat('0.0', 'en_US');
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
OverlayMetadata({ OverlayMetadata({
String aperture, double aperture,
this.exposureTime, String exposureTime,
this.focalLength, double focalLength,
this.iso, int iso,
}) : aperture = aperture.replaceFirst('f', 'ƒ'); }) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
exposureTime = exposureTime,
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
iso = iso != null ? 'ISO$iso' : null;
factory OverlayMetadata.fromMap(Map map) { factory OverlayMetadata.fromMap(Map map) {
return OverlayMetadata( return OverlayMetadata(
aperture: map['aperture'] ?? '', aperture: map['aperture'] as double,
exposureTime: map['exposureTime'] ?? '', exposureTime: map['exposureTime'] as String,
focalLength: map['focalLength'] ?? '', focalLength: map['focalLength'] as double,
iso: map['iso'] ?? '', iso: map['iso'] as int,
); );
} }
bool get isEmpty => aperture.isEmpty && exposureTime.isEmpty && focalLength.isEmpty && iso.isEmpty; bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
@override @override
String toString() { String toString() {

View file

@ -41,5 +41,17 @@ class MimeTypes {
// groups // groups
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
static const List<String> undecodable = [crw, psd, tiff]; // TODO TLAD make it dynamic if it depends on OS/lib versions static const List<String> undecodable = [crw, psd]; // TODO TLAD make it dynamic if it depends on OS/lib versions
static String displayType(String mime) {
final patterns = [
RegExp('.*/'), // remove type, keep subtype
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
'+XML', // noisy suffix
RegExp('ADOBE\\\.'), // for PSD
];
mime = mime.toUpperCase();
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));
return mime;
}
} }

View file

@ -45,6 +45,7 @@ class Settings extends ChangeNotifier {
static const pinnedFiltersKey = 'pinned_filters'; static const pinnedFiltersKey = 'pinned_filters';
// viewer // viewer
static const showOverlayMinimapKey = 'show_overlay_minimap';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
// info // info
@ -159,6 +160,10 @@ class Settings extends ChangeNotifier {
// viewer // viewer
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true); bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true);
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
@ -113,6 +114,43 @@ class ImageFileService {
return Future.sync(() => null); return Future.sync(() => null);
} }
// `rect`: region to decode, with coordinates in reference to `imageSize`
static Future<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped,
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
Object taskKey,
int priority,
}) {
return servicePolicy.call(
() async {
try {
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'sampleSize': sampleSize,
'regionX': regionRect.left,
'regionY': regionRect.top,
'regionWidth': regionRect.width,
'regionHeight': regionRect.height,
'imageWidth': imageSize.width.toInt(),
'imageHeight': imageSize.height.toInt(),
});
return result as Uint8List;
} on PlatformException catch (e) {
debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
},
priority: priority ?? ServiceCallPriority.getRegion,
key: taskKey,
);
}
static Future<Uint8List> getThumbnail( static Future<Uint8List> getThumbnail(
String uri, String uri,
String mimeType, String mimeType,
@ -160,9 +198,11 @@ class ImageFileService {
} }
} }
static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]);
static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]);
static Future<T> resumeThumbnail<T>(Object taskKey) => servicePolicy.resume<T>(taskKey); static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) { static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) {
try { try {

View file

@ -43,6 +43,7 @@ class MetadataService {
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
'path': entry.path,
}) as Map; }) as Map;
result['contentId'] = entry.contentId; result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result); return CatalogMetadata.fromMap(result);
@ -64,7 +65,7 @@ class MetadataService {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
// return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso' // return map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int)
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'uri': entry.uri, 'uri': entry.uri,
@ -76,6 +77,19 @@ class MetadataService {
return null; return null;
} }
static Future<Map> getBitmapFactoryInfo(ImageEntry entry) async {
try {
// return map with all data available when decoding image bounds with `BitmapFactory`
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getContentResolverMetadata(ImageEntry entry) async { static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
try { try {
// return map with all data available from the content resolver // return map with all data available from the content resolver
@ -92,7 +106,7 @@ class MetadataService {
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async { static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
try { try {
// return map with all data available from the ExifInterface library // return map with all data available from the `ExifInterface` library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; }) as Map;
@ -105,7 +119,7 @@ class MetadataService {
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async { static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
try { try {
// return map with all data available from the MediaMetadataRetriever // return map with all data available from `MediaMetadataRetriever`
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{ final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri, 'uri': entry.uri,
}) as Map; }) as Map;
@ -116,6 +130,19 @@ class MetadataService {
return {}; return {};
} }
static Future<Map> getMetadataExtractorSummary(ImageEntry entry) async {
try {
// return map with the mime type and tag count for each directory found by `metadata-extractor`
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async { static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{

View file

@ -7,10 +7,13 @@ import 'package:tuple/tuple.dart';
final ServicePolicy servicePolicy = ServicePolicy._private(); final ServicePolicy servicePolicy = ServicePolicy._private();
class ServicePolicy { class ServicePolicy {
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
final Map<Object, Tuple2<int, _Task>> _paused = {}; final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap(); final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
_Task _running; _Task _running;
Stream<QueueState> get queueStream => _queueStreamController.stream;
ServicePolicy._private(); ServicePolicy._private();
Future<T> call<T>( Future<T> call<T>(
@ -60,6 +63,7 @@ class ServicePolicy {
Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>()); Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>());
void _pickNext() { void _pickNext() {
_notifyQueueState();
if (_running != null) return; if (_running != null) return;
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value; final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
_running = queue?.removeFirst(); _running = queue?.removeFirst();
@ -90,6 +94,13 @@ class ServicePolicy {
} }
bool isPaused(Object key) => _paused.containsKey(key); bool isPaused(Object key) => _paused.containsKey(key);
void _notifyQueueState() {
if (!_queueStreamController.hasListener) return;
final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length)));
_queueStreamController.add(QueueState(queueByPriority));
}
} }
class _Task { class _Task {
@ -104,8 +115,15 @@ class CancelledException {}
class ServiceCallPriority { class ServiceCallPriority {
static const int getFastThumbnail = 100; static const int getFastThumbnail = 100;
static const int getRegion = 150;
static const int getSizedThumbnail = 200; static const int getSizedThumbnail = 200;
static const int normal = 500; static const int normal = 500;
static const int getMetadata = 1000; static const int getMetadata = 1000;
static const int getLocation = 1000; static const int getLocation = 1000;
} }
class QueueState {
final Map<int, int> queueByPriority;
const QueueState(this.queueByPriority);
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/painting.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class Constants { class Constants {
// as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
// so we give it a `strutStyle` with a slightly larger height // so we give it a `strutStyle` with a slightly larger height
static const overflowStrutStyle = StrutStyle(height: 1.3); static const overflowStrutStyle = StrutStyle(height: 1.3);
@ -18,13 +18,20 @@ class Constants {
offset: Offset(0.5, 1.0), offset: Offset(0.5, 1.0),
); );
static const String unknown = 'unknown'; static const String overlayUnknown = ''; // em dash
static const String infoUnknown = 'unknown';
static const pointNemo = Tuple2(-48.876667, -123.393333); static const pointNemo = Tuple2(-48.876667, -123.393333);
static const int infoGroupMaxValueLength = 140; static const int infoGroupMaxValueLength = 140;
static const List<Dependency> androidDependencies = [ static const List<Dependency> androidDependencies = [
Dependency(
name: 'Android-TiffBitmapFactory',
license: 'MIT',
licenseUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory/blob/master/license.txt',
sourceUrl: 'https://github.com/Beyka/Android-TiffBitmapFactory',
),
Dependency( Dependency(
name: 'CWAC-Document', name: 'CWAC-Document',
license: 'Apache 2.0', license: 'Apache 2.0',

View file

@ -27,6 +27,7 @@ class Durations {
// fullscreen animations // fullscreen animations
static const fullscreenPageAnimation = Duration(milliseconds: 300); static const fullscreenPageAnimation = Duration(milliseconds: 300);
static const fullscreenOverlayAnimation = Duration(milliseconds: 200); static const fullscreenOverlayAnimation = Duration(milliseconds: 200);
static const fullscreenOverlayChangeAnimation = Duration(milliseconds: 150);
// info // info
static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const mapStyleSwitchAnimation = Duration(milliseconds: 300);

View file

@ -1,10 +1,10 @@
import 'dart:math' as math; import 'dart:math';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
String _decimal2sexagesimal(final double degDecimal) { String _decimal2sexagesimal(final double degDecimal) {
double _round(final double value, {final int decimals = 6}) => (value * math.pow(10, decimals)).round() / math.pow(10, decimals); double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals);
List<int> _split(final double value) { List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value // NumberFormat is necessary to create digit after comma if the value

11
lib/utils/math_utils.dart Normal file
View file

@ -0,0 +1,11 @@
import 'dart:math';
const double _piOver180 = pi / 180.0;
final double log2 = log(2);
double toDegrees(num radians) => radians / _piOver180;
double toRadians(num degrees) => degrees * _piOver180;
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / log2).floor());

View file

@ -12,11 +12,11 @@ String formatDuration(Duration d) {
} }
extension ExtraDateTime on DateTime { extension ExtraDateTime on DateTime {
bool isAtSameYearAs(DateTime other) => this != null && other != null && year == other.year; bool isAtSameYearAs(DateTime other) => this?.year == other?.year;
bool isAtSameMonthAs(DateTime other) => isAtSameYearAs(other) && month == other.month; bool isAtSameMonthAs(DateTime other) => isAtSameYearAs(other) && this?.month == other?.month;
bool isAtSameDayAs(DateTime other) => isAtSameMonthAs(other) && day == other.day; bool isAtSameDayAs(DateTime other) => isAtSameMonthAs(other) && this?.day == other?.day;
bool get isToday => isAtSameDayAs(DateTime.now()); bool get isToday => isAtSameDayAs(DateTime.now());

View file

@ -1,370 +0,0 @@
import 'dart:collection';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart';
class AppDebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source;
const AppDebugPage({this.source});
@override
State<StatefulWidget> createState() => AppDebugPageState();
}
class AppDebugPageState extends State<AppDebugPage> {
Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
Future<Map> _envLoader;
List<ImageEntry> get entries => widget.source.rawEntries;
@override
void initState() {
super.initState();
_startDbReport();
_envLoader = AndroidAppService.getEnv();
}
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
bottom: TabBar(
tabs: [
Tab(icon: Icon(AIcons.debug)),
Tab(icon: Icon(AIcons.settings)),
Tab(icon: Icon(AIcons.removableStorage)),
Tab(icon: Icon(AIcons.android)),
],
),
),
body: SafeArea(
child: TabBarView(
children: [
_buildGeneralTabView(),
_buildSettingsTabView(),
_buildStorageTabView(),
_buildEnvTabView(),
],
),
),
),
),
);
}
Widget _buildGeneralTabView() {
final catalogued = entries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated);
return ListView(
padding: EdgeInsets.all(16),
children: [
Text('Time dilation'),
Slider(
value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v),
min: 1.0,
max: 10.0,
divisions: 9,
label: '$timeDilation',
),
Divider(),
Row(
children: [
Expanded(
child: Text('Crashlytics'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: FirebaseCrashlytics.instance.crash,
child: Text('Crash'),
),
],
),
Row(
children: [
Expanded(
child: Text('Analytics'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => FirebaseAnalytics().logEvent(
name: 'debug_test',
parameters: {'time': DateTime.now().toIso8601String()},
),
child: Text('Send event'),
),
],
),
Text('Firebase data collection: ${Firebase.app().isAutomaticDataCollectionEnabled ? 'enabled' : 'disabled'}'),
Text('Crashlytics collection: ${FirebaseCrashlytics.instance.isCrashlyticsCollectionEnabled ? 'enabled' : 'disabled'}'),
Divider(),
Text('Entries: ${entries.length}'),
Text('Catalogued: ${catalogued.length}'),
Text('With GPS: ${withGps.length}'),
Text('With address: ${located.length}'),
Divider(),
Row(
children: [
Expanded(
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
imageCache.clear();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
PictureProvider.clearCache();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('Glide disk cache: ?'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'),
),
],
),
Divider(),
FutureBuilder<int>(
future: _dbFileSizeLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
child: Text('Reset'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB entry rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB date rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB metadata rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB address rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbFavouritesLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => favourites.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
],
);
}
Widget _buildSettingsTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: Text('Settings'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => settings.reset().then((_) => setState(() {})),
child: Text('Reset'),
),
],
),
InfoRowGroup({
'collectionGroupFactor': '${settings.collectionGroupFactor}',
'collectionSortFactor': '${settings.collectionSortFactor}',
'collectionTileExtent': '${settings.collectionTileExtent}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': '${settings.pinnedFilters}',
'searchHistory': '${settings.searchHistory}',
}),
],
);
}
Widget _buildStorageTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
...androidFileUtils.storageVolumes.expand((v) => [
Text(v.path),
InfoRowGroup({
'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',
}),
Divider(),
])
],
);
}
Widget _buildEnvTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
FutureBuilder<Map>(
future: _envLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null')));
return InfoRowGroup(data);
},
),
],
);
}
void _startDbReport() {
_dbFileSizeLoader = metadataDb.dbFileSize();
_dbEntryLoader = metadataDb.loadEntries();
_dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites();
setState(() {});
}
}

View file

@ -56,6 +56,7 @@ class MonthSectionHeader extends StatelessWidget {
static DateFormat ym = DateFormat.yMMMM(); static DateFormat ym = DateFormat.yMMMM();
static String _formatDate(DateTime date) { static String _formatDate(DateTime date) {
if (date == null) return 'Unknown';
if (date.isThisMonth) return 'This month'; if (date.isThisMonth) return 'This month';
if (date.isThisYear) return m.format(date); if (date.isThisYear) return m.format(date);
return ym.format(date); return ym.format(date);

View file

@ -80,7 +80,7 @@ class SectionHeader extends StatelessWidget {
final para = RenderParagraph( final para = RenderParagraph(
TextSpan( TextSpan(
children: [ children: [
// `RenderParagraph` fails to lay out `WidgetSpan` offscreen as of Flutter v1.17.0 // as of Flutter v1.22.3, `RenderParagraph` fails to lay out `WidgetSpan` offscreen
// so we use a hair space times a magic number to match width // so we use a hair space times a magic number to match width
TextSpan( TextSpan(
text: '\u200A' * (hasLeading ? 23 : 1), text: '\u200A' * (hasLeading ? 23 : 1),
@ -214,7 +214,7 @@ class SectionSelectableLeading extends StatelessWidget {
child: IconButton( child: IconButton(
iconSize: 26, iconSize: 26,
padding: EdgeInsets.only(top: 1), padding: EdgeInsets.only(top: 1),
alignment: Alignment.topLeft, alignment: AlignmentDirectional.topStart,
icon: Icon(selected ? AIcons.selected : AIcons.unselected), icon: Icon(selected ? AIcons.selected : AIcons.unselected),
onPressed: onPressed, onPressed: onPressed,
tooltip: selected ? 'Deselect section' : 'Select section', tooltip: selected ? 'Deselect section' : 'Select section',

View file

@ -10,9 +10,9 @@ import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up // Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up.
// with the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen // With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0 // because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
class CollectionListSliver extends StatelessWidget { class CollectionListSliver extends StatelessWidget {
const CollectionListSliver(); const CollectionListSliver();

View file

@ -226,10 +226,13 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
Positioned( Positioned(
left: clampedCenter.dx - extent / 2, left: clampedCenter.dx - extent / 2,
top: clampedCenter.dy - extent / 2, top: clampedCenter.dy - extent / 2,
child: DecoratedThumbnail( child: DefaultTextStyle(
entry: widget.imageEntry, style: TextStyle(),
extent: extent, child: DecoratedThumbnail(
showOverlay: false, entry: widget.imageEntry,
extent: extent,
showOverlay: false,
),
), ),
), ),
], ],

View file

@ -64,7 +64,7 @@ class DecoratedThumbnail extends StatelessWidget {
); );
} }
return Container( return Container(
decoration: BoxDecoration( foregroundDecoration: BoxDecoration(
border: Border.all( border: Border.all(
color: borderColor, color: borderColor,
width: borderWidth, width: borderWidth,

View file

@ -1,11 +1,17 @@
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget { class ErrorThumbnail extends StatelessWidget {
final ImageEntry entry;
final double extent; final double extent;
final String tooltip; final String tooltip;
const ErrorThumbnail({@required this.extent, @required this.tooltip}); const ErrorThumbnail({
@required this.entry,
@required this.extent,
@required this.tooltip,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -13,10 +19,13 @@ class ErrorThumbnail extends StatelessWidget {
child: Tooltip( child: Tooltip(
message: tooltip, message: tooltip,
preferBelow: false, preferBelow: false,
child: Icon( child: Text(
AIcons.error, MimeTypes.displayType(entry.mimeType),
size: extent / 2, style: TextStyle(
color: Colors.blueGrey, color: Colors.blueGrey,
fontSize: extent / 5,
),
textAlign: TextAlign.center,
), ),
), ),
); );

View file

@ -97,7 +97,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
); );
child = AnimatedContainer( child = AnimatedContainer(
duration: duration, duration: duration,
alignment: Alignment.topRight, alignment: AlignmentDirectional.topEnd,
color: selected ? Colors.black54 : Colors.transparent, color: selected ? Colors.black54 : Colors.transparent,
child: child, child: child,
); );

View file

@ -71,8 +71,6 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
_pauseProvider(); _pauseProvider();
} }
bool get isSupported => entry.canDecode;
void _initProvider() { void _initProvider() {
if (!entry.canDecode) return; if (!entry.canDecode) return;
@ -101,6 +99,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!entry.canDecode) { if (!entry.canDecode) {
return ErrorThumbnail( return ErrorThumbnail(
entry: entry,
extent: extent, extent: extent,
tooltip: '${entry.mimeType} not supported', tooltip: '${entry.mimeType} not supported',
); );
@ -139,6 +138,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
); );
}, },
errorBuilder: (context, error, stackTrace) => ErrorThumbnail( errorBuilder: (context, error, stackTrace) => ErrorThumbnail(
entry: entry,
extent: extent, extent: extent,
tooltip: error.toString(), tooltip: error.toString(),
), ),

View file

@ -9,12 +9,13 @@ class AvesExpansionTile extends StatelessWidget {
const AvesExpansionTile({ const AvesExpansionTile({
@required this.title, @required this.title,
@required this.children,
this.expandedNotifier, this.expandedNotifier,
@required this.children,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final enabled = children?.isNotEmpty == true;
return Theme( return Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
// color used by the `ExpansionTileCard` for selected text and icons // color used by the `ExpansionTileCard` for selected text and icons
@ -27,12 +28,17 @@ class AvesExpansionTile extends StatelessWidget {
title: HighlightTitle( title: HighlightTitle(
title, title,
fontSize: 18, fontSize: 18,
enabled: enabled,
),
expandable: enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(thickness: 1, height: 1),
SizedBox(height: 4),
if (enabled) ...children,
],
), ),
children: [
Divider(thickness: 1, height: 1),
SizedBox(height: 4),
...children,
],
baseColor: Colors.grey[900], baseColor: Colors.grey[900],
expandedColor: Colors.grey[850], expandedColor: Colors.grey[850],
), ),

View file

@ -201,7 +201,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
if (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped) { if (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped) {
chip = Hero( chip = Hero(
tag: filter, tag: filter,
child: chip, child: DefaultTextStyle(
style: TextStyle(),
child: chip,
),
); );
} }
return chip; return chip;

View file

@ -5,11 +5,15 @@ import 'package:flutter/material.dart';
class HighlightTitle extends StatelessWidget { class HighlightTitle extends StatelessWidget {
final String name; final String name;
final double fontSize; final double fontSize;
final bool enabled;
const HighlightTitle( const HighlightTitle(
this.name, { this.name, {
this.fontSize = 20, this.fontSize = 20,
}); this.enabled = true,
}) : assert(name != null);
static const disabledColor = Colors.grey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -17,7 +21,7 @@ class HighlightTitle extends StatelessWidget {
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: Container( child: Container(
decoration: HighlightDecoration( decoration: HighlightDecoration(
color: stringToColor(name), color: enabled ? stringToColor(name) : disabledColor,
), ),
margin: EdgeInsets.symmetric(vertical: 4.0), margin: EdgeInsets.symmetric(vertical: 4.0),
child: Text( child: Text(

View file

@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class RegionProvider extends ImageProvider<RegionProviderKey> {
final RegionProviderKey key;
RegionProvider(this.key) : assert(key != null);
@override
Future<RegionProviderKey> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<RegionProviderKey>(key);
}
@override
ImageStreamCompleter load(RegionProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}');
},
);
}
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
final uri = key.uri;
final mimeType = key.mimeType;
try {
final bytes = await ImageFileService.getRegion(
uri,
mimeType,
key.rotationDegrees,
key.isFlipped,
key.sampleSize,
key.regionRect,
key.imageSize,
taskKey: key,
);
if (bytes == null) {
throw StateError('$uri ($mimeType) region loading failed');
}
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType region decoding failed');
}
}
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => ImageFileService.cancelRegion(key);
}
class RegionProviderKey {
final String uri, mimeType;
final int rotationDegrees, sampleSize;
final bool isFlipped;
final Rectangle<int> regionRect;
final Size imageSize;
final double scale;
const RegionProviderKey({
@required this.uri,
@required this.mimeType,
@required this.rotationDegrees,
@required this.isFlipped,
@required this.sampleSize,
@required this.regionRect,
@required this.imageSize,
this.scale = 1.0,
}) : assert(uri != null),
assert(mimeType != null),
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(sampleSize != null),
assert(regionRect != null),
assert(imageSize != null),
assert(scale != null);
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
factory RegionProviderKey.fromEntry(
ImageEntry entry, {
@required int sampleSize,
@required Rectangle<int> rect,
}) {
return RegionProviderKey(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
sampleSize: sampleSize,
regionRect: rect,
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
}
@override
int get hashCode => hashValues(
uri,
mimeType,
rotationDegrees,
isFlipped,
mimeType,
sampleSize,
regionRect,
imageSize,
scale,
);
@override
String toString() {
return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)';
}
}

View file

@ -30,8 +30,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
} }
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
var uri = key.uri; final uri = key.uri;
var mimeType = key.mimeType; final mimeType = key.mimeType;
try { try {
final bytes = await ImageFileService.getThumbnail( final bytes = await ImageFileService.getThumbnail(
uri, uri,
@ -55,7 +55,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeThumbnail(key); ImageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
@ -105,7 +105,15 @@ class ThumbnailProviderKey {
} }
@override @override
int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale); int get hashCode => hashValues(
uri,
mimeType,
dateModifiedSecs,
rotationDegrees,
isFlipped,
extent,
scale,
);
@override @override
String toString() { String toString() {

View file

@ -73,7 +73,7 @@ class UriImage extends ImageProvider<UriImage> {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; if (other.runtimeType != runtimeType) return false;
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.scale == scale; return other is UriImage && other.uri == uri && other.scale == scale;
} }
@override @override

View file

@ -0,0 +1,47 @@
import 'dart:collection';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
class DebugAndroidEnvironmentSection extends StatefulWidget {
@override
_DebugAndroidEnvironmentSectionState createState() => _DebugAndroidEnvironmentSectionState();
}
class _DebugAndroidEnvironmentSectionState extends State<DebugAndroidEnvironmentSection> with AutomaticKeepAliveClientMixin {
Future<Map> _loader;
@override
void initState() {
super.initState();
_loader = AndroidAppService.getEnv();
}
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Android Environment',
children: [
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: FutureBuilder<Map>(
future: _loader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null')));
return InfoRowGroup(data);
},
),
),
],
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,107 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/debug/android_env.dart';
import 'package:aves/widgets/debug/cache.dart';
import 'package:aves/widgets/debug/database.dart';
import 'package:aves/widgets/debug/firebase.dart';
import 'package:aves/widgets/debug/overlay.dart';
import 'package:aves/widgets/debug/settings.dart';
import 'package:aves/widgets/debug/storage.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
class AppDebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source;
const AppDebugPage({this.source});
@override
State<StatefulWidget> createState() => AppDebugPageState();
}
class AppDebugPageState extends State<AppDebugPage> {
List<ImageEntry> get entries => widget.source.rawEntries;
static OverlayEntry _taskQueueOverlayEntry;
@override
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
),
body: SafeArea(
child: ListView(
padding: EdgeInsets.all(8),
children: [
_buildGeneralTabView(),
DebugAndroidEnvironmentSection(),
DebugCacheSection(),
DebugAppDatabaseSection(),
DebugFirebaseSection(),
DebugSettingsSection(),
DebugStorageSection(),
],
),
),
),
);
}
Widget _buildGeneralTabView() {
final catalogued = entries.where((entry) => entry.isCatalogued);
final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated);
return AvesExpansionTile(
title: 'General',
children: [
Padding(
padding: EdgeInsets.all(8),
child: Text('Time dilation'),
),
Slider(
value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v),
min: 1.0,
max: 10.0,
divisions: 9,
label: '$timeDilation',
),
SwitchListTile(
value: _taskQueueOverlayEntry != null,
onChanged: (v) {
_taskQueueOverlayEntry?.remove();
if (v) {
_taskQueueOverlayEntry = OverlayEntry(
builder: (context) => DebugTaskQueueOverlay(),
);
Overlay.of(context).insert(_taskQueueOverlayEntry);
} else {
_taskQueueOverlayEntry = null;
}
setState(() {});
},
title: Text('Show tasks overlay'),
),
Divider(),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
{
'Entries': '${entries.length}',
'Catalogued': '${catalogued.length}',
'With GPS': '${withGps.length}',
'With address': '${located.length}',
},
),
),
],
);
}
}

View file

@ -0,0 +1,77 @@
import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class DebugCacheSection extends StatefulWidget {
@override
_DebugCacheSectionState createState() => _DebugCacheSectionState();
}
class _DebugCacheSectionState extends State<DebugCacheSection> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Cache',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
imageCache.clear();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () {
PictureProvider.clearCache();
setState(() {});
},
child: Text('Clear'),
),
],
),
Row(
children: [
Expanded(
child: Text('Glide disk cache: ?'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'),
),
],
),
],
),
),
],
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,184 @@
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:flutter/material.dart';
class DebugAppDatabaseSection extends StatefulWidget {
@override
_DebugAppDatabaseSectionState createState() => _DebugAppDatabaseSectionState();
}
class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with AutomaticKeepAliveClientMixin {
Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;
Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader;
@override
void initState() {
super.initState();
_startDbReport();
}
@override
Widget build(BuildContext context) {
super.build(context);
return AvesExpansionTile(
title: 'Database',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
FutureBuilder<int>(
future: _dbFileSizeLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
child: Text('Reset'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('entry rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('date rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('metadata rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('address rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
FutureBuilder<List>(
future: _dbFavouritesLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row(
children: [
Expanded(
child: Text('favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => favourites.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
],
);
},
),
],
),
),
],
);
}
void _startDbReport() {
_dbFileSizeLoader = metadataDb.dbFileSize();
_dbEntryLoader = metadataDb.loadEntries();
_dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites();
setState(() {});
}
@override
bool get wantKeepAlive => true;
}

View file

@ -0,0 +1,43 @@
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
class DebugFirebaseSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AvesExpansionTile(
title: 'Firebase',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
ElevatedButton(
onPressed: FirebaseCrashlytics.instance.crash,
child: Text('Crash'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () => FirebaseAnalytics().logEvent(
name: 'debug_test',
parameters: {'time': DateTime.now().toIso8601String()},
),
child: Text('Send event'),
),
],
),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}',
'Crashlytics collection enabled': '${FirebaseCrashlytics.instance.isCrashlyticsCollectionEnabled}',
}),
)
],
);
}
}

View file

@ -0,0 +1,39 @@
import 'package:aves/services/service_policy.dart';
import 'package:flutter/material.dart';
class DebugTaskQueueOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: DefaultTextStyle(
style: TextStyle(),
child: Align(
alignment: AlignmentDirectional.bottomStart,
child: SafeArea(
child: Container(
color: Colors.indigo[900].withAlpha(0xCC),
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
padding: EdgeInsets.all(8),
child: StreamBuilder<QueueState>(
stream: servicePolicy.queueStream,
builder: (context, snapshot) {
if (snapshot.hasError) return SizedBox.shrink();
final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []);
queuedEntries.sort((a, b) => a.key.compareTo(b.key));
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(queuedEntries.map((kv) => '${kv.key}: ${kv.value}').join(', ')),
],
);
}),
),
),
),
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DebugSettingsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<Settings>(builder: (context, settings, child) {
String toMultiline(Iterable l) => l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
return AvesExpansionTile(
title: 'Settings',
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: ElevatedButton(
onPressed: () => settings.reset(),
child: Text('Reset'),
),
),
SwitchListTile(
value: settings.hasAcceptedTerms,
onChanged: (v) => settings.hasAcceptedTerms = v,
title: Text('hasAcceptedTerms'),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup({
'collectionTileExtent': '${settings.collectionTileExtent}',
'infoMapZoom': '${settings.infoMapZoom}',
'pinnedFilters': toMultiline(settings.pinnedFilters),
'searchHistory': toMultiline(settings.searchHistory),
}),
),
],
);
});
}
}

View file

@ -0,0 +1,32 @@
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:flutter/material.dart';
class DebugStorageSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AvesExpansionTile(
title: 'Storage Volumes',
children: [
...androidFileUtils.storageVolumes.expand((v) => [
Padding(
padding: EdgeInsets.all(8),
child: Text(v.path),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: InfoRowGroup({
'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',
}),
),
Divider(),
])
],
);
}
}

View file

@ -10,9 +10,9 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/app_debug_page.dart';
import 'package:aves/widgets/common/aves_logo.dart'; import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/debug/app_debug_page.dart';
import 'package:aves/widgets/drawer/collection_tile.dart'; import 'package:aves/widgets/drawer/collection_tile.dart';
import 'package:aves/widgets/drawer/tile.dart'; import 'package:aves/widgets/drawer/tile.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';

View file

@ -18,7 +18,7 @@ class MetadataTab extends StatefulWidget {
} }
class _MetadataTabState extends State<MetadataTab> { class _MetadataTabState extends State<MetadataTab> {
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader; Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
// MediaStore timestamp keys // MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
@ -33,9 +33,11 @@ class _MetadataTabState extends State<MetadataTab> {
} }
void _loadMetadata() { void _loadMetadata() {
_bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry);
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); _mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
_metadataExtractorLoader = MetadataService.getMetadataExtractorSummary(entry);
setState(() {}); setState(() {});
} }
@ -60,22 +62,27 @@ class _MetadataTabState extends State<MetadataTab> {
})); }));
return AvesExpansionTile( return AvesExpansionTile(
title: title, title: title,
children: [ children: data.isNotEmpty
Container( ? [
alignment: AlignmentDirectional.topStart, Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup( child: InfoRowGroup(
data, data,
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
), ),
) )
], ]
: null,
); );
} }
return ListView( return ListView(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
children: [ children: [
FutureBuilder<Map>(
future: _bitmapFactoryLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
),
FutureBuilder<Map>( FutureBuilder<Map>(
future: _contentResolverMetadataLoader, future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
@ -88,6 +95,10 @@ class _MetadataTabState extends State<MetadataTab> {
future: _mediaMetadataLoader, future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'), builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
), ),
FutureBuilder<Map>(
future: _metadataExtractorLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'),
),
], ],
); );
} }

View file

@ -10,6 +10,7 @@ import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
@ -52,6 +53,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
EdgeInsets _frozenViewInsets, _frozenViewPadding; EdgeInsets _frozenViewInsets, _frozenViewPadding;
EntryActionDelegate _actionDelegate; EntryActionDelegate _actionDelegate;
final List<Tuple2<String, IjkMediaController>> _videoControllers = []; final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@ -97,7 +99,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
collection: collection, collection: collection,
showInfo: () => _goToVerticalPage(infoPage), showInfo: () => _goToVerticalPage(infoPage),
); );
_initVideoController(); _initViewStateControllers();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
@ -154,7 +156,11 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
}, },
child: NotificationListener( child: NotificationListener(
onNotification: (notification) { onNotification: (notification) {
if (notification is FilterNotification) _goToCollection(notification.filter); if (notification is FilterNotification) {
_goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
}
return false; return false;
}, },
child: Stack( child: Stack(
@ -169,6 +175,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
onHorizontalPageChanged: _onHorizontalPageChanged, onHorizontalPageChanged: _onHorizontalPageChanged,
onImageTap: () => _overlayVisible.value = !_overlayVisible.value, onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
onImagePageRequested: () => _goToVerticalPage(imagePage), onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null),
), ),
_buildTopOverlay(), _buildTopOverlay(),
_buildBottomOverlay(), _buildBottomOverlay(),
@ -178,11 +185,17 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
); );
} }
void _updateViewState(String uri, ViewState viewState) {
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == uri, orElse: () => null)?.item2;
viewStateNotifier?.value = viewState ?? ViewState.zero;
}
Widget _buildTopOverlay() { Widget _buildTopOverlay() {
final child = ValueListenableBuilder<ImageEntry>( final child = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, entry, child) { builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink(); if (entry == null) return SizedBox.shrink();
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return FullscreenTopOverlay( return FullscreenTopOverlay(
entry: entry, entry: entry,
scale: _topOverlayScale, scale: _topOverlayScale,
@ -190,6 +203,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,
onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action),
viewStateNotifier: viewStateNotifier,
); );
}, },
); );
@ -324,7 +338,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
if (_entryNotifier.value == newEntry) return; if (_entryNotifier.value == newEntry) return;
_entryNotifier.value = newEntry; _entryNotifier.value = newEntry;
_pauseVideoControllers(); _pauseVideoControllers();
_initVideoController(); _initViewStateControllers();
} }
void _onLeave() { void _onLeave() {
@ -381,28 +395,45 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
} }
} }
// video controller // state controllers/monitors
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); void _initViewStateControllers() {
Future<void> _initVideoController() async {
final entry = _entryNotifier.value; final entry = _entryNotifier.value;
if (entry == null || !entry.isVideo) return; if (entry == null) return;
final uri = entry.uri; final uri = entry.uri;
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null); _initViewSpecificController<ValueNotifier<ViewState>>(
if (controllerEntry != null) { uri,
_videoControllers.remove(controllerEntry); _viewStateNotifiers,
} else { () => ValueNotifier<ViewState>(ViewState.zero),
// do not set data source of IjkMediaController here (_) => _.dispose(),
controllerEntry = Tuple2(uri, IjkMediaController()); );
} if (entry.isVideo) {
_videoControllers.insert(0, controllerEntry); _initViewSpecificController<IjkMediaController>(
while (_videoControllers.length > 3) { uri,
_videoControllers.removeLast().item2.dispose(); _videoControllers,
() => IjkMediaController(),
(_) => _.dispose(),
);
} }
setState(() {}); setState(() {});
} }
void _initViewSpecificController<T>(String uri, List<Tuple2<String, T>> controllers, T Function() builder, void Function(T controller) disposer) {
var controller = controllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
if (controller != null) {
controllers.remove(controller);
} else {
controller = Tuple2(uri, builder());
}
controllers.insert(0, controller);
while (controllers.length > 3) {
disposer?.call(controllers.removeLast().item2);
}
}
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
} }
class FullscreenVerticalPageView extends StatefulWidget { class FullscreenVerticalPageView extends StatefulWidget {
@ -412,6 +443,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
final PageController horizontalPager, verticalPager; final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImageTap, onImagePageRequested; final VoidCallback onImageTap, onImagePageRequested;
final void Function(String uri) onViewDisposed;
const FullscreenVerticalPageView({ const FullscreenVerticalPageView({
@required this.collection, @required this.collection,
@ -423,6 +455,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
@required this.onHorizontalPageChanged, @required this.onHorizontalPageChanged,
@required this.onImageTap, @required this.onImageTap,
@required this.onImagePageRequested, @required this.onImagePageRequested,
@required this.onViewDisposed,
}); });
@override @override
@ -483,6 +516,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
onTap: widget.onImageTap, onTap: widget.onImageTap,
onPageChanged: widget.onHorizontalPageChanged, onPageChanged: widget.onHorizontalPageChanged,
videoControllers: widget.videoControllers, videoControllers: widget.videoControllers,
onViewDisposed: widget.onViewDisposed,
) )
: SingleImagePage( : SingleImagePage(
entry: entry, entry: entry,

View file

@ -10,17 +10,17 @@ class MultiImagePage extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
final PageController pageController; final PageController pageController;
final ValueChanged<int> onPageChanged; final ValueChanged<int> onPageChanged;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap; final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers; final List<Tuple2<String, IjkMediaController>> videoControllers;
final void Function(String uri) onViewDisposed;
const MultiImagePage({ const MultiImagePage({
this.collection, this.collection,
this.pageController, this.pageController,
this.onPageChanged, this.onPageChanged,
this.onScaleChanged,
this.onTap, this.onTap,
this.videoControllers, this.videoControllers,
this.onViewDisposed,
}); });
@override @override
@ -49,9 +49,9 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
key: Key('imageview'), key: Key('imageview'),
entry: entry, entry: entry,
heroTag: widget.collection.heroTag(entry), heroTag: widget.collection.heroTag(entry),
onScaleChanged: widget.onScaleChanged,
onTap: widget.onTap, onTap: widget.onTap,
videoControllers: widget.videoControllers, videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
), ),
); );
}, },
@ -66,13 +66,11 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
class SingleImagePage extends StatefulWidget { class SingleImagePage extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap; final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers; final List<Tuple2<String, IjkMediaController>> videoControllers;
const SingleImagePage({ const SingleImagePage({
this.entry, this.entry,
this.onScaleChanged,
this.onTap, this.onTap,
this.videoControllers, this.videoControllers,
}); });
@ -90,7 +88,6 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
axis: [Axis.vertical], axis: [Axis.vertical],
child: ImageView( child: ImageView(
entry: widget.entry, entry: widget.entry,
onScaleChanged: widget.onScaleChanged,
onTap: widget.onTap, onTap: widget.onTap,
videoControllers: widget.videoControllers, videoControllers: widget.videoControllers,
), ),

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/empty.dart';
@ -5,133 +7,221 @@ import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/tiled_view.dart';
import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class ImageView extends StatelessWidget { class ImageView extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final Object heroTag; final Object heroTag;
final ValueChanged<PhotoViewScaleState> onScaleChanged;
final VoidCallback onTap; final VoidCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers; final List<Tuple2<String, IjkMediaController>> videoControllers;
final VoidCallback onDisposed;
const ImageView({ const ImageView({
Key key, Key key,
this.entry, @required this.entry,
this.heroTag, this.heroTag,
this.onScaleChanged, @required this.onTap,
this.onTap, @required this.videoControllers,
this.videoControllers, this.onDisposed,
}) : super(key: key); }) : super(key: key);
@override
_ImageViewState createState() => _ImageViewState();
}
class _ImageViewState extends State<ImageView> {
final PhotoViewController _photoViewController = PhotoViewController();
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier<ViewState>(ViewState.zero);
StreamSubscription<PhotoViewControllerValue> _subscription;
Size _photoViewChildSize;
static const backgroundDecoration = BoxDecoration(color: Colors.transparent);
static const maxScale = 2.0;
ImageEntry get entry => widget.entry;
VoidCallback get onTap => widget.onTap;
@override
void initState() {
super.initState();
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged);
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) {
_photoViewChildSize = entry.displaySize;
}
}
@override
void dispose() {
_subscription.cancel();
_subscription = null;
widget.onDisposed?.call();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const backgroundDecoration = BoxDecoration(color: Colors.transparent);
// no hero for videos, as a typical video first frame is different from its thumbnail
if (entry.isVideo) {
final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild(
child: videoController != null
? AvesVideo(
entry: entry,
controller: videoController,
)
: SizedBox(),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final is loaded before the hero animation is done.
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
final fastThumbnailProvider = ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
Widget loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: imageProvider,
fit: BoxFit.fill,
),
),
);
}
Widget child; Widget child;
if (entry.isSvg) { if (entry.isVideo) {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver); child = _buildVideoView();
child = PhotoView.customChild( } else if (entry.isSvg) {
child: SvgPicture( child = _buildSvgView();
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
placeholderBuilder: (context) => loadingBuilder(context, fastThumbnailProvider),
colorFilter: colorFilter,
),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
} else if (entry.canDecode) { } else if (entry.canDecode) {
final uriImage = UriImage( if (useTile) {
uri: entry.uri, child = _buildTiledImageView();
mimeType: entry.mimeType, } else {
rotationDegrees: entry.rotationDegrees, child = _buildImageView();
isFlipped: entry.isFlipped, }
expectedContentLength: entry.sizeBytes,
);
child = PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
loadingBuilder: (context, event) => loadingBuilder(
context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
),
loadFailedChild: _buildError(),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
} else { } else {
child = _buildError(); child = _buildError();
} }
return heroTag != null // if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final image is loaded before the hero animation is done.
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
// no hero for videos, as a typical video first frame is different from its thumbnail
return widget.heroTag != null && !entry.isVideo
? Hero( ? Hero(
tag: heroTag, tag: widget.heroTag,
transitionOnUserGestures: true, transitionOnUserGestures: true,
child: child, child: child,
) )
: child; : child;
} }
// the images loaded by `PhotoView` cannot have a width or height larger than 8192
// so the reported offset and scale does not match expected values derived from the original dimensions
// besides, large images should be tiled to be memory-friendly
bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: imageProvider,
fit: BoxFit.fill,
),
),
);
}
Widget _buildImageView() {
final uriImage = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
return PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
loadingBuilder: (context, event) => _loadingBuilder(
context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
),
loadFailedChild: _buildError(),
backgroundDecoration: backgroundDecoration,
imageSizedCallback: (size) {
// do not directly update the `ViewState` notifier as this callback is called during build
_photoViewChildSize = size;
},
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
Widget _buildTiledImageView() {
return PhotoView.customChild(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
child: Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
return TiledImageView(
entry: entry,
viewportSize: mqSize,
viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider),
errorBuilder: (context, error, stackTrace) => _buildError(),
);
},
),
childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
Widget _buildSvgView() {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
return PhotoView.customChild(
child: SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
placeholderBuilder: (context) => _loadingBuilder(context, fastThumbnailProvider),
colorFilter: colorFilter,
),
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
Widget _buildVideoView() {
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild(
child: videoController != null
? AvesVideo(
entry: entry,
controller: videoController,
)
: SizedBox(),
childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
Widget _buildError() => GestureDetector( Widget _buildError() => GestureDetector(
onTap: () => onTap?.call(), onTap: () => onTap?.call(),
// use a `Container` with a dummy color to make it expand // use a `Container` with a dummy color to make it expand
@ -145,4 +235,37 @@ class ImageView extends StatelessWidget {
), ),
), ),
); );
void _onViewChanged(PhotoViewControllerValue v) {
final viewState = ViewState(v.position, v.scale, _photoViewChildSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
}
class ViewState {
final Offset position;
final double scale;
final Size size;
static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null);
const ViewState(this.position, this.scale, this.size);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
}
}
class ViewStateNotification extends Notification {
final String uri;
final ViewState viewState;
const ViewStateNotification(this.uri, this.viewState);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
}
} }

View file

@ -29,7 +29,7 @@ class BasicSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.unknown; final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.infoUnknown;
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
@ -37,12 +37,12 @@ class BasicSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InfoRowGroup({ InfoRowGroup({
'Title': entry.bestTitle ?? Constants.unknown, 'Title': entry.bestTitle ?? Constants.infoUnknown,
'Date': dateText, 'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(), if (entry.isVideo) ..._buildVideoRows(),
if (!entry.isSvg) 'Resolution': resolutionText, if (!entry.isSvg) 'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.unknown, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
'URI': entry.uri ?? Constants.unknown, 'URI': entry.uri ?? Constants.infoUnknown,
if (entry.path != null) 'Path': entry.path, if (entry.path != null) 'Path': entry.path,
}), }),
_buildChips(), _buildChips(),

View file

@ -1,17 +1,8 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong/latlong.dart';
const double piOver180 = PI / 180.0;
double toDegrees(double radians) {
return radians / piOver180;
}
double toRadians(double degrees) {
return degrees * piOver180;
}
LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) {
var mSemiMajorAxis = 6378137.0; //WGS84 major axis var mSemiMajorAxis = 6378137.0; //WGS84 major axis
var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0;

View file

@ -5,6 +5,7 @@ import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart'; import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart';
@ -122,15 +123,15 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
} }
Widget _buildDirTileWithTitle(_MetadataDirectory dir) { Widget _buildDirTileWithTitle(_MetadataDirectory dir) {
if (dir.name == xmpDirectory) {
return _buildXmpDirTile(dir);
}
Widget thumbnail; Widget thumbnail;
final prefixChildren = <Widget>[]; final prefixChildren = <Widget>[];
switch (dir.name) { switch (dir.name) {
case exifThumbnailDirectory: case exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
break; break;
case xmpDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
break;
case mediaDirectory: case mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
Widget builder(IconData data) => Padding( Widget builder(IconData data) => Padding(
@ -153,14 +154,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
title: dir.name, title: dir.name,
expandedNotifier: _expandedDirectoryNotifier, expandedNotifier: _expandedDirectoryNotifier,
children: [ children: [
if (prefixChildren.isNotEmpty) if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
Align(
alignment: AlignmentDirectional.topStart,
child: Wrap(children: prefixChildren),
),
if (thumbnail != null) thumbnail, if (thumbnail != null) thumbnail,
Container( Padding(
alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength), child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength),
), ),
@ -168,6 +164,42 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
); );
} }
Widget _buildXmpDirTile(_MetadataDirectory dir) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
final byNamespace = SplayTreeMap.of(
groupBy<MapEntry<String, String>, String>(dir.tags.entries, (kv) {
final fullKey = kv.key;
final i = fullKey.indexOf(':');
if (i == -1) return '';
return fullKey.substring(0, i);
}),
compareAsciiLowerCase,
);
return AvesExpansionTile(
title: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
children: [
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: byNamespace.entries.expand((kv) {
final ns = kv.key;
final hasNamespace = ns.isNotEmpty;
final i = hasNamespace ? ns.length + 1 : 0;
final tags = Map.fromEntries(kv.value.map((kv) => MapEntry(kv.key.substring(i), kv.value)));
return [
if (hasNamespace) HighlightTitle(ns),
InfoRowGroup(tags, maxValueLength: Constants.infoGroupMaxValueLength),
];
}).toList(),
),
),
],
);
}
void _onMetadataChanged() { void _onMetadataChanged() {
_loadedMetadataUri.value = null; _loadedMetadataUri.value = null;
_metadata = []; _metadata = [];
@ -196,6 +228,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
_metadata = []; _metadata = [];
_loadedMetadataUri.value = null; _loadedMetadataUri.value = null;
} }
_expandedDirectoryNotifier.value = null;
} }
@override @override

View file

@ -6,6 +6,7 @@ import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart';
@ -150,25 +151,21 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
final positionTitle = [ final positionTitle = [
if (position != null) position, if (position != null) position,
if (entry.bestTitle != null) entry.bestTitle, if (entry.bestTitle != null) entry.bestTitle,
].join(' '); ].join(' ');
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails; final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle), if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
if (entry.hasGps) _buildSoloLocationRow(),
Container(
padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry),
),
if (twoColumns) if (twoColumns)
Padding( Padding(
padding: EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
child: Row( child: Row(
children: [ children: [
Container(width: subRowWidth, child: _DateRow(entry)), Container(width: subRowWidth, child: _DateRow(entry)),
if (hasShootingDetails) Container(width: subRowWidth, child: _ShootingRow(details)), _buildDuoShootingRow(subRowWidth, hasShootingDetails),
], ],
), ),
) )
@ -178,12 +175,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
width: subRowWidth, width: subRowWidth,
child: _DateRow(entry), child: _DateRow(entry),
), ),
if (hasShootingDetails) _buildSoloShootingRow(subRowWidth, hasShootingDetails),
Container(
padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth,
child: _ShootingRow(details),
),
], ],
], ],
); );
@ -192,6 +184,58 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
), ),
); );
} }
Widget _buildSoloLocationRow() => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
child: entry.hasGps
? Container(
padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry),
)
: SizedBox.shrink(),
);
Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: _soloTransition,
child: hasShootingDetails
? Container(
padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth,
child: _ShootingRow(details),
)
: SizedBox.shrink(),
);
Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher(
duration: Durations.fullscreenOverlayChangeAnimation,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: hasShootingDetails
? Container(
width: subRowWidth,
child: _ShootingRow(details),
)
: SizedBox.shrink(),
);
static Widget _soloTransition(Widget child, Animation<double> animation) => FadeTransition(
opacity: animation,
child: SizeTransition(
axisAlignment: 1,
sizeFactor: animation,
child: child,
),
);
} }
class _LocationRow extends AnimatedWidget { class _LocationRow extends AnimatedWidget {
@ -228,7 +272,7 @@ class _DateRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.unknown; final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.overlayUnknown;
return Row( return Row(
children: [ children: [
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
@ -251,10 +295,10 @@ class _ShootingRow extends StatelessWidget {
children: [ children: [
DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize), DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.aperture ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.focalLength, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.focalLength ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.iso, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.iso ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
], ],
); );
} }

View file

@ -0,0 +1,104 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Minimap extends StatelessWidget {
final ImageEntry entry;
final ValueNotifier<ViewState> viewStateNotifier;
final Size size;
static const defaultSize = Size(96, 96);
const Minimap({
@required this.entry,
@required this.viewStateNotifier,
this.size = defaultSize,
});
@override
Widget build(BuildContext context) {
return Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
return AnimatedBuilder(
animation: viewStateNotifier,
builder: (context, child) {
final viewState = viewStateNotifier.value;
return CustomPaint(
painter: MinimapPainter(
viewportSize: mqSize,
entrySize: viewState.size ?? entry.displaySize,
viewCenterOffset: viewState.position,
viewScale: viewState.scale,
minimapBorderColor: Colors.white30,
),
size: size,
);
});
});
}
}
class MinimapPainter extends CustomPainter {
final Size entrySize, viewportSize;
final Offset viewCenterOffset;
final double viewScale;
final Color minimapBorderColor, viewportBorderColor;
const MinimapPainter({
@required this.viewportSize,
@required this.entrySize,
@required this.viewCenterOffset,
@required this.viewScale,
this.minimapBorderColor = Colors.white,
this.viewportBorderColor = Colors.white,
});
@override
void paint(Canvas canvas, Size size) {
final viewSize = entrySize * viewScale;
if (viewSize.isEmpty) return;
// hide minimap when image is in full view
if (viewportSize + Offset(precisionErrorTolerance, precisionErrorTolerance) >= viewSize) return;
final canvasScale = size.longestSide / viewSize.longestSide;
final scaledEntrySize = viewSize * canvasScale;
final scaledViewportSize = viewportSize * canvasScale;
final entryRect = Rect.fromCenter(
center: size.center(Offset.zero),
width: scaledEntrySize.width,
height: scaledEntrySize.height,
);
final viewportRect = Rect.fromCenter(
center: size.center(Offset.zero) - viewCenterOffset * canvasScale,
width: min(scaledEntrySize.width, scaledViewportSize.width),
height: min(scaledEntrySize.height, scaledViewportSize.height),
);
canvas.translate((entryRect.width - size.width) / 2, (entryRect.height - size.height) / 2);
final fill = Paint()
..style = PaintingStyle.fill
..color = Color(0x33000000);
final minimapStroke = Paint()
..style = PaintingStyle.stroke
..color = minimapBorderColor;
final viewportStroke = Paint()
..style = PaintingStyle.stroke
..color = viewportBorderColor;
canvas.drawRect(viewportRect, fill);
canvas.drawRect(entryRect, fill);
canvas.drawRect(entryRect, minimapStroke);
canvas.drawRect(viewportRect, viewportStroke);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View file

@ -2,11 +2,14 @@ import 'dart:math';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart'; import 'package:aves/widgets/fullscreen/overlay/common.dart';
import 'package:aves/widgets/fullscreen/overlay/minimap.dart';
import 'package:flutter/foundation.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';
@ -18,6 +21,7 @@ class FullscreenTopOverlay extends StatelessWidget {
final EdgeInsets viewInsets, viewPadding; final EdgeInsets viewInsets, viewPadding;
final Function(EntryAction value) onActionSelected; final Function(EntryAction value) onActionSelected;
final bool canToggleFavourite; final bool canToggleFavourite;
final ValueNotifier<ViewState> viewStateNotifier;
static const double padding = 8; static const double padding = 8;
@ -33,6 +37,7 @@ class FullscreenTopOverlay extends StatelessWidget {
@required this.viewInsets, @required this.viewInsets,
@required this.viewPadding, @required this.viewPadding,
@required this.onActionSelected, @required this.onActionSelected,
this.viewStateNotifier,
}) : super(key: key); }) : super(key: key);
@override @override
@ -58,8 +63,7 @@ class FullscreenTopOverlay extends StatelessWidget {
].where(_canDo).take(quickActionCount).toList(); ].where(_canDo).take(quickActionCount).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList(); final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
final externalAppActions = EntryActions.externalApp.where(_canDo).toList(); final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
final buttonRow = _TopOverlayRow(
return _TopOverlayRow(
quickActions: quickActions, quickActions: quickActions,
inAppActions: inAppActions, inAppActions: inAppActions,
externalAppActions: externalAppActions, externalAppActions: externalAppActions,
@ -67,6 +71,23 @@ class FullscreenTopOverlay extends StatelessWidget {
entry: entry, entry: entry,
onActionSelected: onActionSelected, onActionSelected: onActionSelected,
); );
return settings.showOverlayMinimap && viewStateNotifier != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buttonRow,
SizedBox(height: 8),
FadeTransition(
opacity: scale,
child: Minimap(
entry: entry,
viewStateNotifier: viewStateNotifier,
),
)
],
)
: buttonRow;
}, },
), ),
), ),

View file

@ -0,0 +1,279 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/image_providers/region_provider.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class TiledImageView extends StatefulWidget {
final ImageEntry entry;
final Size viewportSize;
final ValueNotifier<ViewState> viewStateNotifier;
final Widget baseChild;
final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({
@required this.entry,
@required this.viewportSize,
@required this.viewStateNotifier,
@required this.baseChild,
@required this.errorBuilder,
});
@override
_TiledImageViewState createState() => _TiledImageViewState();
}
class _TiledImageViewState extends State<TiledImageView> {
double _tileSide, _initialScale;
int _maxSampleSize;
Matrix4 _transform;
ImageEntry get entry => widget.entry;
Size get viewportSize => widget.viewportSize;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(TiledImageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
_init();
}
}
void _init() {
_tileSide = viewportSize.shortestSide * scaleFactor;
_initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height);
_maxSampleSize = _sampleSizeForScale(_initialScale);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_transform = null;
if (rotationDegrees != 0 || isFlipped) {
_transform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0);
}
}
@override
Widget build(BuildContext context) {
if (viewStateNotifier == null) return SizedBox.shrink();
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
return AnimatedBuilder(
animation: viewStateNotifier,
builder: (context, child) {
final viewState = viewStateNotifier.value;
var scale = viewState.scale;
if (scale == 0.0) {
// for initial scale as `PhotoViewComputedScale.contained`
scale = _initialScale;
}
final centerOffset = viewState.position;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
final viewRect = viewOrigin & viewportSize;
final tiles = <RegionTile>[];
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) {
// for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image instead of splitting it in tiles
final useTiles = sampleSize != _maxSampleSize;
final regionSide = (_tileSide * sampleSize).round();
final layerRegionWidth = useTiles ? regionSide : displayWidth;
final layerRegionHeight = useTiles ? regionSide : displayHeight;
for (var x = 0; x < displayWidth; x += layerRegionWidth) {
for (var y = 0; y < displayHeight; y += layerRegionHeight) {
final nextX = x + layerRegionWidth;
final nextY = y + layerRegionHeight;
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0);
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles
if (viewRect.overlaps(tileRect)) {
Rectangle<int> regionRect;
if (_transform != null) {
// apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight);
regionRect = Rectangle<int>.fromPoints(
Point<int>(tl.dx.round(), tl.dy.round()),
Point<int>(br.dx.round(), br.dy.round()),
);
} else {
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
}
tiles.add(RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
sampleSize: sampleSize,
));
}
}
}
}
return Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: displayWidth * scale,
height: displayHeight * scale,
child: widget.baseChild,
),
...tiles,
],
);
});
}
int _sampleSizeForScale(double scale) {
var sample = 0;
if (0 < scale && scale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor);
}
return max<int>(1, sample);
}
}
class RegionTile extends StatefulWidget {
final ImageEntry entry;
// `tileRect` uses Flutter view coordinates
// `regionRect` uses the raw image pixel coordinates
final Rect tileRect;
final Rectangle<int> regionRect;
final int sampleSize;
const RegionTile({
@required this.entry,
@required this.tileRect,
@required this.regionRect,
@required this.sampleSize,
});
@override
_RegionTileState createState() => _RegionTileState();
}
class _RegionTileState extends State<RegionTile> {
RegionProvider _provider;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(RegionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(RegionTile widget) {
_initProvider();
}
void _unregisterWidget(RegionTile widget) {
_pauseProvider();
}
void _initProvider() {
if (!entry.canDecode) return;
_provider = RegionProvider(RegionProviderKey.fromEntry(
entry,
sampleSize: widget.sampleSize,
rect: widget.regionRect,
));
}
void _pauseProvider() => _provider?.pause();
@override
Widget build(BuildContext context) {
final tileRect = widget.tileRect;
Widget child = Image(
image: _provider,
width: tileRect.width,
height: tileRect.height,
fit: BoxFit.fill,
);
// apply EXIF orientation
final quarterTurns = entry.rotationDegrees ~/ 90;
if (entry.isFlipped) {
final rotated = quarterTurns % 2 != 0;
final w = (rotated ? tileRect.height : tileRect.width) / 2.0;
final h = (rotated ? tileRect.width : tileRect.height) / 2.0;
final flipper = Matrix4.identity()
..translate(w, h)
..scale(-1.0, 1.0, 1.0)
..translate(-w, -h);
child = Transform(
transform: flipper,
child: child,
);
}
if (quarterTurns != 0) {
child = RotatedBox(
quarterTurns: quarterTurns,
child: child,
);
}
return Positioned.fromRect(
rect: tileRect,
child: child,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('contentId', widget.entry.contentId));
properties.add(IntProperty('sampleSize', widget.sampleSize));
properties.add(DiagnosticsProperty<Rectangle<int>>('regionRect', widget.regionRect));
}
}

View file

@ -3,6 +3,7 @@ import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart'; import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
@ -10,6 +11,7 @@ import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/settings/access_grants.dart'; import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/svg_background.dart'; import 'package:aves/widgets/settings/svg_background.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
@ -25,24 +27,33 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: DefaultTabController( child: Scaffold(
length: 4, appBar: AppBar(
child: Scaffold( title: Text('Settings'),
appBar: AppBar( ),
title: Text('Settings'), body: SafeArea(
), child: Consumer<Settings>(
body: SafeArea( builder: (context, settings, child) => AnimationLimiter(
child: Consumer<Settings>( child: ListView(
builder: (context, settings, child) => ListView(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
children: [ children: AnimationConfiguration.toStaggeredList(
_buildNavigationSection(context), duration: Durations.staggeredAnimation,
_buildDisplaySection(context), delay: Durations.staggeredAnimationDelay,
_buildThumbnailsSection(context), childAnimationBuilder: (child) => SlideAnimation(
_buildViewerSection(context), verticalOffset: 50.0,
_buildSearchSection(context), child: FadeInAnimation(
_buildPrivacySection(context), child: child,
], ),
),
children: [
_buildNavigationSection(context),
_buildDisplaySection(context),
_buildThumbnailsSection(context),
_buildViewerSection(context),
_buildSearchSection(context),
_buildPrivacySection(context),
],
),
), ),
), ),
), ),
@ -163,6 +174,11 @@ class _SettingsPageState extends State<SettingsPage> {
title: 'Viewer', title: 'Viewer',
expandedNotifier: _expandedNotifier, expandedNotifier: _expandedNotifier,
children: [ children: [
SwitchListTile(
value: settings.showOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text('Show minimap'),
),
SwitchListTile( SwitchListTile(
value: settings.showOverlayShootingDetails, value: settings.showOverlayShootingDetails,
onChanged: (v) => settings.showOverlayShootingDetails = v, onChanged: (v) => settings.showOverlayShootingDetails = v,
@ -201,9 +217,8 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => settings.isCrashlyticsEnabled = v, onChanged: (v) => settings.isCrashlyticsEnabled = v,
title: Text('Allow anonymous analytics and crash reporting'), title: Text('Allow anonymous analytics and crash reporting'),
), ),
Container( Padding(
alignment: AlignmentDirectional.topStart, padding: EdgeInsets.only(top: 8, bottom: 16),
padding: EdgeInsets.only(bottom: 16),
child: GrantedDirectories(), child: GrantedDirectories(),
), ),
], ],

View file

@ -2,6 +2,7 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -50,7 +51,10 @@ class FilterTable extends StatelessWidget {
return TableRow( return TableRow(
children: [ children: [
Container( Container(
padding: EdgeInsets.only(bottom: 8), // the `Table` `border` property paints on the cells and does not add margins,
// so we define margins here instead, but they should be symmetric
// to keep all cells vertically aligned on the center/middle
margin: EdgeInsets.symmetric(vertical: 4),
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip( child: AvesFilterChip(
filter: filter, filter: filter,
@ -65,7 +69,10 @@ class FilterTable extends StatelessWidget {
progressColor: stringToColor(label), progressColor: stringToColor(label),
animation: true, animation: true,
padding: EdgeInsets.symmetric(horizontal: lineHeight), padding: EdgeInsets.symmetric(horizontal: lineHeight),
center: Text(NumberFormat.percentPattern().format(percent)), center: Text(
NumberFormat.percentPattern().format(percent),
style: TextStyle(shadows: [Constants.embossShadow]),
),
), ),
Text( Text(
'$count', '$count',

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
@ -90,7 +91,10 @@ class StatsPage extends StatelessWidget {
leading: Icon(AIcons.location), leading: Icon(AIcons.location),
// right padding to match leading, so that inside label is aligned with outside label below // right padding to match leading, so that inside label is aligned with outside label below
padding: EdgeInsets.symmetric(horizontal: lineHeight) + EdgeInsets.only(right: 24), padding: EdgeInsets.symmetric(horizontal: lineHeight) + EdgeInsets.only(right: 24),
center: Text(NumberFormat.percentPattern().format(withGpsPercent)), center: Text(
NumberFormat.percentPattern().format(withGpsPercent),
style: TextStyle(shadows: [Constants.embossShadow]),
),
), ),
SizedBox(height: 8), SizedBox(height: 8),
Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'), Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'),
@ -257,7 +261,7 @@ class EntryByMimeDatum {
EntryByMimeDatum({ EntryByMimeDatum({
@required this.mimeType, @required this.mimeType,
@required this.entryCount, @required this.entryCount,
}) : displayText = MimeFilter.displayType(mimeType); }) : displayText = MimeTypes.displayType(mimeType);
Color get color => stringToColor(displayText); Color get color => stringToColor(displayText);

View file

@ -3,6 +3,7 @@ import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_logo.dart'; import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/labeled_checkbox.dart'; import 'package:aves/widgets/common/labeled_checkbox.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
@ -24,6 +25,9 @@ class _WelcomePageState extends State<WelcomePage> {
void initState() { void initState() {
super.initState(); super.initState();
_termsLoader = rootBundle.loadString('assets/terms.md'); _termsLoader = rootBundle.loadString('assets/terms.md');
if (!kReleaseMode) {
settings.isCrashlyticsEnabled = false;
}
} }
@override @override
@ -167,9 +171,8 @@ class _WelcomePageState extends State<WelcomePage> {
); );
} }
// workaround to handle `Flexible` widgets, // as of flutter_staggered_animations v0.1.2, `AnimationConfiguration.toStaggeredList` does not handle `Flexible` widgets
// because `AnimationConfiguration.toStaggeredList` does not, // so we use this workaround instead
// as of flutter_staggered_animations v0.1.2,
static List<Widget> _toStaggeredList({ static List<Widget> _toStaggeredList({
Duration duration, Duration duration,
Duration delay, Duration delay,

View file

@ -14,7 +14,7 @@ packages:
name: analyzer name: analyzer
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.40.5" version: "0.40.6"
ansicolor: ansicolor:
dependency: transitive dependency: transitive
description: description:
@ -133,7 +133,7 @@ packages:
name: coverage name: coverage
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.14.1" version: "0.14.2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -169,7 +169,7 @@ packages:
description: description:
path: "." path: "."
ref: HEAD ref: HEAD
resolved-ref: edb6b11bb448fc2f30e566a20605b37093503176 resolved-ref: "51fe2b12588356fade82ce65daef5482beed54e7"
url: "git://github.com/deckerst/expansion_tile_card.git" url: "git://github.com/deckerst/expansion_tile_card.git"
source: git source: git
version: "1.0.3" version: "1.0.3"
@ -207,7 +207,7 @@ packages:
name: firebase_analytics name: firebase_analytics
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.0" version: "6.2.0"
firebase_analytics_platform_interface: firebase_analytics_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -228,7 +228,7 @@ packages:
name: firebase_core name: firebase_core
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.1" version: "0.5.2"
firebase_core_platform_interface: firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -242,21 +242,21 @@ packages:
name: firebase_core_web name: firebase_core_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.0" version: "0.2.1"
firebase_crashlytics: firebase_crashlytics:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_crashlytics name: firebase_crashlytics
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.2" version: "0.2.3"
firebase_crashlytics_platform_interface: firebase_crashlytics_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_crashlytics_platform_interface name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
flushbar: flushbar:
dependency: "direct main" dependency: "direct main"
description: description:
@ -381,7 +381,7 @@ packages:
name: google_maps_flutter name: google_maps_flutter
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.5" version: "1.0.6"
google_maps_flutter_platform_interface: google_maps_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -416,7 +416,7 @@ packages:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.18" version: "2.1.19"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -563,7 +563,7 @@ packages:
name: package_info name: package_info
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.3" version: "0.4.3+2"
palette_generator: palette_generator:
dependency: "direct main" dependency: "direct main"
description: description:
@ -598,7 +598,7 @@ packages:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.6.22" version: "1.6.24"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -612,21 +612,21 @@ packages:
name: path_provider_macos name: path_provider_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.4+4" version: "0.0.4+6"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.3" version: "1.0.4"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
name: path_provider_windows name: path_provider_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.4+1" version: "0.0.4+3"
pdf: pdf:
dependency: "direct main" dependency: "direct main"
description: description:
@ -674,7 +674,7 @@ packages:
description: description:
path: "." path: "."
ref: HEAD ref: HEAD
resolved-ref: "79a3c20ee7f01e6ffb71464000c2ca8f1e28ec44" resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
url: "git://github.com/deckerst/photo_view.git" url: "git://github.com/deckerst/photo_view.git"
source: git source: git
version: "0.9.2" version: "0.9.2"
@ -712,7 +712,7 @@ packages:
name: printing name: printing
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.7.0" version: "3.7.1"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -754,7 +754,7 @@ packages:
name: quiver name: quiver
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.4+1" version: "2.1.5"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -775,21 +775,21 @@ packages:
name: shared_preferences name: shared_preferences
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.12+2" version: "0.5.12+4"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.2+2" version: "0.0.2+4"
shared_preferences_macos: shared_preferences_macos:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_macos name: shared_preferences_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+10" version: "0.0.1+11"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -810,7 +810,7 @@ packages:
name: shared_preferences_windows name: shared_preferences_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+1" version: "0.0.1+3"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@ -990,21 +990,21 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.7.8" version: "5.7.10"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+3" version: "0.0.1+4"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+8" version: "0.0.1+9"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1018,14 +1018,14 @@ packages:
name: url_launcher_web name: url_launcher_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.5" version: "0.1.5+1"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+1" version: "0.0.1+3"
utf: utf:
dependency: transitive dependency: transitive
description: description:
@ -1060,7 +1060,7 @@ packages:
name: vm_service name: vm_service
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.4.0" version: "5.5.0"
vm_service_client: vm_service_client:
dependency: transitive dependency: transitive
description: description:
@ -1095,7 +1095,7 @@ packages:
name: webkit_inspection_protocol name: webkit_inspection_protocol
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.7.3" version: "0.7.4"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View file

@ -15,7 +15,11 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.2.5+31 version: 1.2.6+32
# brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184)
# - TIFF tile decoding is not public (issue #258)
# video_player (as of v0.10.8+2, backed by ExoPlayer): # video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork) # - does not support content URIs (by default, but trivial by fork)
@ -33,6 +37,9 @@ version: 1.2.5+31
# - does not support AC3 (by default, but possible by custom build) # - does not support AC3 (by default, but possible by custom build)
# - can play if only the video or audio stream is supported # - can play if only the video or audio stream is supported
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter

File diff suppressed because one or more lines are too long

1
shaders_1.22.4.sksl.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
import 'package:test/test.dart';
import 'package:aves/utils/time_utils.dart';
void main() {
test('Comparison extension functions', () {
expect(DateTime(1593, 7, 8).isAtSameYearAs(null), false);
expect(DateTime(1903, 9, 25).isAtSameYearAs(DateTime(1970, 2, 25)), false);
expect(DateTime(1929, 3, 22).isAtSameYearAs(DateTime(1929, 3, 22)), true);
expect(DateTime(1593, 7, 8).isAtSameMonthAs(null), false);
expect(DateTime(1903, 9, 25).isAtSameMonthAs(DateTime(1970, 2, 25)), false);
expect(DateTime(1929, 3, 22).isAtSameMonthAs(DateTime(1929, 3, 22)), true);
expect(DateTime(1593, 7, 8).isAtSameDayAs(null), false);
expect(DateTime(1903, 9, 25).isAtSameDayAs(DateTime(1970, 2, 25)), false);
expect(DateTime(1929, 3, 22).isAtSameDayAs(DateTime(1929, 3, 22)), true);
});
}

View file

@ -1 +1,6 @@
Thanks for using Aves! Thanks for using Aves!
v1.2.6:
- subsampling and tiling of large images
- support for TIFF images (single page only)
- optional minimap in viewer overlay
Full changelog available on Github