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
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '1.22.3'
|
||||
flutter-version: '1.22.4'
|
||||
|
||||
- name: Clone the repository.
|
||||
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
|
||||
with:
|
||||
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):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_1.22.3.sksl.json
|
||||
flutter build appbundle --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.4.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
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 {
|
||||
maven {
|
||||
url "https://s3.amazonaws.com/repo.commonsware.com"
|
||||
}
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url "https://s3.amazonaws.com/repo.commonsware.com" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -99,6 +98,11 @@ dependencies {
|
|||
implementation 'androidx.exifinterface:exifinterface:1.3.1'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
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'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.1.0'
|
||||
|
|
|
@ -45,7 +45,6 @@
|
|||
</queries>
|
||||
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
|
@ -55,11 +54,16 @@
|
|||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/AppTheme"
|
||||
android:theme="@style/NormalTheme"
|
||||
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
|
||||
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
|
||||
android:value="false" />
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
@ -105,11 +109,11 @@
|
|||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${googleApiKey}" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="firebase_crashlytics_collection_enabled"
|
||||
android:value="false" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
|
|
@ -6,20 +6,19 @@ import android.content.Intent
|
|||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
@ -134,12 +133,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
.submit(size, size)
|
||||
|
||||
try {
|
||||
val bitmap = target.get()
|
||||
if (bitmap != null) {
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
|
||||
data = stream.toByteArray()
|
||||
}
|
||||
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import com.bumptech.glide.Glide
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
|
@ -19,11 +21,14 @@ import kotlin.math.roundToInt
|
|||
class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||
private val density = activity.resources.displayMetrics.density
|
||||
|
||||
private val regionFetcher = RegionFetcher(activity)
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) }
|
||||
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) }
|
||||
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) }
|
||||
"getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) }
|
||||
"clearSizedThumbnailDiskCache" -> {
|
||||
GlobalScope.launch { Glide.get(activity).clearDiskCache() }
|
||||
result.success(null)
|
||||
|
@ -53,13 +58,13 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val widthDip = call.argument<Double>("widthDip")
|
||||
val heightDip = call.argument<Double>("heightDip")
|
||||
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
|
||||
GlobalScope.launch {
|
||||
ThumbnailFetcher(
|
||||
activity,
|
||||
uri,
|
||||
|
@ -70,9 +75,34 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
width = (widthDip * density).roundToInt(),
|
||||
height = (heightDip * density).roundToInt(),
|
||||
defaultSize = (defaultSizeDip * density).roundToInt(),
|
||||
Coresult(result),
|
||||
result,
|
||||
).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) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import android.content.ContentResolver
|
|||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
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.describeAll
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
|
||||
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.MetadataExtractorHelper.getSafeBoolean
|
||||
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.getSafeRational
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
@ -70,6 +73,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(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)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(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) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val path = call.argument<String>("path")
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType))
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, path))
|
||||
if (isVideo(mimeType)) {
|
||||
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
|
||||
}
|
||||
|
@ -196,7 +202,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
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>()
|
||||
|
||||
var foundExif = false
|
||||
|
@ -209,14 +215,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
// File type
|
||||
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
||||
// `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`)
|
||||
// `context.getContentResolver().getType()` sometimes return incorrect value
|
||||
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
||||
// file extension is unreliable
|
||||
// in the end, `metadata-extractor` is the most reliable, unless it reports `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`, `tiff` as `srw`)
|
||||
// * `context.getContentResolver().getType()` sometimes return incorrect value
|
||||
// * `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
||||
// * file extension is unreliable
|
||||
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
|
||||
// in which case we trust the file extension
|
||||
if (path?.matches(tiffExtensionPattern) == true) {
|
||||
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
|
||||
} else {
|
||||
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
|
||||
if (it != MimeTypes.TIFF) {
|
||||
metadataMap[KEY_MIME_TYPE] = it
|
||||
}
|
||||
}
|
||||
|
@ -351,19 +360,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
if (isVideo(mimeType) || !isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (isVideo(mimeType)) {
|
||||
result.success(metadataMap)
|
||||
return
|
||||
}
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
|
||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = "ISO$it" }
|
||||
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) {
|
||||
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
|
||||
|
||||
val saveExposureTime: (value: Rational) -> Unit = {
|
||||
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
|
||||
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
|
||||
// and process it to make sure the numerator is `1` when the ratio value is less than 1
|
||||
val num = it.numerator
|
||||
|
@ -374,16 +377,47 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
else -> it.toString()
|
||||
}
|
||||
}
|
||||
|
||||
var foundExif = false
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
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)
|
||||
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
@ -483,6 +517,77 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
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) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
@ -517,14 +622,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let {
|
||||
val bitmap = TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), it, orientation)
|
||||
if (bitmap != null) {
|
||||
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())
|
||||
exif.thumbnailBitmap?.let { bitmap ->
|
||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||
thumbnails.add(it.getBytes(canHaveAlpha = false, recycle = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
@ -15,14 +15,16 @@ import com.bumptech.glide.request.RequestOptions
|
|||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
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.needRotationAfterContentResolverThumbnail
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
|
||||
class ThumbnailFetcher internal constructor(
|
||||
private val activity: Activity,
|
||||
private val context: Context,
|
||||
uri: String,
|
||||
private val mimeType: String,
|
||||
private val dateModifiedSecs: Long,
|
||||
|
@ -39,59 +41,57 @@ class ThumbnailFetcher internal constructor(
|
|||
|
||||
fun fetch() {
|
||||
var bitmap: Bitmap? = null
|
||||
var recycle = true
|
||||
var exception: Exception? = null
|
||||
|
||||
// fetch low quality thumbnails when size is not specified
|
||||
if ((width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
// as of Android R, the Media Store content resolver may return a thumbnail
|
||||
// that is automatically rotated according to EXIF orientation,
|
||||
// but not flipped when necessary
|
||||
// so we skip this step for flipped entries
|
||||
try {
|
||||
if (mimeType == MimeTypes.TIFF) {
|
||||
bitmap = getTiff()
|
||||
} else if ((width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||
// Fetch low quality thumbnails when size is not specified.
|
||||
// As of Android R, the Media Store content resolver may return a thumbnail
|
||||
// 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()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
exception = e
|
||||
}
|
||||
}
|
||||
|
||||
// fallback if the native methods failed or for higher quality thumbnails
|
||||
if (bitmap == null) {
|
||||
try {
|
||||
bitmap = getByGlide()
|
||||
recycle = false
|
||||
} catch (e: Exception) {
|
||||
exception = e
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
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)
|
||||
result.success(stream.toByteArray())
|
||||
return
|
||||
}
|
||||
|
||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle))
|
||||
} else {
|
||||
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)
|
||||
private fun getByResolver(): Bitmap? {
|
||||
val resolver = activity.contentResolver
|
||||
val resolver = context.contentResolver
|
||||
var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null)
|
||||
if (needRotationAfterContentResolverThumbnail(mimeType)) {
|
||||
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun getByMediaStore(): Bitmap? {
|
||||
val contentId = ContentUris.parseId(uri)
|
||||
val resolver = activity.contentResolver
|
||||
val resolver = context.contentResolver
|
||||
return if (isVideo(mimeType)) {
|
||||
@Suppress("DEPRECATION")
|
||||
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)
|
||||
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
|
||||
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
bitmap
|
||||
}
|
||||
|
@ -115,13 +115,13 @@ class ThumbnailFetcher internal constructor(
|
|||
|
||||
val target = if (isVideo(mimeType)) {
|
||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
Glide.with(activity)
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(VideoThumbnail(activity, uri))
|
||||
.load(VideoThumbnail(context, uri))
|
||||
.submit(width, height)
|
||||
} else {
|
||||
Glide.with(activity)
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(uri)
|
||||
|
@ -131,11 +131,38 @@ class ThumbnailFetcher internal constructor(
|
|||
return try {
|
||||
var bitmap = target.get()
|
||||
if (needRotationAfterGlide(mimeType)) {
|
||||
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
bitmap
|
||||
} 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
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
@ -11,16 +10,17 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||
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.isVideo
|
||||
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.EventSink
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
|
@ -72,6 +72,8 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(uri)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
streamTiffImage(uri)
|
||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
|
||||
|
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
private fun streamImageAsIs(uri: Uri) {
|
||||
try {
|
||||
openInputStream(activity, uri).use { input -> input?.let { streamBytes(it) } }
|
||||
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
|
||||
} catch (e: IOException) {
|
||||
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)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
val stream = ByteArrayOutputStream()
|
||||
// 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())
|
||||
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
|
||||
} else {
|
||||
error("streamImage-image-decode-null", "failed to get image from uri=$uri", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
var errorDetails = e.message
|
||||
if (errorDetails?.isNotEmpty() == true) {
|
||||
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
|
||||
}
|
||||
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", errorDetails)
|
||||
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", toErrorDetails(e))
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
}
|
||||
|
@ -134,11 +124,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
try {
|
||||
val bitmap = target.get()
|
||||
if (bitmap != null) {
|
||||
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, 100, stream)
|
||||
success(stream.toByteArray())
|
||||
success(bitmap.getBytes(canHaveAlpha = false, recycle = false))
|
||||
} else {
|
||||
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) {
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var len: Int
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package deckers.thibault.aves.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import com.bumptech.glide.Glide
|
||||
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.module.LibraryGlideModule
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
@GlideModule
|
||||
|
@ -49,16 +48,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
|
|||
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||
if (retriever != null) {
|
||||
try {
|
||||
var picture = retriever.embeddedPicture
|
||||
if (picture == null) {
|
||||
// not ideal: bitmap -> byte[] -> bitmap
|
||||
// but simple fallback and we cache result
|
||||
val stream = ByteArrayOutputStream()
|
||||
val bitmap = retriever.frameAtTime
|
||||
bitmap?.compress(Bitmap.CompressFormat.PNG, 0, stream)
|
||||
picture = stream.toByteArray()
|
||||
}
|
||||
val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false)
|
||||
if (picture != null) {
|
||||
callback.onDataReady(ByteArrayInputStream(picture))
|
||||
} else {
|
||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onLoadFailed(e)
|
||||
} finally {
|
||||
|
|
|
@ -10,12 +10,15 @@ import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirector
|
|||
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
object ExifInterfaceHelper {
|
||||
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
|
||||
|
||||
private const val precisionErrorTolerance = 1e-10
|
||||
|
||||
// ExifInterface always states it has the following attributes
|
||||
// and returns "0" instead of "null" when they are actually missing
|
||||
private val neverNullTags = listOf(
|
||||
|
@ -279,7 +282,7 @@ object ExifInterfaceHelper {
|
|||
private fun toRational(s: String?): Rational? {
|
||||
s ?: return null
|
||||
|
||||
// convert "12345/100"
|
||||
// e.g. "12345/100" to Rational(12345, 100)
|
||||
val parts = s.split("/")
|
||||
if (parts.size == 2) {
|
||||
val numerator = parts[0].toLongOrNull() ?: return null
|
||||
|
@ -287,9 +290,20 @@ object ExifInterfaceHelper {
|
|||
return Rational(numerator, denominator)
|
||||
}
|
||||
|
||||
// convert "123.45"
|
||||
var d = s.toDoubleOrNull() ?: return null
|
||||
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
|
||||
while (d != floor(d)) {
|
||||
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) {
|
||||
if (this.hasAttribute(tag)) {
|
||||
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long
|
||||
|
|
|
@ -123,7 +123,8 @@ class SourceImageEntry {
|
|||
fillVideoByMediaMetadataRetriever(context)
|
||||
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)
|
||||
if (isSized && foundExif) return this
|
||||
}
|
||||
|
@ -176,7 +177,6 @@ class SourceImageEntry {
|
|||
dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it }
|
||||
}
|
||||
} else {
|
||||
// EXIF, if defined, should override metadata found in other directories
|
||||
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
|
||||
foundExif = true
|
||||
dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it }
|
||||
|
@ -185,7 +185,8 @@ class SourceImageEntry {
|
|||
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it }
|
||||
}
|
||||
|
||||
if (!foundExif) {
|
||||
// dimensions reported in EXIF do not always match the image
|
||||
// so we fetch them from the format directory if available
|
||||
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
|
||||
dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it }
|
||||
dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it }
|
||||
|
@ -196,7 +197,6 @@ class SourceImageEntry {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
|
@ -225,8 +225,9 @@ class SourceImageEntry {
|
|||
private fun fillByBitmapDecode(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
width = options.outWidth
|
||||
height = options.outHeight
|
||||
|
|
|
@ -144,11 +144,14 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
"contentId" to contentId,
|
||||
)
|
||||
|
||||
if ((width <= 0 || height <= 0) && needSize(mimeType)
|
||||
if (MimeTypes.isRaw(mimeType)
|
||||
|| (width <= 0 || height <= 0) && needSize(mimeType)
|
||||
|| durationMillis == 0L && needDuration
|
||||
) {
|
||||
// some images are incorrectly registered in the Media Store,
|
||||
// they are valid but miss some attributes, such as width, height, orientation
|
||||
// Some images are incorrectly registered in the Media Store,
|
||||
// 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)
|
||||
entryMap = entry.toMap()
|
||||
}
|
||||
|
|
|
@ -5,8 +5,22 @@ import android.graphics.Bitmap
|
|||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
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? {
|
||||
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
||||
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
||||
|
|
|
@ -16,7 +16,16 @@ object MimeTypes {
|
|||
const val WEBP = "image/webp"
|
||||
|
||||
// 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 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
|
||||
const val SVG = "image/svg+xml"
|
||||
|
@ -35,6 +44,13 @@ object MimeTypes {
|
|||
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
|
||||
// a raster image format that allows an alpha channel
|
||||
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
|
||||
|
@ -42,7 +58,7 @@ object MimeTypes {
|
|||
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) {
|
||||
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
|
||||
|
@ -71,4 +87,8 @@ object MimeTypes {
|
|||
DNG, PNG -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// extensions
|
||||
|
||||
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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 -->
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
@ -43,7 +43,7 @@ class LocationFilter extends CollectionFilter {
|
|||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||
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
|
||||
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
|
||||
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/image_entry.dart';
|
||||
import 'package:aves/model/mime_types.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -34,7 +35,7 @@ class MimeFilter extends CollectionFilter {
|
|||
_label ??= lowMime.split('/')[0].toUpperCase();
|
||||
} else {
|
||||
_filter = (entry) => entry.mimeType == lowMime;
|
||||
_label = displayType(lowMime);
|
||||
_label = MimeTypes.displayType(lowMime);
|
||||
}
|
||||
_icon ??= AIcons.vector;
|
||||
}
|
||||
|
@ -50,18 +51,6 @@ class MimeFilter extends CollectionFilter {
|
|||
'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
|
||||
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)
|
||||
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 isVideo => mimeType.startsWith('video');
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geocoder/model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateMetadata {
|
||||
final int contentId, dateMillis;
|
||||
|
@ -109,23 +110,29 @@ class CatalogMetadata {
|
|||
class OverlayMetadata {
|
||||
final String aperture, exposureTime, focalLength, iso;
|
||||
|
||||
static final apertureFormat = NumberFormat('0.0', 'en_US');
|
||||
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
|
||||
|
||||
OverlayMetadata({
|
||||
String aperture,
|
||||
this.exposureTime,
|
||||
this.focalLength,
|
||||
this.iso,
|
||||
}) : aperture = aperture.replaceFirst('f', 'ƒ');
|
||||
double aperture,
|
||||
String exposureTime,
|
||||
double focalLength,
|
||||
int iso,
|
||||
}) : 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) {
|
||||
return OverlayMetadata(
|
||||
aperture: map['aperture'] ?? '',
|
||||
exposureTime: map['exposureTime'] ?? '',
|
||||
focalLength: map['focalLength'] ?? '',
|
||||
iso: map['iso'] ?? '',
|
||||
aperture: map['aperture'] as double,
|
||||
exposureTime: map['exposureTime'] as String,
|
||||
focalLength: map['focalLength'] as double,
|
||||
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
|
||||
String toString() {
|
||||
|
|
|
@ -41,5 +41,17 @@ class MimeTypes {
|
|||
|
||||
// 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> 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';
|
||||
|
||||
// viewer
|
||||
static const showOverlayMinimapKey = 'show_overlay_minimap';
|
||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||
|
||||
// info
|
||||
|
@ -159,6 +160,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// viewer
|
||||
|
||||
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
|
||||
|
||||
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
|
||||
|
||||
bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true);
|
||||
|
||||
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
|
@ -113,6 +114,43 @@ class ImageFileService {
|
|||
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(
|
||||
String uri,
|
||||
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 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) {
|
||||
try {
|
||||
|
|
|
@ -43,6 +43,7 @@ class MetadataService {
|
|||
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'path': entry.path,
|
||||
}) as Map;
|
||||
result['contentId'] = entry.contentId;
|
||||
return CatalogMetadata.fromMap(result);
|
||||
|
@ -64,7 +65,7 @@ class MetadataService {
|
|||
if (entry.isSvg) return null;
|
||||
|
||||
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>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
|
@ -76,6 +77,19 @@ class MetadataService {
|
|||
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 {
|
||||
try {
|
||||
// return map with all data available from the content resolver
|
||||
|
@ -92,7 +106,7 @@ class MetadataService {
|
|||
|
||||
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
|
||||
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>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
|
@ -105,7 +119,7 @@ class MetadataService {
|
|||
|
||||
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
|
||||
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>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
|
@ -116,6 +130,19 @@ class MetadataService {
|
|||
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 {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
||||
|
|
|
@ -7,10 +7,13 @@ import 'package:tuple/tuple.dart';
|
|||
final ServicePolicy servicePolicy = ServicePolicy._private();
|
||||
|
||||
class ServicePolicy {
|
||||
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
|
||||
final Map<Object, Tuple2<int, _Task>> _paused = {};
|
||||
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
|
||||
_Task _running;
|
||||
|
||||
Stream<QueueState> get queueStream => _queueStreamController.stream;
|
||||
|
||||
ServicePolicy._private();
|
||||
|
||||
Future<T> call<T>(
|
||||
|
@ -60,6 +63,7 @@ class ServicePolicy {
|
|||
Queue<_Task> _getQueue(int priority) => _queues.putIfAbsent(priority, () => Queue<_Task>());
|
||||
|
||||
void _pickNext() {
|
||||
_notifyQueueState();
|
||||
if (_running != null) return;
|
||||
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
|
||||
_running = queue?.removeFirst();
|
||||
|
@ -90,6 +94,13 @@ class ServicePolicy {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
@ -104,8 +115,15 @@ class CancelledException {}
|
|||
|
||||
class ServiceCallPriority {
|
||||
static const int getFastThumbnail = 100;
|
||||
static const int getRegion = 150;
|
||||
static const int getSizedThumbnail = 200;
|
||||
static const int normal = 500;
|
||||
static const int getMetadata = 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';
|
||||
|
||||
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
|
||||
static const overflowStrutStyle = StrutStyle(height: 1.3);
|
||||
|
||||
|
@ -18,13 +18,20 @@ class Constants {
|
|||
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 int infoGroupMaxValueLength = 140;
|
||||
|
||||
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(
|
||||
name: 'CWAC-Document',
|
||||
license: 'Apache 2.0',
|
||||
|
|
|
@ -27,6 +27,7 @@ class Durations {
|
|||
// fullscreen animations
|
||||
static const fullscreenPageAnimation = Duration(milliseconds: 300);
|
||||
static const fullscreenOverlayAnimation = Duration(milliseconds: 200);
|
||||
static const fullscreenOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||
|
||||
// info
|
||||
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:tuple/tuple.dart';
|
||||
|
||||
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) {
|
||||
// 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 {
|
||||
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());
|
||||
|
||||
|
|
|
@ -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 String _formatDate(DateTime date) {
|
||||
if (date == null) return 'Unknown';
|
||||
if (date.isThisMonth) return 'This month';
|
||||
if (date.isThisYear) return m.format(date);
|
||||
return ym.format(date);
|
||||
|
|
|
@ -80,7 +80,7 @@ class SectionHeader extends StatelessWidget {
|
|||
final para = RenderParagraph(
|
||||
TextSpan(
|
||||
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
|
||||
TextSpan(
|
||||
text: '\u200A' * (hasLeading ? 23 : 1),
|
||||
|
@ -214,7 +214,7 @@ class SectionSelectableLeading extends StatelessWidget {
|
|||
child: IconButton(
|
||||
iconSize: 26,
|
||||
padding: EdgeInsets.only(top: 1),
|
||||
alignment: Alignment.topLeft,
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
icon: Icon(selected ? AIcons.selected : AIcons.unselected),
|
||||
onPressed: onPressed,
|
||||
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:provider/provider.dart';
|
||||
|
||||
// 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
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0
|
||||
// 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
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||
class CollectionListSliver extends StatelessWidget {
|
||||
const CollectionListSliver();
|
||||
|
||||
|
|
|
@ -226,12 +226,15 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
|
|||
Positioned(
|
||||
left: clampedCenter.dx - extent / 2,
|
||||
top: clampedCenter.dy - extent / 2,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(),
|
||||
child: DecoratedThumbnail(
|
||||
entry: widget.imageEntry,
|
||||
extent: extent,
|
||||
showOverlay: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -64,7 +64,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
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';
|
||||
|
||||
class ErrorThumbnail extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final String tooltip;
|
||||
|
||||
const ErrorThumbnail({@required this.extent, @required this.tooltip});
|
||||
const ErrorThumbnail({
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
@required this.tooltip,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -13,10 +19,13 @@ class ErrorThumbnail extends StatelessWidget {
|
|||
child: Tooltip(
|
||||
message: tooltip,
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
AIcons.error,
|
||||
size: extent / 2,
|
||||
child: Text(
|
||||
MimeTypes.displayType(entry.mimeType),
|
||||
style: TextStyle(
|
||||
color: Colors.blueGrey,
|
||||
fontSize: extent / 5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -97,7 +97,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
|||
);
|
||||
child = AnimatedContainer(
|
||||
duration: duration,
|
||||
alignment: Alignment.topRight,
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
color: selected ? Colors.black54 : Colors.transparent,
|
||||
child: child,
|
||||
);
|
||||
|
|
|
@ -71,8 +71,6 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
_pauseProvider();
|
||||
}
|
||||
|
||||
bool get isSupported => entry.canDecode;
|
||||
|
||||
void _initProvider() {
|
||||
if (!entry.canDecode) return;
|
||||
|
||||
|
@ -101,6 +99,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
Widget build(BuildContext context) {
|
||||
if (!entry.canDecode) {
|
||||
return ErrorThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
tooltip: '${entry.mimeType} not supported',
|
||||
);
|
||||
|
@ -139,6 +138,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) => ErrorThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
tooltip: error.toString(),
|
||||
),
|
||||
|
|
|
@ -9,12 +9,13 @@ class AvesExpansionTile extends StatelessWidget {
|
|||
|
||||
const AvesExpansionTile({
|
||||
@required this.title,
|
||||
@required this.children,
|
||||
this.expandedNotifier,
|
||||
@required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = children?.isNotEmpty == true;
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// color used by the `ExpansionTileCard` for selected text and icons
|
||||
|
@ -27,12 +28,17 @@ class AvesExpansionTile extends StatelessWidget {
|
|||
title: HighlightTitle(
|
||||
title,
|
||||
fontSize: 18,
|
||||
enabled: enabled,
|
||||
),
|
||||
expandable: enabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(thickness: 1, height: 1),
|
||||
SizedBox(height: 4),
|
||||
...children,
|
||||
if (enabled) ...children,
|
||||
],
|
||||
),
|
||||
baseColor: Colors.grey[900],
|
||||
expandedColor: Colors.grey[850],
|
||||
),
|
||||
|
|
|
@ -201,7 +201,10 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
if (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped) {
|
||||
chip = Hero(
|
||||
tag: filter,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(),
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
}
|
||||
return chip;
|
||||
|
|
|
@ -5,11 +5,15 @@ import 'package:flutter/material.dart';
|
|||
class HighlightTitle extends StatelessWidget {
|
||||
final String name;
|
||||
final double fontSize;
|
||||
final bool enabled;
|
||||
|
||||
const HighlightTitle(
|
||||
this.name, {
|
||||
this.fontSize = 20,
|
||||
});
|
||||
this.enabled = true,
|
||||
}) : assert(name != null);
|
||||
|
||||
static const disabledColor = Colors.grey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -17,7 +21,7 @@ class HighlightTitle extends StatelessWidget {
|
|||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
decoration: HighlightDecoration(
|
||||
color: stringToColor(name),
|
||||
color: enabled ? stringToColor(name) : disabledColor,
|
||||
),
|
||||
margin: EdgeInsets.symmetric(vertical: 4.0),
|
||||
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 {
|
||||
var uri = key.uri;
|
||||
var mimeType = key.mimeType;
|
||||
final uri = key.uri;
|
||||
final mimeType = key.mimeType;
|
||||
try {
|
||||
final bytes = await ImageFileService.getThumbnail(
|
||||
uri,
|
||||
|
@ -55,7 +55,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
||||
ImageFileService.resumeThumbnail(key);
|
||||
ImageFileService.resumeLoading(key);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
||||
|
@ -105,7 +105,15 @@ class ThumbnailProviderKey {
|
|||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
|
||||
int get hashCode => hashValues(
|
||||
uri,
|
||||
mimeType,
|
||||
dateModifiedSecs,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
extent,
|
||||
scale,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
|
|
@ -73,7 +73,7 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
@override
|
||||
bool operator ==(Object other) {
|
||||
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
|
||||
|
|
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/utils/android_file_utils.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/icons.dart';
|
||||
import 'package:aves/widgets/debug/app_debug_page.dart';
|
||||
import 'package:aves/widgets/drawer/collection_tile.dart';
|
||||
import 'package:aves/widgets/drawer/tile.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
|
|
|
@ -18,7 +18,7 @@ class MetadataTab extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MetadataTabState extends State<MetadataTab> {
|
||||
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
|
||||
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
|
||||
|
||||
// MediaStore timestamp keys
|
||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||
|
@ -33,9 +33,11 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
}
|
||||
|
||||
void _loadMetadata() {
|
||||
_bitmapFactoryLoader = MetadataService.getBitmapFactoryInfo(entry);
|
||||
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
|
||||
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
|
||||
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
|
||||
_metadataExtractorLoader = MetadataService.getMetadataExtractorSummary(entry);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
@ -60,22 +62,27 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
}));
|
||||
return AvesExpansionTile(
|
||||
title: title,
|
||||
children: [
|
||||
Container(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
children: data.isNotEmpty
|
||||
? [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
data,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
),
|
||||
)
|
||||
],
|
||||
]
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: [
|
||||
FutureBuilder<Map>(
|
||||
future: _bitmapFactoryLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _contentResolverMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
|
||||
|
@ -88,6 +95,10 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
future: _mediaMetadataLoader,
|
||||
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/common/action_delegates/entry_action_delegate.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/notifications.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
|
||||
|
@ -52,6 +53,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
EdgeInsets _frozenViewInsets, _frozenViewPadding;
|
||||
EntryActionDelegate _actionDelegate;
|
||||
final List<Tuple2<String, IjkMediaController>> _videoControllers = [];
|
||||
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
|
@ -97,7 +99,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
collection: collection,
|
||||
showInfo: () => _goToVerticalPage(infoPage),
|
||||
);
|
||||
_initVideoController();
|
||||
_initViewStateControllers();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay());
|
||||
|
@ -154,7 +156,11 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
},
|
||||
child: NotificationListener(
|
||||
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;
|
||||
},
|
||||
child: Stack(
|
||||
|
@ -169,6 +175,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
onHorizontalPageChanged: _onHorizontalPageChanged,
|
||||
onImageTap: () => _overlayVisible.value = !_overlayVisible.value,
|
||||
onImagePageRequested: () => _goToVerticalPage(imagePage),
|
||||
onViewDisposed: (uri) => _updateViewState(uri, null),
|
||||
),
|
||||
_buildTopOverlay(),
|
||||
_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() {
|
||||
final child = ValueListenableBuilder<ImageEntry>(
|
||||
valueListenable: _entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
if (entry == null) return SizedBox.shrink();
|
||||
final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
return FullscreenTopOverlay(
|
||||
entry: entry,
|
||||
scale: _topOverlayScale,
|
||||
|
@ -190,6 +203,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
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;
|
||||
_entryNotifier.value = newEntry;
|
||||
_pauseVideoControllers();
|
||||
_initVideoController();
|
||||
_initViewStateControllers();
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
Future<void> _initVideoController() async {
|
||||
void _initViewStateControllers() {
|
||||
final entry = _entryNotifier.value;
|
||||
if (entry == null || !entry.isVideo) return;
|
||||
if (entry == null) return;
|
||||
|
||||
final uri = entry.uri;
|
||||
var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == uri, orElse: () => null);
|
||||
if (controllerEntry != null) {
|
||||
_videoControllers.remove(controllerEntry);
|
||||
} else {
|
||||
// do not set data source of IjkMediaController here
|
||||
controllerEntry = Tuple2(uri, IjkMediaController());
|
||||
}
|
||||
_videoControllers.insert(0, controllerEntry);
|
||||
while (_videoControllers.length > 3) {
|
||||
_videoControllers.removeLast().item2.dispose();
|
||||
_initViewSpecificController<ValueNotifier<ViewState>>(
|
||||
uri,
|
||||
_viewStateNotifiers,
|
||||
() => ValueNotifier<ViewState>(ViewState.zero),
|
||||
(_) => _.dispose(),
|
||||
);
|
||||
if (entry.isVideo) {
|
||||
_initViewSpecificController<IjkMediaController>(
|
||||
uri,
|
||||
_videoControllers,
|
||||
() => IjkMediaController(),
|
||||
(_) => _.dispose(),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -412,6 +443,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
|
|||
final PageController horizontalPager, verticalPager;
|
||||
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
|
||||
final VoidCallback onImageTap, onImagePageRequested;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
|
||||
const FullscreenVerticalPageView({
|
||||
@required this.collection,
|
||||
|
@ -423,6 +455,7 @@ class FullscreenVerticalPageView extends StatefulWidget {
|
|||
@required this.onHorizontalPageChanged,
|
||||
@required this.onImageTap,
|
||||
@required this.onImagePageRequested,
|
||||
@required this.onViewDisposed,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -483,6 +516,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
|||
onTap: widget.onImageTap,
|
||||
onPageChanged: widget.onHorizontalPageChanged,
|
||||
videoControllers: widget.videoControllers,
|
||||
onViewDisposed: widget.onViewDisposed,
|
||||
)
|
||||
: SingleImagePage(
|
||||
entry: entry,
|
||||
|
|
|
@ -10,17 +10,17 @@ class MultiImagePage extends StatefulWidget {
|
|||
final CollectionLens collection;
|
||||
final PageController pageController;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final ValueChanged<PhotoViewScaleState> onScaleChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
|
||||
const MultiImagePage({
|
||||
this.collection,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.onScaleChanged,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
this.onViewDisposed,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -49,9 +49,9 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
key: Key('imageview'),
|
||||
entry: entry,
|
||||
heroTag: widget.collection.heroTag(entry),
|
||||
onScaleChanged: widget.onScaleChanged,
|
||||
onTap: widget.onTap,
|
||||
videoControllers: widget.videoControllers,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -66,13 +66,11 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
|
||||
class SingleImagePage extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final ValueChanged<PhotoViewScaleState> onScaleChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
|
||||
const SingleImagePage({
|
||||
this.entry,
|
||||
this.onScaleChanged,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
});
|
||||
|
@ -90,7 +88,6 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
|
|||
axis: [Axis.vertical],
|
||||
child: ImageView(
|
||||
entry: widget.entry,
|
||||
onScaleChanged: widget.onScaleChanged,
|
||||
onTap: widget.onTap,
|
||||
videoControllers: widget.videoControllers,
|
||||
),
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
|
@ -5,64 +7,111 @@ import 'package:aves/widgets/common/icons.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_picture_provider.dart';
|
||||
import 'package:aves/widgets/fullscreen/tiled_view.dart';
|
||||
import 'package:aves/widgets/fullscreen/video_view.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ImageView extends StatelessWidget {
|
||||
class ImageView extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final Object heroTag;
|
||||
final ValueChanged<PhotoViewScaleState> onScaleChanged;
|
||||
final VoidCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final VoidCallback onDisposed;
|
||||
|
||||
const ImageView({
|
||||
Key key,
|
||||
this.entry,
|
||||
@required this.entry,
|
||||
this.heroTag,
|
||||
this.onScaleChanged,
|
||||
this.onTap,
|
||||
this.videoControllers,
|
||||
@required this.onTap,
|
||||
@required this.videoControllers,
|
||||
this.onDisposed,
|
||||
}) : 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
|
||||
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
|
||||
|
||||
Widget child;
|
||||
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(),
|
||||
);
|
||||
child = _buildVideoView();
|
||||
} else if (entry.isSvg) {
|
||||
child = _buildSvgView();
|
||||
} else if (entry.canDecode) {
|
||||
if (useTile) {
|
||||
child = _buildTiledImageView();
|
||||
} else {
|
||||
child = _buildImageView();
|
||||
}
|
||||
} else {
|
||||
child = _buildError();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
final fastThumbnailProvider = ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
|
||||
// no hero for videos, as a typical video first frame is different from its thumbnail
|
||||
return widget.heroTag != null && !entry.isVideo
|
||||
? Hero(
|
||||
tag: widget.heroTag,
|
||||
transitionOnUserGestures: true,
|
||||
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` + `Fill` so that the transition image
|
||||
// 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) {
|
||||
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
|
||||
|
@ -75,25 +124,7 @@ class ImageView extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget child;
|
||||
if (entry.isSvg) {
|
||||
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
|
||||
child = PhotoView.customChild(
|
||||
child: SvgPicture(
|
||||
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) {
|
||||
Widget _buildImageView() {
|
||||
final uriImage = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
|
@ -101,35 +132,94 @@ class ImageView extends StatelessWidget {
|
|||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
child = PhotoView(
|
||||
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(
|
||||
loadingBuilder: (context, event) => _loadingBuilder(
|
||||
context,
|
||||
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
|
||||
),
|
||||
loadFailedChild: _buildError(),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
scaleStateChangedCallback: onScaleChanged,
|
||||
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,
|
||||
);
|
||||
} else {
|
||||
child = _buildError();
|
||||
}
|
||||
|
||||
return heroTag != null
|
||||
? Hero(
|
||||
tag: heroTag,
|
||||
transitionOnUserGestures: true,
|
||||
child: child,
|
||||
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,
|
||||
)
|
||||
: child;
|
||||
: 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(
|
||||
|
@ -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
|
||||
Widget build(BuildContext context) {
|
||||
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 resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
|
||||
|
||||
|
@ -37,12 +37,12 @@ class BasicSection extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoRowGroup({
|
||||
'Title': entry.bestTitle ?? Constants.unknown,
|
||||
'Title': entry.bestTitle ?? Constants.infoUnknown,
|
||||
'Date': dateText,
|
||||
if (entry.isVideo) ..._buildVideoRows(),
|
||||
if (!entry.isSvg) 'Resolution': resolutionText,
|
||||
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.unknown,
|
||||
'URI': entry.uri ?? Constants.unknown,
|
||||
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown,
|
||||
'URI': entry.uri ?? Constants.infoUnknown,
|
||||
if (entry.path != null) 'Path': entry.path,
|
||||
}),
|
||||
_buildChips(),
|
||||
|
|
|
@ -1,17 +1,8 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/math_utils.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) {
|
||||
var mSemiMajorAxis = 6378137.0; //WGS84 major axis
|
||||
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/durations.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/fullscreen/info/common.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) {
|
||||
if (dir.name == xmpDirectory) {
|
||||
return _buildXmpDirTile(dir);
|
||||
}
|
||||
Widget thumbnail;
|
||||
final prefixChildren = <Widget>[];
|
||||
switch (dir.name) {
|
||||
case exifThumbnailDirectory:
|
||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
|
||||
break;
|
||||
case xmpDirectory:
|
||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
|
||||
break;
|
||||
case mediaDirectory:
|
||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
|
||||
Widget builder(IconData data) => Padding(
|
||||
|
@ -153,14 +154,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
title: dir.name,
|
||||
expandedNotifier: _expandedDirectoryNotifier,
|
||||
children: [
|
||||
if (prefixChildren.isNotEmpty)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: Wrap(children: prefixChildren),
|
||||
),
|
||||
if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren),
|
||||
if (thumbnail != null) thumbnail,
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
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() {
|
||||
_loadedMetadataUri.value = null;
|
||||
_metadata = [];
|
||||
|
@ -196,6 +228,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
_metadata = [];
|
||||
_loadedMetadataUri.value = null;
|
||||
}
|
||||
_expandedDirectoryNotifier.value = null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/model/settings/coordinate_format.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/metadata_service.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/icons.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/common.dart';
|
||||
|
@ -150,25 +151,21 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
|
|||
final positionTitle = [
|
||||
if (position != null) position,
|
||||
if (entry.bestTitle != null) entry.bestTitle,
|
||||
].join(' – ');
|
||||
].join(' • ');
|
||||
final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
|
||||
if (entry.hasGps)
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: _LocationRow(entry: entry),
|
||||
),
|
||||
_buildSoloLocationRow(),
|
||||
if (twoColumns)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
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,
|
||||
child: _DateRow(entry),
|
||||
),
|
||||
if (hasShootingDetails)
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
width: subRowWidth,
|
||||
child: _ShootingRow(details),
|
||||
),
|
||||
_buildSoloShootingRow(subRowWidth, hasShootingDetails),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -228,7 +272,7 @@ class _DateRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
children: [
|
||||
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
|
@ -251,10 +295,10 @@ class _ShootingRow extends StatelessWidget {
|
|||
children: [
|
||||
DecoratedIcon(AIcons.shooting, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.focalLength, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.iso, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.aperture ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.focalLength ?? Constants.overlayUnknown, 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/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/common/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/fx/sweeper.dart';
|
||||
import 'package:aves/widgets/common/icons.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/minimap.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -18,6 +21,7 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
final EdgeInsets viewInsets, viewPadding;
|
||||
final Function(EntryAction value) onActionSelected;
|
||||
final bool canToggleFavourite;
|
||||
final ValueNotifier<ViewState> viewStateNotifier;
|
||||
|
||||
static const double padding = 8;
|
||||
|
||||
|
@ -33,6 +37,7 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
@required this.viewInsets,
|
||||
@required this.viewPadding,
|
||||
@required this.onActionSelected,
|
||||
this.viewStateNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -58,8 +63,7 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
].where(_canDo).take(quickActionCount).toList();
|
||||
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_canDo).toList();
|
||||
final externalAppActions = EntryActions.externalApp.where(_canDo).toList();
|
||||
|
||||
return _TopOverlayRow(
|
||||
final buttonRow = _TopOverlayRow(
|
||||
quickActions: quickActions,
|
||||
inAppActions: inAppActions,
|
||||
externalAppActions: externalAppActions,
|
||||
|
@ -67,6 +71,23 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
entry: entry,
|
||||
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/settings.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_selection_dialog.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/svg_background.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
|
@ -25,16 +27,24 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: DefaultTabController(
|
||||
length: 4,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Settings'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Consumer<Settings>(
|
||||
builder: (context, settings, child) => ListView(
|
||||
builder: (context, settings, child) => AnimationLimiter(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: AnimationConfiguration.toStaggeredList(
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
childAnimationBuilder: (child) => SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_buildNavigationSection(context),
|
||||
_buildDisplaySection(context),
|
||||
|
@ -48,6 +58,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -163,6 +174,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
title: 'Viewer',
|
||||
expandedNotifier: _expandedNotifier,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayMinimap,
|
||||
onChanged: (v) => settings.showOverlayMinimap = v,
|
||||
title: Text('Show minimap'),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: settings.showOverlayShootingDetails,
|
||||
onChanged: (v) => settings.showOverlayShootingDetails = v,
|
||||
|
@ -201,9 +217,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
onChanged: (v) => settings.isCrashlyticsEnabled = v,
|
||||
title: Text('Allow anonymous analytics and crash reporting'),
|
||||
),
|
||||
Container(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 8, bottom: 16),
|
||||
child: GrantedDirectories(),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.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/common/aves_filter_chip.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -50,7 +51,10 @@ class FilterTable extends StatelessWidget {
|
|||
return TableRow(
|
||||
children: [
|
||||
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,
|
||||
child: AvesFilterChip(
|
||||
filter: filter,
|
||||
|
@ -65,7 +69,10 @@ class FilterTable extends StatelessWidget {
|
|||
progressColor: stringToColor(label),
|
||||
animation: true,
|
||||
padding: EdgeInsets.symmetric(horizontal: lineHeight),
|
||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||
center: Text(
|
||||
NumberFormat.percentPattern().format(percent),
|
||||
style: TextStyle(shadows: [Constants.embossShadow]),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count',
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/filters/tag.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/source/collection_lens.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
|
@ -90,7 +91,10 @@ class StatsPage extends StatelessWidget {
|
|||
leading: Icon(AIcons.location),
|
||||
// right padding to match leading, so that inside label is aligned with outside label below
|
||||
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),
|
||||
Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'),
|
||||
|
@ -257,7 +261,7 @@ class EntryByMimeDatum {
|
|||
EntryByMimeDatum({
|
||||
@required this.mimeType,
|
||||
@required this.entryCount,
|
||||
}) : displayText = MimeFilter.displayType(mimeType);
|
||||
}) : displayText = MimeTypes.displayType(mimeType);
|
||||
|
||||
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/labeled_checkbox.dart';
|
||||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
|
@ -24,6 +25,9 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_termsLoader = rootBundle.loadString('assets/terms.md');
|
||||
if (!kReleaseMode) {
|
||||
settings.isCrashlyticsEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -167,9 +171,8 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
);
|
||||
}
|
||||
|
||||
// workaround to handle `Flexible` widgets,
|
||||
// because `AnimationConfiguration.toStaggeredList` does not,
|
||||
// as of flutter_staggered_animations v0.1.2,
|
||||
// as of flutter_staggered_animations v0.1.2, `AnimationConfiguration.toStaggeredList` does not handle `Flexible` widgets
|
||||
// so we use this workaround instead
|
||||
static List<Widget> _toStaggeredList({
|
||||
Duration duration,
|
||||
Duration delay,
|
||||
|
|
58
pubspec.lock
58
pubspec.lock
|
@ -14,7 +14,7 @@ packages:
|
|||
name: analyzer
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.40.5"
|
||||
version: "0.40.6"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -133,7 +133,7 @@ packages:
|
|||
name: coverage
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.14.1"
|
||||
version: "0.14.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -169,7 +169,7 @@ packages:
|
|||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: edb6b11bb448fc2f30e566a20605b37093503176
|
||||
resolved-ref: "51fe2b12588356fade82ce65daef5482beed54e7"
|
||||
url: "git://github.com/deckerst/expansion_tile_card.git"
|
||||
source: git
|
||||
version: "1.0.3"
|
||||
|
@ -207,7 +207,7 @@ packages:
|
|||
name: firebase_analytics
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "6.2.0"
|
||||
firebase_analytics_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -228,7 +228,7 @@ packages:
|
|||
name: firebase_core
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
version: "0.5.2"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -242,21 +242,21 @@ packages:
|
|||
name: firebase_core_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.2.1"
|
||||
firebase_crashlytics:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_crashlytics
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
version: "0.2.3"
|
||||
firebase_crashlytics_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_crashlytics_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.1.3"
|
||||
flushbar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -381,7 +381,7 @@ packages:
|
|||
name: google_maps_flutter
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
version: "1.0.6"
|
||||
google_maps_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -416,7 +416,7 @@ packages:
|
|||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.18"
|
||||
version: "2.1.19"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -563,7 +563,7 @@ packages:
|
|||
name: package_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.3"
|
||||
version: "0.4.3+2"
|
||||
palette_generator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -598,7 +598,7 @@ packages:
|
|||
name: path_provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.22"
|
||||
version: "1.6.24"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -612,21 +612,21 @@ packages:
|
|||
name: path_provider_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+4"
|
||||
version: "0.0.4+6"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.0.4"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+1"
|
||||
version: "0.0.4+3"
|
||||
pdf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -674,7 +674,7 @@ packages:
|
|||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "79a3c20ee7f01e6ffb71464000c2ca8f1e28ec44"
|
||||
resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
|
||||
url: "git://github.com/deckerst/photo_view.git"
|
||||
source: git
|
||||
version: "0.9.2"
|
||||
|
@ -712,7 +712,7 @@ packages:
|
|||
name: printing
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.7.0"
|
||||
version: "3.7.1"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -754,7 +754,7 @@ packages:
|
|||
name: quiver
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.4+1"
|
||||
version: "2.1.5"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -775,21 +775,21 @@ packages:
|
|||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.12+2"
|
||||
version: "0.5.12+4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.2+2"
|
||||
version: "0.0.2+4"
|
||||
shared_preferences_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+10"
|
||||
version: "0.0.1+11"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -810,7 +810,7 @@ packages:
|
|||
name: shared_preferences_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+1"
|
||||
version: "0.0.1+3"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -990,21 +990,21 @@ packages:
|
|||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.7.8"
|
||||
version: "5.7.10"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+3"
|
||||
version: "0.0.1+4"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+8"
|
||||
version: "0.0.1+9"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1018,14 +1018,14 @@ packages:
|
|||
name: url_launcher_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.5"
|
||||
version: "0.1.5+1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+1"
|
||||
version: "0.0.1+3"
|
||||
utf:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1060,7 +1060,7 @@ packages:
|
|||
name: vm_service
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.4.0"
|
||||
version: "5.5.0"
|
||||
vm_service_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1095,7 +1095,7 @@ packages:
|
|||
name: webkit_inspection_protocol
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.7.3"
|
||||
version: "0.7.4"
|
||||
win32:
|
||||
dependency: transitive
|
||||
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.
|
||||
# Read more about iOS versioning at
|
||||
# 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):
|
||||
# - 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)
|
||||
# - can play if only the video or audio stream is supported
|
||||
|
||||
environment:
|
||||
sdk: ">=2.7.0 <3.0.0"
|
||||
|
||||
dependencies:
|
||||
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!
|
||||
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