Merge branch 'develop'
This commit is contained in:
commit
f678587d6f
78 changed files with 2414 additions and 841 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -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
|
||||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -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
41
CHANGELOG.md
Normal 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
|
||||||
|
...
|
|
@ -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'
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>{
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
11
lib/utils/math_utils.dart
Normal 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());
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
@ -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(() {});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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],
|
||||||
),
|
),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
131
lib/widgets/common/image_providers/region_provider.dart
Normal file
131
lib/widgets/common/image_providers/region_provider.dart
Normal 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)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
|
47
lib/widgets/debug/android_env.dart
Normal file
47
lib/widgets/debug/android_env.dart
Normal 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;
|
||||||
|
}
|
107
lib/widgets/debug/app_debug_page.dart
Normal file
107
lib/widgets/debug/app_debug_page.dart
Normal 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}',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
77
lib/widgets/debug/cache.dart
Normal file
77
lib/widgets/debug/cache.dart
Normal 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;
|
||||||
|
}
|
184
lib/widgets/debug/database.dart
Normal file
184
lib/widgets/debug/database.dart
Normal 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;
|
||||||
|
}
|
43
lib/widgets/debug/firebase.dart
Normal file
43
lib/widgets/debug/firebase.dart
Normal 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}',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
39
lib/widgets/debug/overlay.dart
Normal file
39
lib/widgets/debug/overlay.dart
Normal 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(', ')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
40
lib/widgets/debug/settings.dart
Normal file
40
lib/widgets/debug/settings.dart
Normal 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),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
32
lib/widgets/debug/storage.dart
Normal file
32
lib/widgets/debug/storage.dart
Normal 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(),
|
||||||
|
])
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
104
lib/widgets/fullscreen/overlay/minimap.dart
Normal file
104
lib/widgets/fullscreen/overlay/minimap.dart
Normal 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;
|
||||||
|
}
|
|
@ -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;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
279
lib/widgets/fullscreen/tiled_view.dart
Normal file
279
lib/widgets/fullscreen/tiled_view.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
58
pubspec.lock
58
pubspec.lock
|
@ -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:
|
||||||
|
|
|
@ -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
1
shaders_1.22.4.sksl.json
Normal file
File diff suppressed because one or more lines are too long
18
test/utils/time_utils_test.dart
Normal file
18
test/utils/time_utils_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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
|
Loading…
Reference in a new issue