Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-07-22 17:11:13 +09:00
commit 55e4710545
128 changed files with 3306 additions and 1816 deletions

View file

@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [v1.4.6] - 2021-07-22
### Added
- Albums / Countries / Tags: multiple selection
- Albums: action to create empty albums
- Collection: burst shot grouping (Samsung naming pattern)
- Collection: support motion photos defined by XMP Container namespace
- Settings: hidden paths to exclude folders and their subfolders
- Settings: option to disable viewer overlay blur effect (for older/slower devices)
- Settings: option to exclude cutout area in viewer
### Changed
- Video: restored overlay hiding when pressing play button
### Fixed
- Viewer: fixed manual screen rotation to follow sensor
## [v1.4.5] - 2021-07-08
### Added
- Video: added OGV/Theora/Vorbis support

View file

@ -105,7 +105,7 @@ repositories {
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.caverock:androidsvg-aar:1.4'

View file

@ -36,6 +36,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
@ -114,7 +115,7 @@ class MainActivity : FlutterActivity() {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
}
}
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST -> {
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> {
onPermissionResult(requestCode, data?.data)
}
}
@ -196,6 +197,7 @@ class MainActivity : FlutterActivity() {
const val DELETE_PERMISSION_REQUEST = 2
const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5
// permission request code to pending runnable
val pendingResultHandlers = ConcurrentHashMap<Int, PendingResultHandler>()

View file

@ -0,0 +1,27 @@
package deckers.thibault.aves.channel.calls
import android.os.Build
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class DeviceHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPerformanceClass" -> result.success(getPerformanceClass())
else -> result.notImplemented()
}
}
private fun getPerformanceClass(): Int {
// TODO TLAD uncomment when the future is here
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// return Build.VERSION.MEDIA_PERFORMANCE_CLASS
// }
return Build.VERSION.SDK_INT
}
companion object {
const val CHANNEL = "deckers.thibault/aves/device"
}
}

View file

@ -17,6 +17,7 @@ import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ContentImageProvider
import deckers.thibault.aves.model.provider.ImageProvider
@ -157,18 +158,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
// which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try {
val pathParts = dataPropPath.split('/')
val embedBytes: ByteArray = if (pathParts.size == 1) {
val propName = pathParts[0]
val propNs = XMP.namespaceForPropPath(propName)
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
val propNs = XMP.namespaceForPropPath(dataPropPath)
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first()
} else {
val structName = pathParts[0]
val structNs = XMP.namespaceForPropPath(structName)
val fieldName = pathParts[1]
val fieldNs = XMP.namespaceForPropPath(fieldName)
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let {
XMPUtils.decodeBase64(it.value)
}
}

View file

@ -184,12 +184,33 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
metadataMap.remove(dirName)
}
SonyVideoMetadata.USMT_UUID -> {
QuickTimeMetadata.PROF_UUID -> {
// redundant with info derived on the Dart side
metadataMap.remove(dirName)
}
QuickTimeMetadata.USMT_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
val fields = SonyVideoMetadata.parseUsmt(bytes)
if (fields.isNotEmpty()) {
dirMap.remove("Data")
dirMap.putAll(fields)
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
if (blocks.isNotEmpty()) {
metadataMap.remove(dirName)
dirName = "QuickTime User Media"
val usmt = metadataMap[dirName] ?: HashMap()
metadataMap[dirName] = usmt
blocks.forEach {
var key = it.type
var value = it.value
val language = it.language
var i = 0
while (usmt.containsKey(key)) {
key = it.type + " (${++i})"
}
if (language != "und") {
value += " ($language)"
}
usmt[key] = value
}
}
}
}

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.WindowManager
@ -16,6 +17,8 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
"isRotationLocked" -> safe(call, result, ::isRotationLocked)
"requestOrientation" -> safe(call, result, ::requestOrientation)
"canSetCutoutMode" -> result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
"setCutoutMode" -> safe(call, result, ::setCutoutMode)
else -> result.notImplemented()
}
}
@ -57,6 +60,24 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
result.success(true)
}
private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
val use = call.argument<Boolean>("use")
if (use == null) {
result.error("setCutoutMode-args", "failed because of missing arguments", null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val mode = if (use) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
activity.window.attributes.layoutInDisplayCutoutMode = mode
}
result.success(true)
}
companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
const val CHANNEL = "deckers.thibault/aves/window"

View file

@ -10,6 +10,7 @@ import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingResultHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
@ -41,6 +42,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestVolumeAccess" -> requestVolumeAccess()
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
else -> endOfStream()
}
}
@ -128,6 +130,24 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
}
private fun selectDirectory() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
MainActivity.pendingResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingResultHandler(null, { uri ->
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
endOfStream()
}, {
success(null)
endOfStream()
})
activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
} else {
success(null)
endOfStream()
}
}
override fun onCancel(arguments: Any?) {}
private fun success(result: Any?) {

View file

@ -24,7 +24,7 @@ object Metadata {
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
private val VIDEO_DATE_SUBSECOND_PATTERN = Pattern.compile("(\\d{6})(\\.\\d+)")
private val VIDEO_TIMEZONE_PATTERN = Pattern.compile("(Z|[+-]\\d{4})$")
private val VIDEO_TIME_ZONE_PATTERN = Pattern.compile("(Z|[+-]\\d{4})$")
// directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor
@ -71,7 +71,7 @@ object Metadata {
// optional time zone
var timeZone: TimeZone? = null
val timeZoneMatcher = VIDEO_TIMEZONE_PATTERN.matcher(dateString)
val timeZoneMatcher = VIDEO_TIME_ZONE_PATTERN.matcher(dateString)
if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z", "")}")
dateString = timeZoneMatcher.replaceAll("")

View file

@ -10,6 +10,7 @@ import android.util.Log
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.XMP.getSafeLong
import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
@ -140,7 +141,23 @@ object MultiPage {
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
var offsetFromEnd: Long? = null
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
val xmpMeta = dir.xmpMeta
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
// GCamera motion photo
xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
} else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
// Container motion photo
val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)
if (count == 2) {
// expect the video to be the second item
val i = 2
val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value
val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value
if (MimeTypes.isVideo(mime) && length != null) {
offsetFromEnd = length.toLong()
}
}
}
return offsetFromEnd
}
}

View file

@ -0,0 +1,99 @@
package deckers.thibault.aves.metadata
import java.math.BigInteger
import java.nio.charset.Charset
import java.util.*
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
object QuickTimeMetadata {
// QuickTime Profile Tags
// cf https://exiftool.org/TagNames/QuickTime.html#Profile
const val PROF_UUID = "50524f46-21d2-4fce-bb88-695cfac9c740"
// QuickTime UserMedia Tags
// cf https://exiftool.org/TagNames/QuickTime.html#UserMedia
const val USMT_UUID = "55534d54-21d2-4fce-bb88-695cfac9c740"
private const val METADATA_BOX_ID = "MTDT"
fun parseUuidUsmt(data: ByteArray): List<QuickTimeMetadataBlock> {
val blocks = ArrayList<QuickTimeMetadataBlock>()
val boxHeader = BoxHeader(data)
if (boxHeader.boxType == METADATA_BOX_ID) {
blocks.addAll(parseQuicktimeMtdtBox(boxHeader, data))
}
return blocks
}
private fun parseQuicktimeMtdtBox(boxHeader: BoxHeader, data: ByteArray): List<QuickTimeMetadataBlock> {
val blocks = ArrayList<QuickTimeMetadataBlock>()
var bytes = data
val blockCount = BigInteger(bytes.copyOfRange(8, 10)).toInt()
bytes = bytes.copyOfRange(10, boxHeader.boxDataSize)
for (i in 0 until blockCount) {
val blockSize = BigInteger(bytes.copyOfRange(0, 2)).toInt()
val blockType = BigInteger(bytes.copyOfRange(2, 6)).toInt()
val language = parseLanguage(bytes.copyOfRange(6, 8))
val encoding = BigInteger(bytes.copyOfRange(8, 10)).toInt()
val payload = bytes.copyOfRange(10, blockSize)
val payloadString = when (encoding) {
// 0x00: short array
0x00 -> {
payload
.asList()
.chunked(2)
.map { (h, l) -> ((h.toInt() shl 8) + l.toInt()).toShort() }
.joinToString()
}
// 0x01: string
0x01 -> String(payload, Charset.forName("UTF-16BE")).trim()
// 0x101: artwork/icon
else -> "0x${payload.joinToString("") { "%02x".format(it) }}"
}
val blockTypeString = when (blockType) {
0x01 -> "Title"
0x03 -> "Creation Time"
0x04 -> "Software"
0x0A -> "Track property"
0x0B -> "Time zone"
0x0C -> "Modification Time"
else -> "0x${"%02x".format(blockType)}"
}
blocks.add(
QuickTimeMetadataBlock(
type = blockTypeString,
value = payloadString,
language = language,
)
)
bytes = bytes.copyOfRange(blockSize, bytes.size)
}
return blocks
}
// ISO 639 language code written as 3 groups of 5 bits for each letter (ascii code - 0x60)
// e.g. 0x55c4 -> 10101 01110 00100 -> 21 14 4 -> "und"
private fun parseLanguage(bytes: ByteArray): String {
val i = BigInteger(bytes).toInt()
val c1 = Character.toChars((i shr 10 and 0x1F) + 0x60)[0]
val c2 = Character.toChars((i shr 5 and 0x1F) + 0x60)[0]
val c3 = Character.toChars((i and 0x1F) + 0x60)[0]
return "$c1$c2$c3"
}
}
class BoxHeader(bytes: ByteArray) {
var boxDataSize: Int = 0
var boxType: String
init {
boxDataSize = BigInteger(bytes.copyOfRange(0, 4)).toInt()
boxType = String(bytes.copyOfRange(4, 8))
}
}

View file

@ -1,73 +0,0 @@
package deckers.thibault.aves.metadata
import java.math.BigInteger
import java.nio.charset.Charset
import java.util.*
object SonyVideoMetadata {
const val PROF_UUID = "50524f46-21d2-4fce-bb88-695cfac9c740"
const val USMT_UUID = "55534d54-21d2-4fce-bb88-695cfac9c740"
fun parseUsmt(data: ByteArray): HashMap<String, String> {
val dirMap = HashMap<String, String>()
var bytes = data
var size = BigInteger(bytes.copyOfRange(0, 4)).toInt()
val box = String(bytes.copyOfRange(4, 8))
if (box == "MTDT") {
val blockCount = BigInteger(bytes.copyOfRange(8, 10)).toInt()
bytes = bytes.copyOfRange(10, size)
size -= 10
for (i in 0 until blockCount) {
// cf https://github.com/sonyxperiadev/MultimediaForAndroidLibrary
// cf https://rubenlaguna.com/post/2007-02-25-how-to-read-title-in-sony-psp-mp4-files/
val blockSize = BigInteger(bytes.copyOfRange(0, 2)).toInt()
val blockType = BigInteger(bytes.copyOfRange(2, 6)).toInt()
// ISO 639 language code written as 3 groups of 5 bits for each letter (ascii code - 0x60)
// e.g. 0x55c4 -> 10101 01110 00100 -> 21 14 4 -> "und"
val language = BigInteger(bytes.copyOfRange(6, 8)).toInt()
val c1 = Character.toChars((language shr 10 and 0x1F) + 0x60)[0]
val c2 = Character.toChars((language shr 5 and 0x1F) + 0x60)[0]
val c3 = Character.toChars((language and 0x1F) + 0x60)[0]
val languageString = "$c1$c2$c3"
val encoding = BigInteger(bytes.copyOfRange(8, 10)).toInt()
val payload = bytes.copyOfRange(10, blockSize)
val payloadString = when (encoding) {
// 0x00: short array
0x00 -> {
payload
.asList()
.chunked(2)
.map { (h, l) -> ((h.toInt() shl 8) + l.toInt()).toShort() }
.joinToString()
}
// 0x01: string
0x01 -> String(payload, Charset.forName("UTF-16BE")).trim()
// 0x101: artwork/icon
else -> "0x${payload.joinToString("") { "%02x".format(it) }}"
}
val blockTypeString = when (blockType) {
0x01 -> "Title"
0x03 -> "Timestamp"
0x04 -> "Creator name"
0x0A -> "End of track"
else -> "0x${"%02x".format(blockType)}"
}
val prefix = if (blockCount > 1) "$i/" else ""
dirMap["${prefix}Data"] = payloadString
dirMap["${prefix}Language"] = languageString
dirMap["${prefix}Type"] = blockTypeString
bytes = bytes.copyOfRange(blockSize, bytes.size)
}
}
return dirMap
}
}

View file

@ -4,7 +4,9 @@ import android.util.Log
import com.adobe.internal.xmp.XMPError
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.properties.XMPProperty
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import java.util.*
object XMP {
@ -15,6 +17,15 @@ object XMP {
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
// other namespaces
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
const val SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title"
@ -26,11 +37,13 @@ object XMP {
private const val SPECIFIC_LANG = "en-US"
private val schemas = hashMapOf(
"GAudio" to "http://ns.google.com/photos/1.0/audio/",
"GDepth" to "http://ns.google.com/photos/1.0/depthmap/",
"GImage" to "http://ns.google.com/photos/1.0/image/",
"Container" to CONTAINER_SCHEMA_NS,
"GAudio" to GAUDIO_SCHEMA_NS,
"GDepth" to GDEPTH_SCHEMA_NS,
"GImage" to GIMAGE_SCHEMA_NS,
"Item" to CONTAINER_ITEM_SCHEMA_NS,
"xmp" to XMP_SCHEMA_NS,
"xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/",
"xmpGImg" to XMP_GIMG_SCHEMA_NS,
)
fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
@ -44,9 +57,11 @@ object XMP {
// motion photo
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory"
const val CONTAINER_ITEM_PROP_NAME = "Container:Item"
const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length"
const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime"
// panorama
// cf https://developers.google.com/streetview/spherical-metadata
@ -79,7 +94,26 @@ object XMP {
fun XMPMeta.isMotionPhoto(): Boolean {
try {
return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)
// GCamera motion photo
if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
// Container motion photo
if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) {
val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)
if (count == 2) {
var hasImage = false
var hasVideo = false
for (i in 1 until count + 1) {
val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value
val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
}
if (hasImage && hasVideo) return true
}
}
return false
} catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property
@ -188,4 +222,21 @@ object XMP {
Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e)
}
}
// e.g. 'Container:Directory[42]/Container:Item/Item:Mime'
fun XMPMeta.getSafeStructField(path: String): XMPProperty? {
val separator = path.lastIndexOf("/")
if (separator != -1) {
val structName = path.substring(0, separator)
val structNs = namespaceForPropPath(structName)
val fieldName = path.substring(separator + 1)
val fieldNs = namespaceForPropPath(fieldName)
try {
return getStructField(structNs, structName, fieldNs, fieldName)
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
}
}
return null
}
}

View file

@ -133,8 +133,7 @@ object PermissionManager {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags)
releaseUriPermission(context, it)
true
} ?: false
}
@ -158,4 +157,28 @@ object PermissionManager {
}
return accessibleDirs
}
// As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
// URI permissions we hold points to a folder that no longer exists,
// so we should remove these obsolete URIs before proceeding.
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun sanitizePersistedUriPermissions(context: Context) {
try {
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val uri = uriPermission.uri
val path = StorageUtils.convertTreeUriToDirPath(context, uri)
if (path != null && !File(path).exists()) {
Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path")
releaseUriPermission(context, uri)
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to sanitize persisted URI permissions", e)
}
}
private fun releaseUriPermission(context: Context, it: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags)
}
}

View file

@ -293,6 +293,7 @@ object StorageUtils {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
// cleanest API to get it
PermissionManager.sanitizePersistedUriPermissions(context)
try {
val docUri = MediaStore.getDocumentUri(context, mediaUri)
if (docUri != null) {

View file

@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.1'
classpath 'com.android.tools.build:gradle:4.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.8'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

View file

@ -59,6 +59,8 @@
"@chipActionRename": {},
"chipActionSetCover": "Set cover",
"@chipActionSetCover": {},
"chipActionCreateAlbum": "Create album",
"@chipActionCreateAlbum": {},
"entryActionDelete": "Delete",
"@entryActionDelete": {},
@ -80,6 +82,8 @@
"@entryActionShare": {},
"entryActionViewSource": "View source",
"@entryActionViewSource": {},
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
"@entryActionViewMotionPhotoVideo": {},
"entryActionEdit": "Edit with…",
"@entryActionEdit": {},
"entryActionOpen": "Open with…",
@ -165,6 +169,8 @@
"keepScreenOnAlways": "Always",
"@keepScreenOnAlways": {},
"albumTierNew": "New",
"@albumTierNew": {},
"albumTierPinned": "Pinned",
"@albumTierPinned": {},
"albumTierSpecial": "Common",
@ -272,8 +278,14 @@
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
"deleteAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
"@deleteAlbumConfirmationDialogMessage": {
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
"@deleteSingleAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete these albums and their item?} other{Are you sure you want to delete these albums and their {count} items?}}",
"@deleteMultiAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
@ -509,6 +521,8 @@
"@createAlbumTooltip": {},
"createAlbumButtonLabel": "CREATE",
"@createAlbumButtonLabel": {},
"newFilterBanner": "new",
"@newFilterBanner": {},
"countryPageTitle": "Countries",
"@countryPageTitle": {},
@ -575,6 +589,10 @@
"@settingsViewerShowInformationSubtitle": {},
"settingsViewerShowShootingDetails": "Show shooting details",
"@settingsViewerShowShootingDetails": {},
"settingsViewerEnableOverlayBlurEffect": "Overlay blur effect",
"@settingsViewerEnableOverlayBlurEffect": {},
"settingsViewerUseCutout": "Use cutout area",
"@settingsViewerUseCutout": {},
"settingsViewerQuickActionsTile": "Quick actions",
"@settingsViewerQuickActionsTile": {},
@ -630,11 +648,11 @@
"@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Background opacity",
"@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentLeft": "Left",
"settingsSubtitleThemeTextAlignmentLeft": "Left",
"@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Center",
"@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsSubtitleThemeTextAlignmentRight": "Right",
"settingsSubtitleThemeTextAlignmentRight": "Right",
"@settingsSubtitleThemeTextAlignmentRight": {},
"settingsSectionPrivacy": "Privacy",
@ -653,6 +671,19 @@
"settingsHiddenFiltersEmpty": "No hidden filters",
"@settingsHiddenFiltersEmpty": {},
"settingsHiddenPathsTile": "Hidden paths",
"@settingsHiddenPathsTile": {},
"settingsHiddenPathsTitle": "Hidden Paths",
"@settingsHiddenPathsTitle": {},
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
"@settingsHiddenPathsBanner": {},
"settingsHiddenPathsEmpty": "No hidden paths",
"@settingsHiddenPathsEmpty": {},
"settingsHiddenPathsRemoveTooltip": "Remove",
"@settingsHiddenPathsRemoveTooltip": {},
"addPathTooltip": "Add path",
"@addPathTooltip": {},
"settingsStorageAccessTile": "Storage access",
"@settingsStorageAccessTile": {},
"settingsStorageAccessTitle": "Storage Access",

View file

@ -29,6 +29,7 @@
"chipActionUnpin": "고정 해제",
"chipActionRename": "이름 변경",
"chipActionSetCover": "대표 이미지 변경",
"chipActionCreateAlbum": "앨범 만들기",
"entryActionDelete": "삭제",
"entryActionExport": "내보내기",
@ -40,6 +41,7 @@
"entryActionPrint": "인쇄",
"entryActionShare": "공유",
"entryActionViewSource": "소스 코드 보기",
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
"entryActionEdit": "편집…",
"entryActionOpen": "다른 앱에서 열기…",
"entryActionSetAs": "다음 용도로 사용…",
@ -86,6 +88,7 @@
"keepScreenOnViewerOnly": "뷰어 이용 시 작동",
"keepScreenOnAlways": "항상 켜짐",
"albumTierNew": "신규",
"albumTierPinned": "고정",
"albumTierSpecial": "기본",
"albumTierApps": "앱",
@ -124,7 +127,8 @@
"renameAlbumDialogLabel": "앨범 이름",
"renameAlbumDialogLabelAlreadyExistsHelper": "사용 중인 이름입니다",
"deleteAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
"renameEntryDialogLabel": "이름",
@ -231,8 +235,9 @@
"albumPageTitle": "앨범",
"albumEmpty": "앨범이 없습니다",
"createAlbumTooltip": "앨범 만들기",
"createAlbumTooltip": "앨범 만들기",
"createAlbumButtonLabel": "추가",
"newFilterBanner": "신규",
"countryPageTitle": "국가",
"countryEmpty": "국가가 없습니다",
@ -270,6 +275,8 @@
"settingsViewerShowInformation": "상세 정보 표시",
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
"settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsViewerQuickActionsTile": "빠른 작업",
"settingsViewerQuickActionEditorTitle": "빠른 작업",
@ -299,9 +306,9 @@
"settingsSubtitleThemeTextOpacity": "글자 투명도",
"settingsSubtitleThemeBackgroundColor": "배경 색상",
"settingsSubtitleThemeBackgroundOpacity": "배경 투명도",
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
"settingsSubtitleThemeTextAlignmentCenter": "가운데",
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSectionPrivacy": "개인정보 보호",
"settingsEnableCrashReport": "오류 보고서 보내기",
@ -312,6 +319,13 @@
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
"settingsHiddenPathsTile": "숨겨진 경로",
"settingsHiddenPathsTitle": "숨겨진 경로",
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
"settingsHiddenPathsRemoveTooltip": "제거",
"addPathTooltip": "경로 추가",
"settingsStorageAccessTile": "저장공간 접근",
"settingsStorageAccessTitle": "저장공간 접근",
"settingsStorageAccessBanner": "어떤 폴더는 사용자의 허용을 받아야만 앱이 파일에 접근이 가능합니다. 이 화면에 허용을 받은 폴더를 확인할 수 있으며 원하지 않으면 취소할 수 있습니다.",

View file

@ -2,29 +2,16 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
group,
sort,
stats,
}
enum ChipAction {
delete,
hide,
pin,
unpin,
rename,
setCover,
goToAlbumPage,
goToCountryPage,
goToTagPage,
hide,
}
extension ExtraChipAction on ChipAction {
String getText(BuildContext context) {
switch (this) {
case ChipAction.delete:
return context.l10n.chipActionDelete;
case ChipAction.goToAlbumPage:
return context.l10n.chipActionGoToAlbumPage;
case ChipAction.goToCountryPage:
@ -33,21 +20,11 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionGoToTagPage;
case ChipAction.hide:
return context.l10n.chipActionHide;
case ChipAction.pin:
return context.l10n.chipActionPin;
case ChipAction.unpin:
return context.l10n.chipActionUnpin;
case ChipAction.rename:
return context.l10n.chipActionRename;
case ChipAction.setCover:
return context.l10n.chipActionSetCover;
}
}
IconData getIcon() {
switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.goToAlbumPage:
return AIcons.album;
case ChipAction.goToCountryPage:
@ -56,13 +33,6 @@ extension ExtraChipAction on ChipAction {
return AIcons.tag;
case ChipAction.hide:
return AIcons.hide;
case ChipAction.pin:
case ChipAction.unpin:
return AIcons.pin;
case ChipAction.rename:
return AIcons.rename;
case ChipAction.setCover:
return AIcons.setCover;
}
}
}

View file

@ -0,0 +1,91 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
// general
sort,
group,
select,
selectAll,
selectNone,
stats,
createAlbum,
// single/multiple filters
delete,
hide,
pin,
unpin,
// single filter
rename,
setCover,
}
extension ExtraChipSetAction on ChipSetAction {
String getText(BuildContext context) {
switch (this) {
// general
case ChipSetAction.sort:
return context.l10n.menuActionSort;
case ChipSetAction.group:
return context.l10n.menuActionGroup;
case ChipSetAction.select:
return context.l10n.collectionActionSelect;
case ChipSetAction.selectAll:
return context.l10n.collectionActionSelectAll;
case ChipSetAction.selectNone:
return context.l10n.collectionActionSelectNone;
case ChipSetAction.stats:
return context.l10n.menuActionStats;
case ChipSetAction.createAlbum:
return context.l10n.chipActionCreateAlbum;
// single/multiple filters
case ChipSetAction.delete:
return context.l10n.chipActionDelete;
case ChipSetAction.hide:
return context.l10n.chipActionHide;
case ChipSetAction.pin:
return context.l10n.chipActionPin;
case ChipSetAction.unpin:
return context.l10n.chipActionUnpin;
// single filter
case ChipSetAction.rename:
return context.l10n.chipActionRename;
case ChipSetAction.setCover:
return context.l10n.chipActionSetCover;
}
}
IconData? getIcon() {
switch (this) {
// general
case ChipSetAction.sort:
return AIcons.sort;
case ChipSetAction.group:
return AIcons.group;
case ChipSetAction.select:
return AIcons.select;
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
return null;
case ChipSetAction.stats:
return AIcons.stats;
case ChipSetAction.createAlbum:
return AIcons.createAlbum;
// single/multiple filters
case ChipSetAction.delete:
return AIcons.delete;
case ChipSetAction.hide:
return AIcons.hide;
case ChipSetAction.pin:
return AIcons.pin;
case ChipSetAction.unpin:
return AIcons.unpin;
// single filter
case ChipSetAction.rename:
return AIcons.rename;
case ChipSetAction.setCover:
return AIcons.setCover;
}
}
}

View file

@ -16,6 +16,8 @@ enum EntryAction {
flip,
// vector
viewSource,
// motion photo,
viewMotionPhotoVideo,
// external
edit,
open,
@ -42,6 +44,7 @@ class EntryActions {
EntryAction.export,
EntryAction.print,
EntryAction.viewSource,
EntryAction.viewMotionPhotoVideo,
EntryAction.rotateScreen,
];
@ -87,6 +90,9 @@ extension ExtraEntryAction on EntryAction {
// vector
case EntryAction.viewSource:
return context.l10n.entryActionViewSource;
// motion photo
case EntryAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// external
case EntryAction.edit:
return context.l10n.entryActionEdit;
@ -132,6 +138,9 @@ extension ExtraEntryAction on EntryAction {
// vector
case EntryAction.viewSource:
return AIcons.vector;
// motion photo
case EntryAction.viewMotionPhotoVideo:
return AIcons.motionPhoto;
// external
case EntryAction.edit:
case EntryAction.open:

View file

@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart';
@ -39,6 +40,8 @@ class AvesEntry {
CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails;
List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions
@ -64,6 +67,7 @@ class AvesEntry {
required int? dateModifiedSecs,
required this.sourceDateTakenMillis,
required int? durationMillis,
this.burstEntries,
}) {
this.path = path;
this.sourceTitle = sourceTitle;
@ -80,6 +84,7 @@ class AvesEntry {
String? path,
int? contentId,
int? dateModifiedSecs,
List<AvesEntry>? burstEntries,
}) {
final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry(
@ -96,6 +101,7 @@ class AvesEntry {
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
burstEntries: burstEntries ?? this.burstEntries,
)
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
@ -228,10 +234,6 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false;
bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg;
bool get canEdit => path != null;
bool get canRotateAndFlip => canEdit && canEditExif;
@ -652,6 +654,51 @@ class AvesEntry {
}
}
// multipage
static final _burstFilenamePattern = RegExp(r'^(\d{8}_\d{6})_(\d+)$');
bool get isMultiPage => (_catalogMetadata?.isMultiPage ?? false) || isBurst;
bool get isBurst => burstEntries?.isNotEmpty == true;
bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
String? get burstKey {
if (filenameWithoutExtension != null) {
final match = _burstFilenamePattern.firstMatch(filenameWithoutExtension!);
if (match != null) {
return '$directory/${match.group(1)}';
}
}
return null;
}
Future<MultiPageInfo?> getMultiPageInfo() async {
if (isBurst) {
return MultiPageInfo(
mainEntry: this,
pages: burstEntries!
.mapIndexed((index, entry) => SinglePageInfo(
index: index,
pageId: entry.contentId!,
isDefault: index == 0,
uri: entry.uri,
mimeType: entry.mimeType,
width: entry.width,
height: entry.height,
rotationDegrees: entry.rotationDegrees,
durationMillis: entry.durationMillis,
))
.toList(),
);
} else {
return await metadataService.getMultiPageInfo(this);
}
}
// sort
// compare by:
// 1) title ascending
// 2) extension ascending

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
@ -22,6 +23,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
AlbumFilter.type,
LocationFilter.type,
TagFilter.type,
PathFilter.type,
];
static CollectionFilter? fromJson(String jsonString) {
@ -43,6 +45,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
return QueryFilter.fromMap(jsonMap);
case TagFilter.type:
return TagFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
}
}
debugPrint('failed to parse filter from json=$jsonString');
@ -65,7 +69,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String getTooltip(BuildContext context) => getLabel(context);
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => null;
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));

View file

@ -0,0 +1,46 @@
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class PathFilter extends CollectionFilter {
static const type = 'path';
final String path;
const PathFilter(this.path);
PathFilter.fromMap(Map<String, dynamic> json)
: this(
json['path'],
);
@override
Map<String, dynamic> toMap() => {
'type': type,
'path': path,
};
@override
EntryFilter get test => (entry) => entry.directory?.startsWith(path) ?? false;
@override
String get universalLabel => path;
@override
String get category => type;
@override
String get key => '$type-$path';
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is PathFilter && other.path == path;
}
@override
int get hashCode => hashValues(type, path);
@override
String toString() => '$runtimeType#${shortHash(this)}{path=$path}';
}

View file

@ -22,6 +22,14 @@ class MultiPageInfo {
final firstPage = _pages.removeAt(0);
_pages.insert(0, firstPage.copyWith(isDefault: true));
}
final burstEntries = mainEntry.burstEntries;
if (burstEntries != null) {
_pageEntries.addEntries(pages.map((pageInfo) {
final pageEntry = burstEntries.firstWhere((entry) => entry.uri == pageInfo.uri);
return MapEntry(pageInfo, pageEntry);
}));
}
}
}

View file

@ -22,13 +22,9 @@ class PanoramaInfo {
final projectionType = map['projectionType'] as String?;
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
if (fHeight == null && cWidth != null && cHeight != null) {
// assume the cropped area is actually covering 360 degrees horizontally
// even when `croppedAreaLeft` is non zero
fWidth = cWidth;
if (fHeight == null && fWidth != null && cHeight != null) {
fHeight = (fWidth / 2).round();
cTop = ((fHeight - cHeight) / 2).round();
cLeft = 0;
}
Rect? croppedAreaRect;

45
lib/model/selection.dart Normal file
View file

@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
class Selection<T> extends ChangeNotifier {
bool _isSelecting = false;
bool get isSelecting => _isSelecting;
final Set<T> _selection = {};
Set<T> get selection => _selection;
void browse() {
clearSelection();
_isSelecting = false;
notifyListeners();
}
void select() {
_isSelecting = true;
notifyListeners();
}
bool isSelected(Iterable<T> items) => items.every(selection.contains);
void addToSelection(Iterable<T> items) {
_selection.addAll(items);
notifyListeners();
}
void removeFromSelection(Iterable<T> items) {
_selection.removeAll(items);
notifyListeners();
}
void clearSelection() {
_selection.clear();
notifyListeners();
}
void toggleSelection(T item) {
if (_selection.isEmpty) select();
if (!_selection.remove(item)) _selection.add(item);
notifyListeners();
}
}

View file

@ -1,11 +1,14 @@
import 'dart:convert';
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/device_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:collection/collection.dart';
@ -61,13 +64,15 @@ class Settings extends ChangeNotifier {
static const hiddenFiltersKey = 'hidden_filters';
// viewer
static const viewerQuickActionsKey = 'viewer_quick_actions';
static const showOverlayMinimapKey = 'show_overlay_minimap';
static const showOverlayInfoKey = 'show_overlay_info';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const viewerQuickActionsKey = 'viewer_quick_actions';
static const videoQuickActionsKey = 'video_quick_actions';
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
static const viewerUseCutoutKey = 'viewer_use_cutout';
// video
static const videoQuickActionsKey = 'video_quick_actions';
static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec';
static const enableVideoAutoPlayKey = 'video_auto_play';
static const videoLoopModeKey = 'video_loop';
@ -126,6 +131,21 @@ class Settings extends ChangeNotifier {
}
}
Future<void> setContextualDefaults() async {
// performance
final performanceClass = await DeviceService.getPerformanceClass();
enableOverlayBlurEffect = performanceClass >= 30;
// availability
final hasPlayServices = await availability.hasPlayServices;
if (hasPlayServices) {
infoMapStyle = EntryMapStyle.googleNormal;
} else {
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
infoMapStyle = styles[Random().nextInt(styles.length)];
}
}
// app
bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false);
@ -192,9 +212,9 @@ class Settings extends ChangeNotifier {
// collection
EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
set collectionGroupFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
set collectionSectionFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values);
@ -240,6 +260,10 @@ class Settings extends ChangeNotifier {
// viewer
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
@ -252,16 +276,20 @@ class Settings extends ChangeNotifier {
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
bool get enableOverlayBlurEffect => getBoolOrDefault(enableOverlayBlurEffectKey, true);
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
set enableOverlayBlurEffect(bool newValue) => setAndNotify(enableOverlayBlurEffectKey, newValue);
bool get viewerUseCutout => getBoolOrDefault(viewerUseCutoutKey, true);
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
// video
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values);
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
// video
bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true);
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);
@ -452,6 +480,8 @@ class Settings extends ChangeNotifier {
case showOverlayMinimapKey:
case showOverlayInfoKey:
case showOverlayShootingDetailsKey:
case enableOverlayBlurEffectKey:
case viewerUseCutoutKey:
case enableVideoHardwareAccelerationKey:
case enableVideoAutoPlayKey:
case subtitleShowOutlineKey:

View file

@ -10,9 +10,12 @@ import 'package:flutter/widgets.dart';
mixin AlbumMixin on SourceBase {
final Set<String?> _directories = {};
final Set<String> _newAlbums = {};
List<String> get rawAlbums => List.unmodifiable(_directories);
Set<AlbumFilter> getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => AlbumFilter(v, getAlbumDisplayName(context, v))));
int compareAlbumsByName(String a, String b) {
final ua = getAlbumDisplayName(null, a);
final ub = getAlbumDisplayName(null, b);
@ -109,7 +112,7 @@ mixin AlbumMixin on SourceBase {
}
void cleanEmptyAlbums([Set<String?>? albums]) {
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet();
if (emptyAlbums.isNotEmpty) {
_directories.removeAll(emptyAlbums);
_notifyAlbumChange();
@ -148,6 +151,22 @@ mixin AlbumMixin on SourceBase {
AvesEntry? albumRecentEntry(AlbumFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
}
void createAlbum(String directory) {
_newAlbums.add(directory);
addDirectories({directory});
}
void renameNewAlbum(String source, String destination) {
if (_newAlbums.remove(source)) {
cleanEmptyAlbums({source});
createAlbum(destination);
}
}
void forgetNewAlbums(Set<String> directories) {
_newAlbums.removeAll(directories);
}
}
class AlbumsChangedEvent {}

View file

@ -13,17 +13,18 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin {
class CollectionLens with ChangeNotifier {
final CollectionSource source;
final Set<CollectionFilter> filters;
EntryGroupFactor groupFactor;
EntryGroupFactor sectionFactor;
EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier();
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
final List<StreamSubscription> _subscriptions = [];
int? id;
bool listenToSource;
@ -38,7 +39,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
this.id,
this.listenToSource = true,
}) : filters = (filters ?? {}).whereNotNull().toSet(),
groupFactor = settings.collectionGroupFactor,
sectionFactor = settings.collectionSectionFactor,
sortFactor = settings.collectionSortFactor {
id ??= hashCode;
if (listenToSource) {
@ -73,7 +74,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
int get entryCount => _filteredSortedEntries.length;
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
// sorted as displayed to the user, i.e. sorted then sectioned, not an absolute order on all entries
List<AvesEntry>? _sortedEntries;
List<AvesEntry> get sortedEntries {
@ -84,9 +85,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
bool get showHeaders {
if (sortFactor == EntrySortFactor.size) return false;
if (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.none) return false;
if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false;
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.album);
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album);
final filterByAlbum = filters.any((f) => f is AlbumFilter);
if (albumSections && filterByAlbum) return false;
@ -113,9 +114,33 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
filterChangeNotifier.notifyListeners();
}
final bool groupBursts = true;
void _applyFilters() {
final entries = source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) {
_groupBursts();
}
}
void _groupBursts() {
final byBurstKey = groupBy<AvesEntry, String?>(_filteredSortedEntries, (entry) => entry.burstKey).whereNotNullKey();
byBurstKey.forEach((burstKey, entries) {
if (entries.length > 1) {
entries.sort(AvesEntry.compareByName);
final mainEntry = entries.first;
final burstEntry = mainEntry.copyWith(burstEntries: entries);
entries.skip(1).toList().forEach((subEntry) {
_filteredSortedEntries.remove(subEntry);
});
final index = _filteredSortedEntries.indexOf(mainEntry);
_filteredSortedEntries.removeAt(index);
_filteredSortedEntries.insert(index, burstEntry);
}
});
}
void _applySort() {
@ -132,10 +157,10 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
}
}
void _applyGroup() {
void _applySection() {
switch (sortFactor) {
case EntrySortFactor.date:
switch (groupFactor) {
switch (sectionFactor) {
case EntryGroupFactor.album:
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break;
@ -168,11 +193,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
}
// metadata change should also trigger a full refresh
// as dates impact sorting and grouping
// as dates impact sorting and sectioning
void _refresh() {
_applyFilters();
_applySort();
_applyGroup();
_applySection();
}
void _onFavouritesChanged() {
@ -183,21 +208,21 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
void _onSettingsChanged() {
final newSortFactor = settings.collectionSortFactor;
final newGroupFactor = settings.collectionGroupFactor;
final newSectionFactor = settings.collectionSectionFactor;
final needSort = sortFactor != newSortFactor;
final needGroup = needSort || groupFactor != newGroupFactor;
final needSection = needSort || sectionFactor != newSectionFactor;
if (needSort) {
sortFactor = newSortFactor;
_applySort();
}
if (needGroup) {
groupFactor = newGroupFactor;
_applyGroup();
if (needSection) {
sectionFactor = newSectionFactor;
_applySection();
}
if (needSort || needGroup) {
sortGroupChangeNotifier.notifyListeners();
if (needSort || needSection) {
sortSectionChangeNotifier.notifyListeners();
}
}
@ -206,62 +231,32 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
}
void onEntryRemoved(Set<AvesEntry> entries) {
if (groupBursts) {
// find impacted burst groups
final obsoleteBurstEntries = <AvesEntry>{};
final burstKeys = entries.map((entry) => entry.burstKey).whereNotNull().toSet();
if (burstKeys.isNotEmpty) {
_filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.burstKey)).forEach((mainEntry) {
final subEntries = mainEntry.burstEntries!;
// remove the deleted sub-entries
subEntries.removeWhere(entries.contains);
if (subEntries.isEmpty) {
// remove the burst entry itself
obsoleteBurstEntries.add(mainEntry);
}
// TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted
});
entries.addAll(obsoleteBurstEntries);
}
}
// we should remove obsolete entries and sections
// but do not apply sort/group
// but do not apply sort/section
// as section order change would surprise the user while browsing
_filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains);
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
selection.removeAll(entries);
notifyListeners();
}
}
mixin CollectionActivityMixin {
final ValueNotifier<Activity> _activityNotifier = ValueNotifier(Activity.browse);
ValueNotifier<Activity> get activityNotifier => _activityNotifier;
bool get isBrowsing => _activityNotifier.value == Activity.browse;
bool get isSelecting => _activityNotifier.value == Activity.select;
void browse() {
clearSelection();
_activityNotifier.value = Activity.browse;
}
void select() => _activityNotifier.value = Activity.select;
// selection
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
final Set<AvesEntry> _selection = {};
Set<AvesEntry> get selection => _selection;
bool isSelected(Iterable<AvesEntry> entries) => entries.every(selection.contains);
void addToSelection(Iterable<AvesEntry> entries) {
_selection.addAll(entries);
selectionChangeNotifier.notifyListeners();
}
void removeFromSelection(Iterable<AvesEntry> entries) {
_selection.removeAll(entries);
selectionChangeNotifier.notifyListeners();
}
void clearSelection() {
_selection.clear();
selectionChangeNotifier.notifyListeners();
}
void toggleSelection(AvesEntry entry) {
if (_selection.isEmpty) select();
if (!_selection.remove(entry)) _selection.add(entry);
selectionChangeNotifier.notifyListeners();
}
}

View file

@ -162,6 +162,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter);
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove(
todoEntries: todoEntries,
copy: false,
@ -275,13 +276,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return recentEntry(filter);
}
void changeFilterVisibility(CollectionFilter filter, bool visible) {
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final hiddenFilters = settings.hiddenFilters;
if (visible) {
hiddenFilters.remove(filter);
hiddenFilters.removeAll(filters);
} else {
hiddenFilters.add(filter);
settings.searchHistory = settings.searchHistory..remove(filter);
hiddenFilters.addAll(filters);
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
}
settings.hiddenFilters = hiddenFilters;
@ -292,10 +293,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateLocations();
updateTags();
eventBus.fire(FilterVisibilityChangedEvent(filter, visible));
eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
if (visible) {
refreshMetadata(visibleEntries.where(filter.test).toSet());
refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet());
}
}
}
@ -319,10 +320,10 @@ class EntryMovedEvent {
}
class FilterVisibilityChangedEvent {
final CollectionFilter filter;
final Set<CollectionFilter> filters;
final bool visible;
const FilterVisibilityChangedEvent(this.filter, this.visible);
const FilterVisibilityChangedEvent(this.filters, this.visible);
}
class ProgressEvent {

View file

@ -1,5 +1,3 @@
enum Activity { browse, select }
enum SourceState { loading, cataloguing, locating, ready }
enum ChipSortFactor { date, name, count }

View file

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class DeviceService {
static const platform = MethodChannel('deckers.thibault/aves/device');
static Future<int> getPerformanceClass() async {
try {
await platform.invokeMethod('getPerformanceClass');
final result = await platform.invokeMethod('getPerformanceClass');
if (result != null) return result as int;
} on PlatformException catch (e) {
debugPrint('getPerformanceClass failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return 0;
}
}

View file

@ -105,7 +105,7 @@ class PlatformMetadataService implements MetadataService {
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
final pageMaps = (result as List).cast<Map>();
final pageMaps = ((result as List?) ?? []).cast<Map>();
if (entry.isMotionPhoto && pageMaps.isNotEmpty) {
final imagePage = pageMaps[0];
imagePage['width'] = entry.width;

View file

@ -33,6 +33,8 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType);
Future<String?> selectDirectory();
}
class PlatformStorageService implements StorageService {
@ -217,4 +219,25 @@ class PlatformStorageService implements StorageService {
}
return Uint8List(0);
}
@override
Future<String?> selectDirectory() async {
try {
final completer = Completer<String>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'selectDirectory',
}).listen(
(data) => completer.complete(data as String?),
onError: completer.completeError,
onDone: () {
if (!completer.isCompleted) completer.complete(null);
},
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) {
debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return null;
}
}

View file

@ -8,6 +8,10 @@ abstract class WindowService {
Future<bool> isRotationLocked();
Future<void> requestOrientation([Orientation? orientation]);
Future<bool> canSetCutoutMode();
Future<void> setCutoutMode(bool use);
}
class PlatformWindowService implements WindowService {
@ -41,12 +45,12 @@ class PlatformWindowService implements WindowService {
late final int orientationCode;
switch (orientation) {
case Orientation.landscape:
// SCREEN_ORIENTATION_USER_LANDSCAPE
orientationCode = 11;
// SCREEN_ORIENTATION_SENSOR_LANDSCAPE
orientationCode = 6;
break;
case Orientation.portrait:
// SCREEN_ORIENTATION_USER_PORTRAIT
orientationCode = 12;
// SCREEN_ORIENTATION_SENSOR_PORTRAIT
orientationCode = 7;
break;
default:
// SCREEN_ORIENTATION_UNSPECIFIED
@ -61,4 +65,26 @@ class PlatformWindowService implements WindowService {
debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
@override
Future<bool> canSetCutoutMode() async {
try {
final result = await platform.invokeMethod('canSetCutoutMode');
if (result != null) return result as bool;
} on PlatformException catch (e) {
debugPrint('canSetCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return false;
}
@override
Future<void> setCutoutMode(bool use) async {
try {
await platform.invokeMethod('setCutoutMode', <String, dynamic>{
'use': use,
});
} on PlatformException catch (e) {
debugPrint('setCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
}
}

View file

@ -60,6 +60,7 @@ class Durations {
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const highlightJumpDelay = Duration(milliseconds: 400);
static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoOverlayHideDelay = Duration(milliseconds: 500);
static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000);

View file

@ -30,6 +30,7 @@ class AIcons {
static const IconData tagOff = MdiIcons.tagOffOutline;
// actions
static const IconData addPath = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined;
@ -48,8 +49,10 @@ class AIcons {
static const IconData import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData newTier = Icons.fiber_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;
static const IconData unpin = MdiIcons.pinOffOutline;
static const IconData play = Icons.play_arrow;
static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined;

View file

@ -279,10 +279,10 @@ class Constants {
sourceUrl: 'https://github.com/dart-lang/intl',
),
Dependency(
name: 'LatLong',
name: 'LatLong2',
license: 'Apache 2.0',
licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE',
sourceUrl: 'https://github.com/MikeMitterer/dart-latlong',
licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE',
sourceUrl: 'https://github.com/jifalops/dart-latlong',
),
Dependency(
name: 'PDF for Dart and Flutter',

View file

@ -5,6 +5,7 @@ import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
@ -45,10 +46,10 @@ class CollectionAppBar extends StatefulWidget {
}
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController();
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
CollectionLens get collection => widget.collection;
@ -63,6 +64,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
duration: Durations.iconAnimation,
vsync: this,
);
_isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = AppShortcutService.canPin();
_registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
@ -78,35 +80,34 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override
void dispose() {
_unregisterWidget(widget);
_isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose();
_searchFieldController.dispose();
super.dispose();
}
void _registerWidget(CollectionAppBar widget) {
widget.collection.activityNotifier.addListener(_onActivityChange);
widget.collection.filterChangeNotifier.addListener(_updateHeight);
}
void _unregisterWidget(CollectionAppBar widget) {
widget.collection.activityNotifier.removeListener(_onActivityChange);
widget.collection.filterChangeNotifier.removeListener(_updateHeight);
}
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier,
builder: (context, activity, child) {
return Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.isSelecting,
builder: (context, isSelecting, child) {
_isSelectingNotifier.value = isSelecting;
return AnimatedBuilder(
animation: collection.filterChangeNotifier,
builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal;
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading() : null,
title: _buildAppBarTitle(),
actions: _buildActions(),
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting),
actions: _buildActions(isSelecting),
bottom: hasFilters
? FilterBar(
filters: collection.filters,
@ -123,15 +124,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
}
Widget _buildAppBarLeading() {
Widget _buildAppBarLeading(bool isSelecting) {
VoidCallback? onPressed;
String? tooltip;
if (collection.isBrowsing) {
if (isSelecting) {
onPressed = () => context.read<Selection<AvesEntry>>().browse();
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} else {
onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
} else if (collection.isSelecting) {
onPressed = collection.browse;
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
}
return IconButton(
key: const Key('appbar-leading-button'),
@ -144,8 +145,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
}
Widget? _buildAppBarTitle() {
if (collection.isBrowsing) {
Widget? _buildAppBarTitle(bool isSelecting) {
if (isSelecting) {
return Selector<Selection<AvesEntry>, int>(
selector: (context, selection) => selection.selection.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
);
} else {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
if (appMode == AppMode.main) {
@ -158,36 +164,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
onTap: appMode.canSearch ? _goToSearch : null,
child: title,
);
} else if (collection.isSelecting) {
return AnimatedBuilder(
animation: collection.selectionChangeNotifier,
builder: (context, child) {
final count = collection.selection.length;
return Text(context.l10n.collectionSelectionPageTitle(count));
},
);
}
return null;
}
List<Widget> _buildActions() {
List<Widget> _buildActions(bool isSelecting) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return [
if (collection.isBrowsing && appMode.canSearch)
if (!isSelecting && appMode.canSearch)
CollectionSearchButton(
source: source,
parentCollection: collection,
),
if (collection.isSelecting)
...EntryActions.selection.map((action) => AnimatedBuilder(
animation: collection.selectionChangeNotifier,
builder: (context, child) {
return IconButton(
icon: Icon(action.getIcon()),
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
tooltip: action.getText(context),
);
},
if (isSelecting)
...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.selection.isEmpty,
builder: (context, isEmpty, child) => IconButton(
icon: Icon(action.getIcon()),
onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
tooltip: action.getText(context),
),
)),
FutureBuilder<bool>(
future: _canAddShortcutsLoader,
@ -196,8 +191,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return PopupMenuButton<CollectionAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final selection = context.read<Selection<AvesEntry>>();
final isNotEmpty = !collection.isEmpty;
final hasSelection = collection.selection.isNotEmpty;
final hasSelection = selection.selection.isNotEmpty;
return [
PopupMenuItem(
key: const Key('menu-sort'),
@ -210,7 +206,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
),
if (collection.isBrowsing && appMode == AppMode.main) ...[
if (!selection.isSelecting && appMode == AppMode.main) ...[
PopupMenuItem(
value: CollectionAction.select,
enabled: isNotEmpty,
@ -227,7 +223,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
),
],
if (collection.isSelecting) ...[
if (selection.isSelecting) ...[
const PopupMenuDivider(),
PopupMenuItem(
value: CollectionAction.copy,
@ -247,7 +243,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
const PopupMenuDivider(),
PopupMenuItem(
value: CollectionAction.selectAll,
enabled: collection.selection.length < collection.entryCount,
enabled: selection.selection.length < collection.entryCount,
child: MenuRow(text: context.l10n.collectionActionSelectAll),
),
PopupMenuItem(
@ -269,11 +265,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
void _onActivityChange() {
if (collection.isSelecting) {
if (context.read<Selection<AvesEntry>>().isSelecting) {
_browseToSelectAnimation.forward();
} else {
_browseToSelectAnimation.reverse();
_searchFieldController.clear();
}
}
@ -289,13 +284,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_actionDelegate.onCollectionActionSelected(context, action);
break;
case CollectionAction.select:
collection.select();
context.read<Selection<AvesEntry>>().select();
break;
case CollectionAction.selectAll:
collection.addToSelection(collection.sortedEntries);
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
break;
case CollectionAction.selectNone:
collection.clearSelection();
context.read<Selection<AvesEntry>>().clearSelection();
break;
case CollectionAction.stats:
_goToStats();
@ -307,7 +302,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final value = await showDialog<EntryGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
initialValue: settings.collectionGroupFactor,
initialValue: settings.collectionSectionFactor,
options: {
EntryGroupFactor.album: context.l10n.collectionGroupAlbum,
EntryGroupFactor.month: context.l10n.collectionGroupMonth,
@ -320,7 +315,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
settings.collectionGroupFactor = value;
settings.collectionSectionFactor = value;
}
break;
case CollectionAction.sort:
@ -372,13 +367,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void _goToSearch() {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: collection.source,
parentCollection: collection,
),
));
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: collection.source,
parentCollection: collection,
),
),
);
}
void _goToStats() {

View file

@ -12,17 +12,17 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
import 'package:aves/widgets/collection/grid/section_layout.dart';
import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
@ -79,7 +79,7 @@ class _CollectionGridContent extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return ThumbnailTheme(
return GridTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
@ -173,7 +173,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector(
selectable: isMainMode,
collection: collection,
items: collection.sortedEntries,
scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier,
child: scaler,
@ -210,14 +210,11 @@ class _CollectionScaler extends StatelessWidget {
),
child: child,
),
scaledBuilder: (entry, extent) => ThumbnailTheme(
extent: extent,
child: DecoratedThumbnail(
entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false,
highlightable: false,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false,
highlightable: false,
),
child: child,
);
@ -270,13 +267,13 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
void _registerWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop);
widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop);
widget.scrollController.addListener(_onScrollChange);
}
void _unregisterWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop);
widget.scrollController.removeListener(_onScrollChange);
}

View file

@ -1,8 +1,11 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -35,22 +38,27 @@ class _CollectionPageState extends State<CollectionPage> {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: WillPopScope(
onWillPop: () {
if (collection.isSelecting) {
collection.browse();
return SynchronousFuture(false);
}
return SynchronousFuture(true);
},
child: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: const CollectionGrid(
key: Key('collection-grid'),
body: SelectionProvider<AvesEntry>(
child: Builder(
builder: (context) => WillPopScope(
onWillPop: () {
final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) {
selection.browse();
return SynchronousFuture(false);
}
return SynchronousFuture(true);
},
child: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: const CollectionGrid(
key: Key('collection-grid'),
),
),
),
),
),

View file

@ -25,7 +25,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
lineBuilder: (context, entry) {
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.groupFactor) {
switch (collection.sectionFactor) {
case EntryGroupFactor.album:
return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),

View file

@ -3,9 +3,12 @@ import 'dart:async';
import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
@ -31,10 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_showDeleteDialog(context);
break;
case EntryAction.share:
final collection = context.read<CollectionLens>();
AndroidAppService.shareEntries(collection.selection).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
_share(context);
break;
default:
break;
@ -57,18 +57,33 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
}
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet();
}
void _share(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
AndroidAppService.shareEntries(selectedItems).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
}
void _refreshMetadata(BuildContext context) {
final collection = context.read<CollectionLens>();
collection.source.refreshMetadata(collection.selection);
collection.browse();
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
source.refreshMetadata(selectedItems);
selection.browse();
}
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final collection = context.read<CollectionLens>();
final source = collection.source;
final selection = collection.selection;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet();
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions,
// before asking to pick a destination album
@ -95,11 +110,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return;
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return;
// do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection
final todoEntries = selection.toSet();
final todoEntries = selectedItems.toSet();
final copy = moveType == MoveType.copy;
final todoCount = todoEntries.length;
@ -118,7 +133,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
collection.browse();
selection.browse();
source.resumeMonitoring();
// cleanup
@ -140,6 +155,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
label: context.l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>();
var targetCollection = collection;
if (collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
@ -175,11 +191,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
Future<void> _showDeleteDialog(BuildContext context) async {
final collection = context.read<CollectionLens>();
final source = collection.source;
final selection = collection.selection;
final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selection.length;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length;
final confirmed = await showDialog<bool>(
context: context,
@ -207,12 +223,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
context: context,
opStream: imageFileService.delete(selection),
opStream: imageFileService.delete(selectedItems),
itemCount: todoCount,
onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
await source.removeEntries(deletedUris);
collection.browse();
selection.browse();
source.resumeMonitoring();
final deletedCount = deletedUris.length;

View file

@ -1,3 +1,4 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/icons.dart';
@ -33,7 +34,7 @@ class AlbumSectionHeader extends StatelessWidget {
);
}
}
return SectionHeader(
return SectionHeader<AvesEntry>(
sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon,
title: albumName ?? context.l10n.sectionUnknown,

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
@ -35,13 +36,13 @@ class CollectionSectionHeader extends StatelessWidget {
Widget? _buildHeader(BuildContext context) {
switch (collection.sortFactor) {
case EntrySortFactor.date:
switch (collection.groupFactor) {
switch (collection.sectionFactor) {
case EntryGroupFactor.album:
return _buildAlbumHeader(context);
case EntryGroupFactor.month:
return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
return MonthSectionHeader<AvesEntry>(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
case EntryGroupFactor.day:
return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
return DaySectionHeader<AvesEntry>(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
case EntryGroupFactor.none:
break;
}

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class DaySectionHeader extends StatelessWidget {
class DaySectionHeader<T> extends StatelessWidget {
final DateTime? date;
const DaySectionHeader({
@ -45,14 +45,14 @@ class DaySectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SectionHeader(
return SectionHeader<T>(
sectionKey: EntryDateSectionKey(date),
title: _formatDate(context, date),
);
}
}
class MonthSectionHeader extends StatelessWidget {
class MonthSectionHeader<T> extends StatelessWidget {
final DateTime? date;
const MonthSectionHeader({
@ -71,7 +71,7 @@ class MonthSectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SectionHeader(
return SectionHeader<T>(
sectionKey: EntryDateSectionKey(date),
title: _formatDate(context, date),
);

View file

@ -1,5 +1,6 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
@ -31,10 +32,11 @@ class InteractiveThumbnail extends StatelessWidget {
final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) {
case AppMode.main:
if (collection.isBrowsing) {
final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) {
selection.toggleSelection(entry);
} else {
_goToViewer(context);
} else if (collection.isSelecting) {
collection.toggleSelection(entry);
}
break;
case AppMode.pickExternal:
@ -74,7 +76,7 @@ class InteractiveThumbnail extends StatelessWidget {
id: collection.id,
listenToSource: false,
);
assert(viewerCollection.sortedEntries.contains(entry));
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
return EntryViewerPage(
collection: viewerCollection,
initialEntry: entry,

View file

@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget {
@ -10,7 +11,7 @@ class DecoratedThumbnail extends StatelessWidget {
final double tileExtent;
final CollectionLens? collection;
final ValueNotifier<bool>? cancellableNotifier;
final bool selectable, highlightable;
final bool selectable, highlightable, hero;
static final Color borderColor = Colors.grey.shade700;
static final double borderWidth = AvesBorder.borderWidth;
@ -23,6 +24,7 @@ class DecoratedThumbnail extends StatelessWidget {
this.cancellableNotifier,
this.selectable = true,
this.highlightable = true,
this.hero = true,
}) : super(key: key);
@override
@ -32,7 +34,7 @@ class DecoratedThumbnail extends StatelessWidget {
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
final heroTag = hashValues(collection?.id, entry);
final heroTag = hero ? hashValues(collection?.id, entry.uri) : null;
final isSvg = entry.isSvg;
Widget child = ThumbnailImage(
entry: entry,
@ -46,7 +48,7 @@ class DecoratedThumbnail extends StatelessWidget {
children: [
child,
if (!isSvg) ThumbnailEntryOverlay(entry: entry),
if (selectable) ThumbnailSelectionOverlay(entry: entry),
if (selectable) GridItemSelectionOverlay(item: entry),
if (highlightable) ThumbnailHighlightOverlay(entry: entry),
],
);

View file

@ -2,12 +2,8 @@ import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -23,7 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final children = [
if (entry.hasGps && context.select<ThumbnailThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
if (entry.isVideo)
VideoIcon(
entry: entry,
@ -31,11 +27,11 @@ class ThumbnailEntryOverlay extends StatelessWidget {
else if (entry.isAnimated)
const AnimatedImageIcon()
else ...[
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(),
]
],
if (entry.isMultiPage) MultiPageIcon(entry: entry),
];
if (children.isEmpty) return const SizedBox.shrink();
if (children.length == 1) return children.first;
@ -47,63 +43,6 @@ class ThumbnailEntryOverlay extends StatelessWidget {
}
}
class ThumbnailSelectionOverlay extends StatelessWidget {
final AvesEntry entry;
static const duration = Durations.thumbnailOverlayAnimation;
const ThumbnailSelectionOverlay({
Key? key,
required this.entry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final collection = context.watch<CollectionLens>();
return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier,
builder: (context, activity, child) {
final child = collection.isSelecting
? AnimatedBuilder(
animation: collection.selectionChangeNotifier,
builder: (context, child) {
final selected = collection.isSelected([entry]);
var child = collection.isSelecting
? OverlayIcon(
key: ValueKey(selected),
icon: selected ? AIcons.selected : AIcons.unselected,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
)
: const SizedBox.shrink();
child = AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: child,
);
child = AnimatedContainer(
duration: duration,
alignment: AlignmentDirectional.topEnd,
color: selected ? Colors.black54 : Colors.transparent,
child: child,
);
return child;
},
)
: const SizedBox.shrink();
return AnimatedSwitcher(
duration: duration,
child: child,
);
},
);
}
}
class ThumbnailHighlightOverlay extends StatefulWidget {
final AvesEntry entry;
@ -132,7 +71,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: Theme.of(context).accentColor,
width: context.select<ThumbnailThemeData, double>((t) => t.highlightBorderWidth),
width: context.select<GridThemeData, double>((t) => t.highlightBorderWidth),
)),
),
),

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/theme/durations.dart';
@ -14,10 +15,14 @@ mixin FeedbackMixin {
// provide the messenger if feedback happens as the widget is disposed
void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) {
final duration = action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay;
messenger.showSnackBar(SnackBar(
content: Text(message),
content: _FeedbackMessage(
message: message,
duration: action != null ? duration : null,
),
action: action,
duration: action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay,
duration: duration,
));
}
@ -136,3 +141,68 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
);
}
}
class _FeedbackMessage extends StatefulWidget {
final String message;
final Duration? duration;
const _FeedbackMessage({
Key? key,
required this.message,
this.duration,
}) : super(key: key);
@override
_FeedbackMessageState createState() => _FeedbackMessageState();
}
class _FeedbackMessageState extends State<_FeedbackMessage> {
double _percent = 0;
late int _remainingSecs;
Timer? _timer;
@override
void initState() {
super.initState();
final duration = widget.duration;
if (duration != null) {
_remainingSecs = duration.inSeconds;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _remainingSecs--);
});
WidgetsBinding.instance!.addPostFrameCallback((_) => setState(() => _percent = 1.0));
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final text = Text(widget.message);
final duration = widget.duration;
return duration == null
? text
: Row(
children: [
Expanded(child: text),
const SizedBox(width: 16),
CircularPercentIndicator(
percent: _percent,
lineWidth: 2,
radius: 32,
backgroundColor: Theme.of(context).accentColor,
progressColor: Colors.grey,
animation: true,
animationDuration: duration.inMilliseconds,
center: Text('$_remainingSecs'),
animateFromLastPercent: true,
reverse: true,
),
],
);
}
}

View file

@ -28,42 +28,50 @@ class BlurredRect extends StatelessWidget {
}
class BlurredRRect extends StatelessWidget {
final bool enabled;
final double borderRadius;
final Widget child;
const BlurredRRect({
Key? key,
this.enabled = true,
required this.borderRadius,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
child: BackdropFilter(
filter: _filter,
child: child,
),
);
return enabled
? ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
child: BackdropFilter(
filter: _filter,
child: child,
),
)
: child;
}
}
class BlurredOval extends StatelessWidget {
final bool enabled;
final Widget child;
const BlurredOval({
Key? key,
this.enabled = true,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ClipOval(
child: BackdropFilter(
filter: _filter,
child: child,
),
child: enabled
? BackdropFilter(
filter: _filter,
child: child,
)
: child,
);
}
}

View file

@ -1,15 +1,15 @@
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class SectionHeader extends StatelessWidget {
class SectionHeader<T> extends StatelessWidget {
final SectionKey sectionKey;
final Widget? leading, trailing;
final String title;
@ -43,7 +43,7 @@ class SectionHeader extends StatelessWidget {
children: [
WidgetSpan(
alignment: widgetSpanAlignment,
child: _SectionSelectableLeading(
child: _SectionSelectableLeading<T>(
selectable: selectable,
sectionKey: sectionKey,
browsingBuilder: leading != null
@ -77,13 +77,13 @@ class SectionHeader extends StatelessWidget {
}
void _toggleSectionSelection(BuildContext context) {
final collection = context.read<CollectionLens>();
final sectionEntries = collection.sections[sectionKey]!;
final selected = collection.isSelected(sectionEntries);
if (selected) {
collection.removeFromSelection(sectionEntries);
final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
final selection = context.read<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries);
if (isSelected) {
selection.removeFromSelection(sectionEntries);
} else {
collection.addToSelection(sectionEntries);
selection.addToSelection(sectionEntries);
}
}
@ -122,7 +122,7 @@ class SectionHeader extends StatelessWidget {
}
}
class _SectionSelectableLeading extends StatelessWidget {
class _SectionSelectableLeading<T> extends StatelessWidget {
final bool selectable;
final SectionKey sectionKey;
final WidgetBuilder? browsingBuilder;
@ -142,72 +142,82 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget build(BuildContext context) {
if (!selectable) return _buildBrowsing(context);
final collection = context.watch<CollectionLens>();
return ValueListenableBuilder<Activity>(
valueListenable: collection.activityNotifier,
builder: (context, activity, child) {
final child = collection.isSelecting
? AnimatedBuilder(
animation: collection.selectionChangeNotifier,
builder: (context, child) {
final sectionEntries = collection.sections[sectionKey]!;
final selected = collection.isSelected(sectionEntries);
final child = TooltipTheme(
key: ValueKey(selected),
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: IconButton(
iconSize: 26,
padding: const EdgeInsets.only(top: 1),
alignment: AlignmentDirectional.topStart,
icon: Icon(selected ? AIcons.selected : AIcons.unselected),
onPressed: onPressed,
tooltip: selected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip,
constraints: const BoxConstraints(
minHeight: leadingDimension,
minWidth: leadingDimension,
),
),
);
return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: child,
);
},
)
: _buildBrowsing(context);
return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
Widget transition = ScaleTransition(
scale: animation,
child: child,
);
if (browsingBuilder == null) {
// when switching with a header that has no icon,
// we also transition the size for a smooth push to the text
transition = SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: transition,
);
}
return transition;
},
final isSelecting = context.select<Selection<T>, bool>((selection) => selection.isSelecting);
final Widget child = isSelecting
? _SectionSelectingLeading<T>(
sectionKey: sectionKey,
onPressed: onPressed,
)
: _buildBrowsing(context);
return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
Widget transition = ScaleTransition(
scale: animation,
child: child,
);
if (browsingBuilder == null) {
// when switching with a header that has no icon,
// we also transition the size for a smooth push to the text
transition = SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: transition,
);
}
return transition;
},
child: child,
);
}
Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension);
}
class _SectionSelectingLeading<T> extends StatelessWidget {
final SectionKey sectionKey;
final VoidCallback? onPressed;
const _SectionSelectingLeading({
Key? key,
required this.sectionKey,
required this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final sectionEntries = context.watch<SectionedListLayout<T>>().sections[sectionKey] ?? [];
final selection = context.watch<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries);
return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: TooltipTheme(
key: ValueKey(isSelected),
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: IconButton(
iconSize: 26,
padding: const EdgeInsets.only(top: 1),
alignment: AlignmentDirectional.topStart,
icon: Icon(isSelected ? AIcons.selected : AIcons.unselected),
onPressed: onPressed,
tooltip: isSelected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip,
constraints: const BoxConstraints(
minHeight: SectionHeader.leadingDimension,
minWidth: SectionHeader.leadingDimension,
),
),
),
);
}
}

View file

@ -0,0 +1,66 @@
import 'package:aves/model/selection.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GridItemSelectionOverlay<T> extends StatelessWidget {
final T item;
final BorderRadius? borderRadius;
final EdgeInsets? padding;
static const duration = Durations.thumbnailOverlayAnimation;
const GridItemSelectionOverlay({
Key? key,
required this.item,
this.borderRadius,
this.padding,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isSelecting = context.select<Selection<T>, bool>((selection) => selection.isSelecting);
final child = isSelecting
? Selector<Selection<T>, bool>(
selector: (context, selection) => selection.isSelected([item]),
builder: (context, isSelected, child) {
var child = isSelecting
? OverlayIcon(
key: ValueKey(isSelected),
icon: isSelected ? AIcons.selected : AIcons.unselected,
size: context.select<GridThemeData, double>((t) => t.iconSize),
)
: const SizedBox.shrink();
child = AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: child,
);
child = AnimatedContainer(
duration: duration,
alignment: AlignmentDirectional.topEnd,
padding: padding,
decoration: BoxDecoration(
color: isSelected ? Colors.black54 : Colors.transparent,
borderRadius: borderRadius,
),
child: child,
);
return child;
},
)
: const SizedBox.shrink();
return AnimatedSwitcher(
duration: duration,
child: child,
);
}
}

View file

@ -1,8 +1,7 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
@ -10,9 +9,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class GridSelectionGestureDetector extends StatefulWidget {
class GridSelectionGestureDetector<T> extends StatefulWidget {
final bool selectable;
final CollectionLens collection;
final List<T> items;
final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier;
final Widget child;
@ -20,17 +19,17 @@ class GridSelectionGestureDetector extends StatefulWidget {
const GridSelectionGestureDetector({
Key? key,
this.selectable = true,
required this.collection,
required this.items,
required this.scrollController,
required this.appBarHeightNotifier,
required this.child,
}) : super(key: key);
@override
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState();
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState<T>();
}
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDetector<T>> {
bool _pressing = false, _selecting = false;
late int _fromIndex, _lastToIndex;
late Offset _localPosition;
@ -38,9 +37,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
late double _scrollSpeedFactor;
Timer? _updateTimer;
CollectionLens get collection => widget.collection;
List<AvesEntry> get entries => collection.sortedEntries;
List<T> get items => widget.items;
ScrollController get scrollController => widget.scrollController;
@ -52,15 +49,17 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
@override
Widget build(BuildContext context) {
final selectable = widget.selectable;
return GestureDetector(
onLongPressStart: widget.selectable
onLongPressStart: selectable
? (details) {
final fromEntry = _getEntryAt(details.localPosition);
if (fromEntry == null) return;
final fromItem = _getItemAt(details.localPosition);
if (fromItem == null) return;
collection.toggleSelection(fromEntry);
_selecting = collection.isSelected([fromEntry]);
_fromIndex = entries.indexOf(fromEntry);
final selection = context.read<Selection<T>>();
selection.toggleSelection(fromItem);
_selecting = selection.isSelected([fromItem]);
_fromIndex = items.indexOf(fromItem);
_lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only(
top: appBarHeight,
@ -70,20 +69,29 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
_pressing = true;
}
: null,
onLongPressMoveUpdate: widget.selectable
onLongPressMoveUpdate: selectable
? (details) {
if (!_pressing) return;
_localPosition = details.localPosition;
_onLongPressUpdate();
}
: null,
onLongPressEnd: widget.selectable
onLongPressEnd: selectable
? (details) {
if (!_pressing) return;
_setScrollSpeed(0);
_pressing = false;
}
: null,
onTapUp: selectable && context.select<Selection<T>, bool>((selection) => selection.isSelecting)
? (details) {
final item = _getItemAt(details.localPosition);
if (item == null) return;
final selection = context.read<Selection<T>>();
selection.toggleSelection(item);
}
: null,
child: widget.child,
);
}
@ -102,9 +110,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
_setScrollSpeed(0);
}
final toEntry = _getEntryAt(_localPosition);
if (toEntry != null) {
_toggleSelectionToIndex(entries.indexOf(toEntry));
final toItem = _getItemAt(_localPosition);
if (toItem != null) {
_toggleSelectionToIndex(items.indexOf(toItem));
}
}
@ -128,47 +136,48 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
duration: Duration(milliseconds: millis.round()),
curve: Curves.linear,
);
// use a timer to update the entry selection, because `onLongPressMoveUpdate`
// use a timer to update the selection, because `onLongPressMoveUpdate`
// is not called when the pointer stays still while the view is scrolling
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
}
}
AvesEntry? _getEntryAt(Offset localPosition) {
T? _getItemAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry.
// so we use custom layout computation instead to find the item.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
final sectionedListLayout = context.read<SectionedListLayout<T>>();
return sectionedListLayout.getItemAt(offset);
}
void _toggleSelectionToIndex(int toIndex) {
if (toIndex == -1) return;
final selection = context.read<Selection<T>>();
if (_selecting) {
if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) {
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
selection.addToSelection(items.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_fromIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1));
selection.removeFromSelection(items.getRange(_fromIndex + 1, _lastToIndex + 1));
}
} else if (_lastToIndex < toIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
selection.removeFromSelection(items.getRange(_lastToIndex, toIndex));
}
} else if (_fromIndex < toIndex) {
if (_lastToIndex < toIndex) {
collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
selection.addToSelection(items.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
if (_lastToIndex < _fromIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
selection.removeFromSelection(items.getRange(_lastToIndex, _fromIndex));
}
} else if (toIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
selection.removeFromSelection(items.getRange(toIndex + 1, _lastToIndex + 1));
}
}
_lastToIndex = toIndex;
} else {
collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
selection.removeFromSelection(items.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
}
}
}

View file

@ -4,12 +4,12 @@ import 'package:aves/model/settings/settings.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget {
class GridTheme extends StatelessWidget {
final double extent;
final bool? showLocation;
final Widget child;
const ThumbnailTheme({
const GridTheme({
Key? key,
required this.extent,
this.showLocation,
@ -18,12 +18,12 @@ class ThumbnailTheme extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ProxyProvider<Settings, ThumbnailThemeData>(
return ProxyProvider<Settings, GridThemeData>(
update: (_, settings, __) {
final iconSize = min(28.0, (extent / 4)).roundToDouble();
final fontSize = (iconSize / 2).floorToDouble();
final highlightBorderWidth = extent * .1;
return ThumbnailThemeData(
return GridThemeData(
iconSize: iconSize,
fontSize: fontSize,
highlightBorderWidth: highlightBorderWidth,
@ -37,11 +37,11 @@ class ThumbnailTheme extends StatelessWidget {
}
}
class ThumbnailThemeData {
class GridThemeData {
final double iconSize, fontSize, highlightBorderWidth;
final bool showLocation, showRaw, showVideoDuration;
const ThumbnailThemeData({
const GridThemeData({
required this.iconSize,
required this.fontSize,
required this.highlightBorderWidth,

View file

@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
@ -23,6 +23,7 @@ class AvesFilterChip extends StatefulWidget {
final bool removable;
final bool showGenericIcon;
final Widget? background;
final String? banner;
final Widget? details;
final BorderRadius? borderRadius;
final double padding;
@ -43,6 +44,7 @@ class AvesFilterChip extends StatefulWidget {
this.removable = false,
this.showGenericIcon = true,
this.background,
this.banner,
this.details,
this.borderRadius,
this.padding = 6.0,
@ -195,6 +197,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
}
final borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner;
Widget chip = Container(
constraints: const BoxConstraints(
minWidth: AvesFilterChip.minChipWidth,
@ -209,51 +212,62 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius,
child: widget.background,
),
Tooltip(
message: filter.getTooltip(context),
preferBelow: false,
child: Material(
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: onTap != null
? () {
WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
setState(() => _tapped = true);
}
: null,
onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
_outlineColor = snapshot.data!;
Material(
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: onTap != null
? () {
WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
setState(() => _tapped = true);
}
return DecoratedBox(
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: _outlineColor,
width: AvesFilterChip.outlineWidth,
)),
borderRadius: borderRadius,
),
position: DecorationPosition.foreground,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: content,
),
);
},
),
: null,
onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
_outlineColor = snapshot.data!;
}
return DecoratedBox(
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: _outlineColor,
width: AvesFilterChip.outlineWidth,
)),
borderRadius: borderRadius,
),
position: DecorationPosition.foreground,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: content,
),
);
},
),
),
),
if (banner != null)
LayoutBuilder(builder: (context, constraints) {
return ClipRRect(
borderRadius: borderRadius,
child: Transform(
transform: Matrix4.identity().scaled((constraints.maxHeight / 90 - .4).clamp(.45, 1.0)),
child: Banner(
message: banner.toUpperCase(),
location: BannerLocation.topStart,
color: Theme.of(context).accentColor,
child: const SizedBox(),
),
),
);
}),
],
),
);

View file

@ -5,7 +5,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -20,11 +20,11 @@ class VideoIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
final thumbnailTheme = context.watch<ThumbnailThemeData>();
final showDuration = thumbnailTheme.showVideoDuration;
final gridTheme = context.watch<GridThemeData>();
final showDuration = gridTheme.showVideoDuration;
Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
size: thumbnailTheme.iconSize,
size: gridTheme.iconSize,
text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1,
);
@ -32,7 +32,7 @@ class VideoIcon extends StatelessWidget {
child = DefaultTextStyle(
style: TextStyle(
color: Colors.grey.shade200,
fontSize: thumbnailTheme.fontSize,
fontSize: gridTheme.fontSize,
),
child: child,
);
@ -48,7 +48,7 @@ class AnimatedImageIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.animated,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8,
);
}
@ -61,7 +61,7 @@ class GeotiffIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.geo,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -73,7 +73,7 @@ class SphericalImageIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.threeSixty,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -85,7 +85,7 @@ class GpsIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.location,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -97,7 +97,7 @@ class RawIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.raw,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -112,10 +112,21 @@ class MultiPageIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
IconData icon;
String? text;
if (entry.isMotionPhoto) {
icon = AIcons.motionPhoto;
} else {
if(entry.isBurst) {
text = '${entry.burstEntries?.length}';
}
icon = AIcons.multiPage;
}
return OverlayIcon(
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
icon: icon,
size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8,
text: text,
);
}
}

View file

@ -76,8 +76,6 @@ class AvesLogoPainter extends CustomPainter {
path3.relativeArcToPoint(Offset(dim * 1.917, dim * -4.63), radius: Radius.circular(dim * 2.712), rotation: 112.5, clockwise: false);
path3.close();
canvas.drawPath(
path0,
Paint()

View file

@ -0,0 +1,20 @@
import 'package:aves/model/selection.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class SelectionProvider<T> extends StatelessWidget {
final Widget child;
const SelectionProvider({
Key? key,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<Selection<T>>(
create: (context) => Selection<T>(),
child: child,
);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:ui' as ui;
import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:collection/collection.dart';
@ -79,7 +80,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
builder: (extent) => SizedBox(
width: extent,
height: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
child: GridTheme(
extent: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
),
),
center: thumbnailCenter,
viewportWidth: gridWidth,

View file

@ -7,7 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -89,7 +89,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 16),
child: DecoratedFilterChip(
child: CoveredFilterChip(
filter: filter,
extent: extent,
coverEntry: _isCustom ? _customEntry : _recentEntry,

View file

@ -130,7 +130,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
// when the field gets focus, we wait for the soft keyboard to appear
// then scroll to the bottom to make sure the field is in view
if (_nameFieldFocusNode.hasFocus) {
await Future.delayed(Durations.softKeyboardDisplayDelay);
await Future.delayed(Durations.softKeyboardDisplayDelay + const Duration(milliseconds: 500));
_scrollToBottom();
}
}

View file

@ -1,9 +1,11 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -36,13 +38,15 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
value: ValueNotifier(AppMode.pickInternal),
child: MediaQueryDataProvider(
child: Scaffold(
body: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: const CollectionGrid(
settingsRouteKey: CollectionPage.routeName,
body: SelectionProvider<AvesEntry>(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: collection,
child: const CollectionGrid(
settingsRouteKey: CollectionPage.routeName,
),
),
),
),

View file

@ -1,6 +1,7 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart';
@ -12,9 +13,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -46,37 +48,42 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
@override
Widget build(BuildContext context) {
Widget appBar = AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(),
queryNotifier: _queryNotifier,
);
return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>(
selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor),
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterGridPage<AlbumFilter>(
settingsRouteKey: AlbumListPage.routeName,
appBar: appBar,
appBarHeight: AlbumPickAppBar.preferredHeight,
filterSections: AlbumListPage.getAlbumEntries(context, source),
sortFactor: settings.albumSortFactor,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
queryNotifier: _queryNotifier,
applyQuery: (filters, query) {
if (query.isEmpty) return filters;
query = query.toUpperCase();
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
},
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album),
),
builder: (context, snapshot) {
final gridItems = AlbumListPage.getAlbumGridItems(context, source);
return SelectionProvider<FilterGridItem<AlbumFilter>>(
child: FilterGridPage<AlbumFilter>(
settingsRouteKey: AlbumListPage.routeName,
appBar: AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(gridItems),
queryNotifier: _queryNotifier,
),
appBarHeight: AlbumPickAppBar.preferredHeight,
sections: AlbumListPage.groupToSections(context, source, gridItems),
newFilters: source.getNewAlbumFilters(context),
sortFactor: settings.albumSortFactor,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
selectable: false,
queryNotifier: _queryNotifier,
applyQuery: (filters, query) {
if (query.isEmpty) return filters;
query = query.toUpperCase();
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
},
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album),
),
);
},
);
},
);
@ -156,7 +163,7 @@ class AlbumPickAppBar extends StatelessWidget {
FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, action));
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
},
),
],

View file

@ -1,4 +1,3 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
@ -9,8 +8,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
@ -38,32 +36,23 @@ class AlbumListPage extends StatelessWidget {
animation: androidFileUtils.appNameChangeNotifier,
builder: (context, child) => StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
source: source,
title: context.l10n.albumPageTitle,
sortFactor: settings.albumSortFactor,
groupable: true,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
chipSetActionDelegate: AlbumChipSetActionDelegate(),
chipActionDelegate: AlbumChipActionDelegate(),
chipActionsBuilder: (filter) {
final dir = VolumeRelativeDirectory.fromPath(filter.album);
// do not allow renaming volume root
final canRename = dir != null && dir.relativeDir.isNotEmpty;
return [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
if (canRename) ChipAction.rename,
ChipAction.delete,
ChipAction.hide,
];
},
filterSections: getAlbumEntries(context, source),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
),
builder: (context, snapshot) {
final gridItems = getAlbumGridItems(context, source);
return FilterNavigationPage<AlbumFilter>(
source: source,
title: context.l10n.albumPageTitle,
sortFactor: settings.albumSortFactor,
groupable: true,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
actionDelegate: AlbumChipSetActionDelegate(gridItems),
filterSections: groupToSections(context, source, gridItems),
newFilters: source.getNewAlbumFilters(context),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
);
},
),
);
},
@ -72,18 +61,27 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(BuildContext context, CollectionSource source) {
static List<FilterGridItem<AlbumFilter>> getAlbumGridItems(BuildContext context, CollectionSource source) {
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
return _group(context, sorted);
return FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> _group(BuildContext context, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> groupToSections(BuildContext context, CollectionSource source, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
final newFilters = source.getNewAlbumFilters(context);
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = byPin[true] ?? [];
final unpinnedMapEntries = byPin[false] ?? [];
final List<FilterGridItem<AlbumFilter>> newMapEntries = [], pinnedMapEntries = [], unpinnedMapEntries = [];
for (final item in sortedMapEntries) {
final filter = item.filter;
if (newFilters.contains(filter)) {
newMapEntries.add(item);
} else if (pinned.contains(filter)) {
pinnedMapEntries.add(item);
} else {
unpinnedMapEntries.add(item);
}
}
var sections = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
switch (settings.albumGroupFactor) {
@ -116,8 +114,9 @@ class AlbumListPage extends StatelessWidget {
break;
case AlbumChipGroupFactor.none:
return {
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
if (sortedMapEntries.isNotEmpty)
const ChipSectionKey(): [
...newMapEntries,
...pinnedMapEntries,
...unpinnedMapEntries,
],
@ -131,6 +130,13 @@ class AlbumListPage extends StatelessWidget {
]);
}
if (newMapEntries.isNotEmpty) {
sections = Map.fromEntries([
MapEntry(AlbumImportanceSectionKey.newAlbum(context), newMapEntries),
...sections.entries,
]);
}
return sections;
}
}

View file

@ -1,151 +1,148 @@
import 'dart:io';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) {
case ChipAction.pin:
settings.pinnedFilters = settings.pinnedFilters..add(filter);
break;
case ChipAction.unpin:
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
break;
case ChipAction.hide:
_hide(context, filter);
break;
case ChipAction.setCover:
_showCoverSelectionDialog(context, filter);
break;
case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage());
break;
case ChipAction.goToCountryPage:
_goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage());
break;
case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
break;
default:
break;
}
}
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
final Iterable<FilterGridItem<AlbumFilter>> _items;
Future<void> _hide(BuildContext context, CollectionFilter filter) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.hideButtonLabel),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
final source = context.read<CollectionSource>();
source.changeFilterVisibility(filter, false);
}
void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
context: context,
builder: (context) => CoverSelectionDialog(
filter: filter,
customEntry: customEntry,
),
);
if (coverSelection == null) return;
final isCustom = coverSelection.item1;
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
}
void _goTo(
BuildContext context,
CollectionFilter filter,
String routeName,
WidgetBuilder pageBuilder,
) {
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
}
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
@override
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
super.onActionSelected(context, filter, action);
Iterable<FilterGridItem<AlbumFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
case ChipAction.delete:
_showDeleteDialog(context, filter as AlbumFilter);
case ChipSetAction.delete:
case ChipSetAction.rename:
return true;
default:
return super.isValid(filters, action);
}
}
@override
bool canApply(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
case ChipSetAction.rename:
{
if (filters.length != 1) return false;
// do not allow renaming volume root
final dir = VolumeRelativeDirectory.fromPath(filters.first.album);
return dir != null && dir.relativeDir.isNotEmpty;
}
default:
return super.canApply(filters, action);
}
}
@override
void onActionSelected(BuildContext context, Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
// general
case ChipSetAction.group:
_showGroupDialog(context);
break;
case ChipAction.rename:
_showRenameDialog(context, filter as AlbumFilter);
case ChipSetAction.createAlbum:
_createAlbum(context);
break;
// single/multiple filters
case ChipSetAction.delete:
_showDeleteDialog(context, filters);
break;
// single filter
case ChipSetAction.rename:
_showRenameDialog(context, filters.first);
break;
default:
break;
}
super.onActionSelected(context, filters, action);
}
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
Future<void> _showGroupDialog(BuildContext context) async {
final factor = await showDialog<AlbumChipGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
initialValue: settings.albumGroupFactor,
options: {
AlbumChipGroupFactor.importance: context.l10n.albumGroupTier,
AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume,
AlbumChipGroupFactor.none: context.l10n.albumGroupNone,
},
title: context.l10n.albumGroupTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
settings.albumGroupFactor = factor;
}
}
void _createAlbum(BuildContext context) async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => const CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
final source = context.read<CollectionSource>();
source.createAlbum(newAlbum);
final showAction = SnackBarAction(
label: context.l10n.showButtonLabel,
onPressed: () async {
final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum));
context.read<HighlightInfo>().trackItem(FilterGridItem(filter, null), highlightItem: filter);
},
);
showFeedback(context, context.l10n.genericSuccessFeedback, showAction);
}
}
Future<void> _showDeleteDialog(BuildContext context, Set<AlbumFilter> filters) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>();
final album = filter.album;
final todoEntries = source.visibleEntries.where(filter.test).toSet();
final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
final todoCount = todoEntries.length;
final todoAlbums = filters.map((v) => v.album).toSet();
final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet();
final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).toSet();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)),
content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -161,7 +158,10 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
);
if (confirmed == null || !confirmed) return;
if (!await checkStoragePermissionForAlbums(context, {album})) return;
source.forgetNewAlbums(todoAlbums);
source.cleanEmptyAlbums(emptyAlbums);
if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return;
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
@ -180,7 +180,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
}
// cleanup
await storageService.deleteEmptyDirectories({album});
await storageService.deleteEmptyDirectories(filledAlbums);
},
);
}

View file

@ -0,0 +1,75 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) {
case ChipAction.hide:
_hide(context, filter);
break;
case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage());
break;
case ChipAction.goToCountryPage:
_goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage());
break;
case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
break;
default:
break;
}
}
Future<void> _hide(BuildContext context, CollectionFilter filter) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.hideButtonLabel),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
final source = context.read<CollectionSource>();
source.changeFilterVisibility({filter}, false);
}
void _goTo(
BuildContext context,
CollectionFilter filter,
String routeName,
WidgetBuilder pageBuilder,
) {
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
}

View file

@ -0,0 +1,181 @@
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Iterable<FilterGridItem<T>> get allItems;
ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor);
bool isValid(Set<T> filters, ChipSetAction action) {
final hasSelection = filters.isNotEmpty;
switch (action) {
case ChipSetAction.delete:
case ChipSetAction.rename:
return false;
case ChipSetAction.pin:
return !hasSelection || !settings.pinnedFilters.containsAll(filters);
case ChipSetAction.unpin:
return hasSelection && settings.pinnedFilters.containsAll(filters);
default:
return true;
}
}
bool canApply(Set<T> filters, ChipSetAction action) {
switch (action) {
// general
case ChipSetAction.sort:
case ChipSetAction.group:
case ChipSetAction.select:
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
case ChipSetAction.stats:
case ChipSetAction.createAlbum:
return true;
// single/multiple filters
case ChipSetAction.delete:
case ChipSetAction.hide:
case ChipSetAction.pin:
case ChipSetAction.unpin:
return filters.isNotEmpty;
// single filter
case ChipSetAction.rename:
case ChipSetAction.setCover:
return filters.length == 1;
}
}
void onActionSelected(BuildContext context, Set<T> filters, ChipSetAction action) {
switch (action) {
// general
case ChipSetAction.sort:
_showSortDialog(context);
break;
case ChipSetAction.stats:
_goToStats(context);
break;
case ChipSetAction.select:
context.read<Selection<FilterGridItem<T>>>().select();
break;
case ChipSetAction.selectAll:
context.read<Selection<FilterGridItem<T>>>().addToSelection(allItems);
break;
case ChipSetAction.selectNone:
context.read<Selection<FilterGridItem<T>>>().clearSelection();
break;
// single/multiple filters
case ChipSetAction.pin:
settings.pinnedFilters = settings.pinnedFilters..addAll(filters);
break;
case ChipSetAction.unpin:
settings.pinnedFilters = settings.pinnedFilters..removeAll(filters);
break;
case ChipSetAction.hide:
_hide(context, filters);
break;
// single filter
case ChipSetAction.setCover:
_showCoverSelectionDialog(context, filters.first);
break;
default:
break;
}
}
Future<void> _showSortDialog(BuildContext context) async {
final factor = await showDialog<ChipSortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<ChipSortFactor>(
initialValue: sortFactor,
options: {
ChipSortFactor.date: context.l10n.chipSortDate,
ChipSortFactor.name: context.l10n.chipSortName,
ChipSortFactor.count: context.l10n.chipSortCount,
},
title: context.l10n.chipSortTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
sortFactor = factor;
}
}
void _goToStats(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
source: source,
),
),
);
}
Future<void> _hide(BuildContext context, Set<T> filters) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.hideButtonLabel),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
final source = context.read<CollectionSource>();
source.changeFilterVisibility(filters, false);
}
void _showCoverSelectionDialog(BuildContext context, T filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
context: context,
builder: (context) => CoverSelectionDialog(
filter: filter,
customEntry: customEntry,
),
);
if (coverSelection == null) return;
final isCustom = coverSelection.item1;
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
}
}

View file

@ -0,0 +1,20 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
class CountryChipSetActionDelegate extends ChipSetActionDelegate<LocationFilter> {
final Iterable<FilterGridItem<LocationFilter>> _items;
CountryChipSetActionDelegate(Iterable<FilterGridItem<LocationFilter>> items) : _items = items;
@override
Iterable<FilterGridItem<LocationFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
}

View file

@ -0,0 +1,20 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
class TagChipSetActionDelegate extends ChipSetActionDelegate<TagFilter> {
final Iterable<FilterGridItem<TagFilter>> _items;
TagChipSetActionDelegate(Iterable<FilterGridItem<TagFilter>> items) : _items = items;
@override
Iterable<FilterGridItem<TagFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.tagSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor;
}

View file

@ -0,0 +1,240 @@
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class FilterGridAppBar<T extends CollectionFilter> extends StatefulWidget {
final CollectionSource source;
final String title;
final ChipSetActionDelegate actionDelegate;
final bool groupable, isEmpty;
const FilterGridAppBar({
Key? key,
required this.source,
required this.title,
required this.actionDelegate,
required this.groupable,
required this.isEmpty,
}) : super(key: key);
@override
_FilterGridAppBarState createState() => _FilterGridAppBarState<T>();
}
class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGridAppBar<T>> with SingleTickerProviderStateMixin {
late AnimationController _browseToSelectAnimation;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
CollectionSource get source => widget.source;
ChipSetActionDelegate get actionDelegate => widget.actionDelegate;
static const filterSelectionActions = [
ChipSetAction.setCover,
ChipSetAction.pin,
ChipSetAction.unpin,
ChipSetAction.delete,
ChipSetAction.rename,
ChipSetAction.hide,
];
static const buttonActionCount = 2;
@override
void initState() {
super.initState();
_browseToSelectAnimation = AnimationController(
duration: Durations.iconAnimation,
vsync: this,
);
_isSelectingNotifier.addListener(_onActivityChange);
}
@override
void dispose() {
_isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final selection = context.watch<Selection<FilterGridItem<T>>>();
final isSelecting = selection.isSelecting;
_isSelectingNotifier.value = isSelecting;
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting),
actions: _buildActions(appMode, selection),
titleSpacing: 0,
floating: true,
);
}
Widget _buildAppBarLeading(bool isSelecting) {
VoidCallback? onPressed;
String? tooltip;
if (isSelecting) {
onPressed = () => context.read<Selection<FilterGridItem<T>>>().browse();
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} else {
onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
}
return IconButton(
key: const Key('appbar-leading-button'),
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: _browseToSelectAnimation,
),
onPressed: onPressed,
tooltip: tooltip,
);
}
Widget? _buildAppBarTitle(bool isSelecting) {
if (isSelecting) {
return Selector<Selection<FilterGridItem<T>>, int>(
selector: (context, selection) => selection.selection.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
);
} else {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return InteractiveAppBarTitle(
onTap: appMode.canSearch ? _goToSearch : null,
child: SourceStateAwareAppBarTitle(
title: Text(widget.title),
source: source,
),
);
}
}
List<Widget> _buildActions(AppMode appMode, Selection<FilterGridItem<T>> selection) {
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
PopupMenuItem<ChipSetAction> toMenuItem(ChipSetAction action, {bool enabled = true}) {
return PopupMenuItem(
value: action,
enabled: enabled && actionDelegate.canApply(selectedFilters, action),
child: MenuRow(
text: action.getText(context),
icon: action.getIcon(),
),
);
}
void applyAction(ChipSetAction action) {
actionDelegate.onActionSelected(context, selectedFilters, action);
if (filterSelectionActions.contains(action)) {
selection.browse();
}
}
final isSelecting = selection.isSelecting;
final selectionRowActions = <ChipSetAction>[];
final buttonActions = <Widget>[];
if (isSelecting) {
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList();
buttonActions.addAll(validActions.take(buttonActionCount).map(
(action) {
final enabled = actionDelegate.canApply(selectedFilters, action);
return IconButton(
icon: Icon(action.getIcon()),
onPressed: enabled ? () => applyAction(action) : null,
tooltip: action.getText(context),
);
},
));
selectionRowActions.addAll(validActions.skip(buttonActionCount));
} else if (appMode.canSearch) {
buttonActions.add(CollectionSearchButton(source: source));
}
return [
...buttonActions,
PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final menuItems = <PopupMenuEntry<ChipSetAction>>[
toMenuItem(ChipSetAction.sort),
if (widget.groupable) toMenuItem(ChipSetAction.group),
];
if (isSelecting) {
final selectedItems = selection.selection;
if (selectionRowActions.isNotEmpty) {
menuItems.add(const PopupMenuDivider());
menuItems.addAll(selectionRowActions.map(toMenuItem));
}
menuItems.addAll([
const PopupMenuDivider(),
toMenuItem(
ChipSetAction.selectAll,
enabled: selectedItems.length < actionDelegate.allItems.length,
),
toMenuItem(
ChipSetAction.selectNone,
enabled: selectedItems.isNotEmpty,
),
]);
} else if (appMode == AppMode.main) {
menuItems.addAll([
toMenuItem(
ChipSetAction.select,
enabled: !widget.isEmpty,
),
toMenuItem(ChipSetAction.stats),
toMenuItem(ChipSetAction.createAlbum),
]);
}
return menuItems;
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
},
),
];
}
void _onActivityChange() {
if (context.read<Selection<FilterGridItem<T>>>().isSelecting) {
_browseToSelectAnimation.forward();
} else {
_browseToSelectAnimation.reverse();
}
}
void _goToSearch() {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: source,
),
),
);
}
}

View file

@ -1,119 +0,0 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
abstract class ChipSetActionDelegate {
ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor);
void onActionSelected(BuildContext context, ChipSetAction action) {
switch (action) {
case ChipSetAction.sort:
_showSortDialog(context);
break;
case ChipSetAction.stats:
_goToStats(context);
break;
default:
break;
}
}
Future<void> _showSortDialog(BuildContext context) async {
final factor = await showDialog<ChipSortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<ChipSortFactor>(
initialValue: sortFactor,
options: {
ChipSortFactor.date: context.l10n.chipSortDate,
ChipSortFactor.name: context.l10n.chipSortName,
ChipSortFactor.count: context.l10n.chipSortCount,
},
title: context.l10n.chipSortTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
sortFactor = factor;
}
}
void _goToStats(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
source: source,
),
),
);
}
}
class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
void onActionSelected(BuildContext context, ChipSetAction action) {
switch (action) {
case ChipSetAction.group:
_showGroupDialog(context);
break;
default:
break;
}
super.onActionSelected(context, action);
}
Future<void> _showGroupDialog(BuildContext context) async {
final factor = await showDialog<AlbumChipGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
initialValue: settings.albumGroupFactor,
options: {
AlbumChipGroupFactor.importance: context.l10n.albumGroupTier,
AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume,
AlbumChipGroupFactor.none: context.l10n.albumGroupNone,
},
title: context.l10n.albumGroupTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
settings.albumGroupFactor = factor;
}
}
}
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
}
class TagChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.tagSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor;
}

View file

@ -16,29 +16,27 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget {
final CollectionFilter filter;
class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final T filter;
final double extent, thumbnailExtent;
final AvesEntry? coverEntry;
final bool pinned, highlightable;
final bool pinned;
final String? banner;
final FilterCallback? onTap;
final OffsetFilterCallback? onLongPress;
const DecoratedFilterChip({
const CoveredFilterChip({
Key? key,
required this.filter,
required this.extent,
double? thumbnailExtent,
this.coverEntry,
this.pinned = false,
this.highlightable = true,
this.banner,
this.onTap,
this.onLongPress,
}) : thumbnailExtent = thumbnailExtent ?? extent,
super(key: key);
@ -46,7 +44,7 @@ class DecoratedFilterChip extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<CollectionSource>(
builder: (context, source, child) {
switch (filter.runtimeType) {
switch (T) {
case AlbumFilter:
{
final album = (filter as AlbumFilter).album;
@ -89,41 +87,24 @@ class DecoratedFilterChip extends StatelessWidget {
extent: thumbnailExtent,
);
final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(radius(extent));
Widget child = AvesFilterChip(
filter: filter,
showGenericIcon: false,
background: backgroundImage,
details: _buildDetails(source, filter),
borderRadius: borderRadius,
padding: titlePadding,
onTap: onTap,
onLongPress: onLongPress,
);
child = Stack(
fit: StackFit.passthrough,
children: [
child,
if (highlightable)
ChipHighlightOverlay(
filter: filter,
extent: extent,
borderRadius: borderRadius,
),
],
);
child = SizedBox(
return SizedBox(
width: extent,
height: extent,
child: child,
child: AvesFilterChip(
filter: filter,
showGenericIcon: false,
background: backgroundImage,
banner: banner,
details: _buildDetails(source, filter),
borderRadius: BorderRadius.all(radius(extent)),
padding: titlePadding,
onTap: onTap,
onLongPress: null,
),
);
return child;
}
Widget _buildDetails(CollectionSource source, CollectionFilter filter) {
Widget _buildDetails(CollectionSource source, T filter) {
final padding = min<double>(8.0, extent / 16);
final iconSize = min<double>(14.0, extent / 8);
final fontSize = min<double>(14.0, extent / 6);

View file

@ -0,0 +1,43 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:flutter/widgets.dart';
class FilterChipGridDecorator<T extends CollectionFilter, U extends FilterGridItem<T>> extends StatelessWidget {
final U gridItem;
final double extent;
final Widget child;
const FilterChipGridDecorator({
Key? key,
required this.gridItem,
required this.extent,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.all(CoveredFilterChip.radius(extent));
return SizedBox(
width: extent,
height: extent,
child: Stack(
fit: StackFit.passthrough,
children: [
child,
GridItemSelectionOverlay<FilterGridItem<T>>(
item: gridItem,
borderRadius: borderRadius,
padding: EdgeInsets.all(extent / 24),
),
ChipHighlightOverlay(
filter: gridItem.filter,
extent: extent,
borderRadius: borderRadius,
),
],
),
);
}
}

View file

@ -1,8 +1,10 @@
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
@ -13,7 +15,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
@ -21,8 +25,9 @@ import 'package:aves/widgets/common/providers/tile_extent_controller_provider.da
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart';
import 'package:aves/widgets/filter_grids/common/filter_chip_grid_decorator.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:aves/widgets/filter_grids/common/section_layout.dart';
import 'package:collection/collection.dart';
@ -38,28 +43,29 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final String? settingsRouteKey;
final Widget appBar;
final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters;
final ChipSortFactor sortFactor;
final bool showHeaders;
final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGridPage({
Key? key,
this.settingsRouteKey,
required this.appBar,
this.appBarHeight = kToolbarHeight,
required this.filterSections,
required this.sections,
required this.newFilters,
required this.sortFactor,
required this.showHeaders,
required this.selectable,
required this.queryNotifier,
this.applyQuery,
required this.emptyBuilder,
required this.onTap,
this.onLongPress,
}) : super(key: key);
static const Color detailColor = Color(0xFFE0E0E0);
@ -68,24 +74,35 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,
filterSections: filterSections,
sortFactor: sortFactor,
showHeaders: showHeaders,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
onTap: onTap,
onLongPress: onLongPress,
body: WillPopScope(
onWillPop: () {
final selection = context.read<Selection<FilterGridItem<T>>>();
if (selection.isSelecting) {
selection.browse();
return SynchronousFuture(false);
}
return SynchronousFuture(true);
},
child: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,
sections: sections,
newFilters: newFilters,
sortFactor: sortFactor,
showHeaders: showHeaders,
selectable: selectable,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
onTap: onTap,
),
),
),
),
@ -102,28 +119,29 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
final String? settingsRouteKey;
final Widget appBar;
final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters;
final ChipSortFactor sortFactor;
final bool showHeaders;
final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGrid({
Key? key,
required this.settingsRouteKey,
required this.appBar,
required this.appBarHeight,
required this.filterSections,
required this.sections,
required this.newFilters,
required this.sortFactor,
required this.showHeaders,
required this.selectable,
required this.queryNotifier,
required this.applyQuery,
required this.emptyBuilder,
required this.onTap,
required this.onLongPress,
}) : super(key: key);
@override
@ -152,14 +170,15 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
child: _FilterGridContent<T>(
appBar: widget.appBar,
appBarHeight: widget.appBarHeight,
filterSections: widget.filterSections,
sections: widget.sections,
newFilters: widget.newFilters,
sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders,
selectable: widget.selectable,
queryNotifier: widget.queryNotifier,
applyQuery: widget.applyQuery,
emptyBuilder: widget.emptyBuilder,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
),
);
}
@ -167,14 +186,14 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters;
final ChipSortFactor sortFactor;
final bool showHeaders;
final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder;
final QueryTest<T>? applyQuery;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
@ -182,14 +201,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
Key? key,
required this.appBar,
required double appBarHeight,
required this.filterSections,
required this.sections,
required this.newFilters,
required this.sortFactor,
required this.showHeaders,
required this.selectable,
required this.queryNotifier,
required this.applyQuery,
required this.emptyBuilder,
required this.onTap,
required this.onLongPress,
}) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight;
}
@ -199,15 +219,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
return ValueListenableBuilder<String>(
valueListenable: queryNotifier,
builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
if (applyQuery == null) {
visibleFilterSections = filterSections;
visibleSections = sections;
} else {
visibleFilterSections = {};
filterSections.forEach((sectionKey, sectionFilters) {
visibleSections = {};
sections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery!(sectionFilters, query);
if (visibleFilters.isNotEmpty) {
visibleFilterSections[sectionKey] = visibleFilters.toList();
visibleSections[sectionKey] = visibleFilters.toList();
}
});
}
@ -216,48 +236,55 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false);
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return SectionedFilterListLayoutProvider<T>(
sections: visibleFilterSections,
showHeaders: showHeaders,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: tileSpacing,
tileExtent: tileExtent,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
final entry = gridItem.entry;
return MetaData(
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
child: DecoratedFilterChip(
key: Key(filter.key),
filter: filter,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
),
);
},
tileAnimationDelay: tileAnimationDelay,
child: _FilterSectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleFilterSections: visibleFilterSections,
sortFactor: sortFactor,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
});
return GridTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false);
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return SectionedFilterListLayoutProvider<T>(
sections: visibleSections,
showHeaders: showHeaders,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: tileSpacing,
tileExtent: tileExtent,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
return MetaData(
metaData: ScalerMetadata(gridItem),
child: FilterChipGridDecorator<T, FilterGridItem<T>>(
gridItem: gridItem,
extent: tileExtent,
child: CoveredFilterChip(
key: Key(filter.key),
filter: filter,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null,
onTap: onTap,
),
),
);
},
tileAnimationDelay: tileAnimationDelay,
child: _FilterSectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleSections: visibleSections,
sortFactor: sortFactor,
selectable: selectable,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
}),
);
},
);
return sectionedListLayoutProvider;
@ -269,16 +296,18 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
final ChipSortFactor sortFactor;
final bool selectable;
final Widget Function() emptyBuilder;
final ScrollController scrollController;
const _FilterSectionedContent({
required this.appBar,
required this.appBarHeightNotifier,
required this.visibleFilterSections,
required this.visibleSections,
required this.sortFactor,
required this.selectable,
required this.emptyBuilder,
required this.scrollController,
});
@ -293,7 +322,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
@override
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleSections => widget.visibleSections;
Widget Function() get emptyBuilder => widget.emptyBuilder;
@ -328,14 +357,23 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
child: scrollView,
);
return scaler;
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector<FilterGridItem<T>>(
selectable: isMainMode && widget.selectable,
items: visibleSections.values.expand((v) => v).toList(),
scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier,
child: scaler,
);
return selector;
}
Future<void> _checkInitHighlight() async {
final highlightInfo = context.read<HighlightInfo>();
final filter = highlightInfo.clear();
if (filter is T) {
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
final gridItem = visibleSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
if (gridItem != null) {
await Future.delayed(Durations.highlightScrollInitDelay);
highlightInfo.trackItem(gridItem, highlightItem: filter);
@ -367,19 +405,18 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
extent: extent,
spacing: tileSpacing,
borderWidth: AvesFilterChip.outlineWidth,
borderRadius: DecoratedFilterChip.radius(extent),
borderRadius: CoveredFilterChip.radius(extent),
color: Colors.grey.shade700,
),
child: child,
),
scaledBuilder: (item, extent) {
final filter = item.filter;
return DecoratedFilterChip(
return CoveredFilterChip(
filter: filter,
extent: extent,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
pinned: pinnedFilters.contains(filter),
highlightable: false,
);
},
highlightItem: (item) => item.filter,

View file

@ -1,39 +1,24 @@
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/filter_grids/common/app_bar.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
final CollectionSource source;
final String title;
final ChipSetActionDelegate chipSetActionDelegate;
final ChipSortFactor sortFactor;
final bool groupable, showHeaders;
final ChipActionDelegate chipActionDelegate;
final List<ChipAction> Function(T filter) chipActionsBuilder;
final ChipSetActionDelegate actionDelegate;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Set<T>? newFilters;
final Widget Function() emptyBuilder;
const FilterNavigationPage({
@ -43,114 +28,56 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
required this.sortFactor,
this.groupable = false,
this.showHeaders = false,
required this.chipSetActionDelegate,
required this.chipActionDelegate,
required this.chipActionsBuilder,
required this.actionDelegate,
required this.filterSections,
this.newFilters,
required this.emptyBuilder,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
return FilterGridPage<T>(
key: const Key('filter-grid-page'),
appBar: SliverAppBar(
title: InteractiveAppBarTitle(
onTap: () => _goToSearch(context),
child: SourceStateAwareAppBarTitle(
title: Text(title),
return SelectionProvider<FilterGridItem<T>>(
child: Builder(
builder: (context) => FilterGridPage<T>(
key: const Key('filter-grid-page'),
appBar: FilterGridAppBar<T>(
source: source,
title: title,
actionDelegate: actionDelegate,
groupable: groupable,
isEmpty: filterSections.isEmpty,
),
),
actions: _buildActions(context),
titleSpacing: 0,
floating: true,
),
filterSections: filterSections,
sortFactor: sortFactor,
showHeaders: showHeaders,
queryNotifier: ValueNotifier(''),
emptyBuilder: () => ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
},
),
onTap: (filter) => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: CollectionLens(
source: source,
filters: [filter],
),
sections: filterSections,
newFilters: newFilters ?? {},
sortFactor: sortFactor,
showHeaders: showHeaders,
selectable: true,
queryNotifier: ValueNotifier(''),
emptyBuilder: () => ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
},
),
onTap: (filter) => _goToCollection(context, filter),
),
),
onLongPress: isMainMode ? _showMenu as OffsetFilterCallback : null,
);
}
void _showMenu(BuildContext context, T filter, Offset? tapPosition) async {
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
const touchArea = Size(40, 40);
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect((tapPosition ?? Offset.zero) & touchArea, Offset.zero & overlay.size),
items: chipActionsBuilder(filter)
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
))
.toList(),
);
if (selectedAction != null) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipActionDelegate.onActionSelected(context, filter, selectedAction));
}
}
List<Widget> _buildActions(BuildContext context) {
return [
CollectionSearchButton(source: source),
PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: const Key('menu-sort'),
value: ChipSetAction.sort,
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
),
if (groupable)
PopupMenuItem(
value: ChipSetAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
),
PopupMenuItem(
value: ChipSetAction.stats,
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
),
];
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action));
},
),
];
}
void _goToSearch(BuildContext context) {
void _goToCollection(BuildContext context, CollectionFilter filter) {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: CollectionLens(
source: source,
filters: [filter],
),
));
),
),
);
}
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
@ -167,21 +94,23 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return a.filter.compareTo(b.filter);
}
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters.map((filter) => FilterGridItem(
filter,
source.recentEntry(filter),
));
static List<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters
.map((filter) => FilterGridItem(
filter,
source.recentEntry(filter),
))
.toList();
}
Iterable<FilterGridItem<T>> allMapEntries = {};
List<FilterGridItem<T>> allMapEntries = [];
switch (sortFactor) {
case ChipSortFactor.name:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName);
allMapEntries = toGridItem(source, filters)..sort(compareFiltersByName);
break;
case ChipSortFactor.date:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
allMapEntries = toGridItem(source, filters)..sort(compareFiltersByDate);
break;
case ChipSortFactor.count:
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));

View file

@ -2,7 +2,7 @@ import 'package:aves/widgets/common/grid/header.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:flutter/material.dart';
class FilterChipSectionHeader extends StatelessWidget {
class FilterChipSectionHeader<T> extends StatelessWidget {
final ChipSectionKey sectionKey;
const FilterChipSectionHeader({
@ -12,11 +12,10 @@ class FilterChipSectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SectionHeader(
return SectionHeader<T>(
sectionKey: sectionKey,
leading: sectionKey.leading,
title: sectionKey.title,
selectable: false,
);
}

View file

@ -32,6 +32,8 @@ class AlbumImportanceSectionKey extends ChipSectionKey {
AlbumImportanceSectionKey._private(BuildContext context, this.importance) : super(title: importance.getText(context));
factory AlbumImportanceSectionKey.newAlbum(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.newAlbum);
factory AlbumImportanceSectionKey.pinned(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.pinned);
factory AlbumImportanceSectionKey.special(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.special);
@ -44,11 +46,13 @@ class AlbumImportanceSectionKey extends ChipSectionKey {
Widget get leading => Icon(importance.getIcon());
}
enum AlbumImportance { pinned, special, apps, regular }
enum AlbumImportance { newAlbum, pinned, special, apps, regular }
extension ExtraAlbumImportance on AlbumImportance {
String getText(BuildContext context) {
switch (this) {
case AlbumImportance.newAlbum:
return context.l10n.albumTierNew;
case AlbumImportance.pinned:
return context.l10n.albumTierPinned;
case AlbumImportance.special:
@ -62,6 +66,8 @@ extension ExtraAlbumImportance on AlbumImportance {
IconData getIcon() {
switch (this) {
case AlbumImportance.newAlbum:
return AIcons.newTier;
case AlbumImportance.pinned:
return AIcons.pin;
case AlbumImportance.special:

View file

@ -41,7 +41,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
@override
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
return FilterChipSectionHeader(
return FilterChipSectionHeader<FilterGridItem<T>>(
sectionKey: sectionKey as ChipSectionKey,
);
}

View file

@ -1,4 +1,3 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
@ -8,8 +7,7 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/country_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class CountryListPage extends StatelessWidget {
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
source: source,
title: context.l10n.countryPageTitle,
sortFactor: settings.countrySortFactor,
chipSetActionDelegate: CountryChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide,
],
filterSections: _getCountryEntries(source),
emptyBuilder: () => EmptyContent(
icon: AIcons.location,
text: context.l10n.countryEmpty,
),
),
builder: (context, snapshot) {
final gridItems = _getGridItems(source);
return FilterNavigationPage<LocationFilter>(
source: source,
title: context.l10n.countryPageTitle,
sortFactor: settings.countrySortFactor,
actionDelegate: CountryChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems),
emptyBuilder: () => EmptyContent(
icon: AIcons.location,
text: context.l10n.countryEmpty,
),
);
},
);
},
);
}
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries(CollectionSource source) {
List<FilterGridItem<LocationFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
return _group(sorted);
return FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _group(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _groupToSections(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []);

View file

@ -1,4 +1,3 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.dart';
@ -8,8 +7,7 @@ import 'package:aves/model/source/tag.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/tag_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class TagListPage extends StatelessWidget {
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
source: source,
title: context.l10n.tagPageTitle,
sortFactor: settings.tagSortFactor,
chipSetActionDelegate: TagChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide,
],
filterSections: _getTagEntries(source),
emptyBuilder: () => EmptyContent(
icon: AIcons.tag,
text: context.l10n.tagEmpty,
),
),
builder: (context, snapshot) {
final gridItems = _getGridItems(source);
return FilterNavigationPage<TagFilter>(
source: source,
title: context.l10n.tagPageTitle,
sortFactor: settings.tagSortFactor,
actionDelegate: TagChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems),
emptyBuilder: () => EmptyContent(
icon: AIcons.tag,
text: context.l10n.tagEmpty,
),
);
},
);
},
);
}
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries(CollectionSource source) {
List<FilterGridItem<TagFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
return _group(sorted);
return FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _group(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _groupToSections(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
final pinned = settings.pinnedFilters.whereType<TagFilter>();
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []);

View file

@ -26,12 +26,13 @@ class CollectionSearchButton extends StatelessWidget {
void _goToSearch(BuildContext context) {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: source,
parentCollection: parentCollection,
),
));
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: source,
parentCollection: parentCollection,
),
),
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/path.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
@ -29,7 +30,7 @@ class HiddenFilterTile extends StatelessWidget {
}
class HiddenFilterPage extends StatelessWidget {
static const routeName = '/settings/hidden';
static const routeName = '/settings/hidden_filters';
const HiddenFilterPage({Key? key}) : super(key: key);
@ -41,7 +42,7 @@ class HiddenFilterPage extends StatelessWidget {
),
body: SafeArea(
child: Selector<Settings, Set<CollectionFilter>>(
selector: (context, s) => settings.hiddenFilters,
selector: (context, s) => settings.hiddenFilters.where((v) => v is! PathFilter).toSet(),
builder: (context, hiddenFilters, child) {
if (hiddenFilters.isEmpty) {
return Column(
@ -76,7 +77,7 @@ class HiddenFilterPage extends StatelessWidget {
.map((filter) => AvesFilterChip(
filter: filter,
removable: true,
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true),
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility({filter}, true),
onLongPress: null,
))
.toList(),

View file

@ -0,0 +1,118 @@
import 'package:aves/model/filters/path.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class HiddenPathTile extends StatelessWidget {
const HiddenPathTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(context.l10n.settingsHiddenPathsTile),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: HiddenPathPage.routeName),
builder: (context) => const HiddenPathPage(),
),
);
},
);
}
}
class HiddenPathPage extends StatelessWidget {
static const routeName = '/settings/hidden_paths';
const HiddenPathPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsHiddenPathsTitle),
actions: [
IconButton(
icon: const Icon(AIcons.addPath),
onPressed: () async {
final path = await storageService.selectDirectory();
if (path != null && path.isNotEmpty) {
context.read<CollectionSource>().changeFilterVisibility({PathFilter(path)}, false);
}
},
tooltip: context.l10n.addPathTooltip,
),
],
),
body: SafeArea(
child: Selector<Settings, Set<PathFilter>>(
selector: (context, s) => settings.hiddenFilters.whereType<PathFilter>().toSet(),
builder: (context, hiddenPaths, child) {
if (hiddenPaths.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Header(),
const Divider(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: EmptyContent(
icon: AIcons.hide,
text: context.l10n.settingsHiddenPathsEmpty,
),
),
),
],
);
}
final pathList = hiddenPaths.toList()..sort();
return ListView(
children: [
const _Header(),
const Divider(),
...pathList.map((pathFilter) => ListTile(
title: Text(pathFilter.path),
dense: true,
trailing: IconButton(
icon: const Icon(AIcons.clear),
onPressed: () {
context.read<CollectionSource>().changeFilterVisibility({pathFilter}, true);
},
tooltip: context.l10n.settingsHiddenPathsRemoveTooltip,
),
)),
],
);
},
),
),
);
}
}
class _Header extends StatelessWidget {
const _Header({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
const Icon(AIcons.info),
const SizedBox(width: 16),
Expanded(child: Text(context.l10n.settingsHiddenPathsBanner)),
],
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/settings/common/tile_leading.dart';
import 'package:aves/widgets/settings/privacy/access_grants.dart';
import 'package:aves/widgets/settings/privacy/hidden_filters.dart';
import 'package:aves/widgets/settings/privacy/hidden_paths.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -47,6 +48,7 @@ class PrivacySection extends StatelessWidget {
title: Text(context.l10n.settingsSaveSearchHistory),
),
const HiddenFilterTile(),
const HiddenPathTile(),
const StorageAccessTile(),
],
);

View file

@ -36,7 +36,7 @@ class VideoSection extends StatelessWidget {
if (!standalonePage)
SwitchListTile(
value: currentShowVideos,
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility({MimeFilter.video}, v),
title: Text(context.l10n.settingsVideoShowVideos),
),
const VideoActionsTile(),

View file

@ -1,5 +1,6 @@
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -9,6 +10,7 @@ import 'package:aves/widgets/settings/viewer/entry_background.dart';
import 'package:aves/widgets/settings/viewer/viewer_actions_editor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ViewerSection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier;
@ -20,11 +22,6 @@ class ViewerSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final currentShowOverlayMinimap = context.select<Settings, bool>((s) => s.showOverlayMinimap);
final currentShowOverlayInfo = context.select<Settings, bool>((s) => s.showOverlayInfo);
final currentShowOverlayShootingDetails = context.select<Settings, bool>((s) => s.showOverlayShootingDetails);
final currentImageBackground = context.select<Settings, EntryBackground>((s) => s.imageBackground);
return AvesExpansionTile(
leading: SettingsTileLeading(
icon: AIcons.image,
@ -35,30 +32,92 @@ class ViewerSection extends StatelessWidget {
showHighlight: false,
children: [
const ViewerActionsTile(),
SwitchListTile(
value: currentShowOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
Selector<Settings, bool>(
selector: (context, s) => s.showOverlayMinimap,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
),
),
SwitchListTile(
value: currentShowOverlayInfo,
onChanged: (v) => settings.showOverlayInfo = v,
title: Text(context.l10n.settingsViewerShowInformation),
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
Selector<Settings, bool>(
selector: (context, s) => s.showOverlayInfo,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.showOverlayInfo = v,
title: Text(context.l10n.settingsViewerShowInformation),
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
),
),
SwitchListTile(
value: currentShowOverlayShootingDetails,
onChanged: currentShowOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
title: Text(context.l10n.settingsViewerShowShootingDetails),
Selector<Settings, Tuple2<bool, bool>>(
selector: (context, s) => Tuple2(s.showOverlayInfo, s.showOverlayShootingDetails),
builder: (context, s, child) {
final showInfo = s.item1;
final current = s.item2;
return SwitchListTile(
value: current,
onChanged: showInfo ? (v) => settings.showOverlayShootingDetails = v : null,
title: Text(context.l10n.settingsViewerShowShootingDetails),
);
},
),
ListTile(
title: Text(context.l10n.settingsImageBackground),
trailing: EntryBackgroundSelector(
getter: () => currentImageBackground,
setter: (value) => settings.imageBackground = value,
Selector<Settings, bool>(
selector: (context, s) => s.enableOverlayBlurEffect,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.enableOverlayBlurEffect = v,
title: Text(context.l10n.settingsViewerEnableOverlayBlurEffect),
),
),
const _CutoutModeSwitch(),
Selector<Settings, EntryBackground>(
selector: (context, s) => s.imageBackground,
builder: (context, current, child) => ListTile(
title: Text(context.l10n.settingsImageBackground),
trailing: EntryBackgroundSelector(
getter: () => current,
setter: (value) => settings.imageBackground = value,
),
),
),
],
);
}
}
class _CutoutModeSwitch extends StatefulWidget {
const _CutoutModeSwitch({Key? key}) : super(key: key);
@override
_CutoutModeSwitchState createState() => _CutoutModeSwitchState();
}
class _CutoutModeSwitchState extends State<_CutoutModeSwitch> {
late Future<bool> _canSet;
@override
void initState() {
super.initState();
_canSet = windowService.canSetCutoutMode();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: _canSet,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return Selector<Settings, bool>(
selector: (context, s) => s.viewerUseCutout,
builder: (context, current, child) => SwitchListTile(
value: current,
onChanged: (v) => settings.viewerUseCutout = v,
title: Text(context.l10n.settingsViewerUseCutout),
),
);
}
return const SizedBox.shrink();
},
);
}
}

View file

@ -38,7 +38,6 @@ class ViewerActionEditorPage extends StatelessWidget {
EntryAction.export,
EntryAction.print,
EntryAction.rotateScreen,
EntryAction.viewSource,
EntryAction.flip,
EntryAction.rotateCCW,
EntryAction.rotateCW,

View file

@ -28,10 +28,9 @@ class StatsPage extends StatelessWidget {
final CollectionSource source;
final CollectionLens? parentCollection;
late final Set<AvesEntry> entries;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
Set<AvesEntry> get entries => parentCollection?.sortedEntries.toSet() ?? source.visibleEntries;
static const mimeDonutMinWidth = 124.0;
StatsPage({
@ -39,6 +38,7 @@ class StatsPage extends StatelessWidget {
required this.source,
this.parentCollection,
}) : super(key: key) {
entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries;
entries.forEach((entry) {
if (entry.hasAddress) {
final address = entry.addressDetails!;

View file

@ -0,0 +1,83 @@
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin {
final AvesEntry entry;
final Widget child;
const EmbeddedDataOpener({
Key? key,
required this.entry,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) {
_openEmbeddedData(context, notification);
return true;
},
child: child,
);
}
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
late Map fields;
switch (notification.source) {
case EmbeddedDataSource.motionPhotoVideo:
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
break;
case EmbeddedDataSource.videoCover:
fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
break;
case EmbeddedDataSource.xmp:
fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
break;
}
if (!fields.containsKey('mimeType') || !fields.containsKey('uri')) {
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
return;
}
final mimeType = fields['mimeType']!;
final uri = fields['uri']!;
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
// open with another app
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
if (!success) {
// fallback to sharing, so that the file can be saved somewhere
AndroidAppService.shareSingle(uri, mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
}
}));
return;
}
_openTempEntry(context, AvesEntry.fromMap(fields));
}
void _openTempEntry(BuildContext context, AvesEntry tempEntry) {
Navigator.push(
context,
TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (c, a, sa) => EntryViewerPage(
initialEntry: tempEntry,
),
),
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
class OpenEmbeddedDataNotification extends Notification {
final EmbeddedDataSource source;
final String? propPath;
final String? mimeType;
const OpenEmbeddedDataNotification._private({
required this.source,
this.propPath,
this.mimeType,
});
factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.motionPhotoVideo,
);
factory OpenEmbeddedDataNotification.videoCover() => const OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.videoCover,
);
factory OpenEmbeddedDataNotification.xmp({
required String propPath,
required String mimeType,
}) =>
OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.xmp,
propPath: propPath,
mimeType: mimeType,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}';
}

View file

@ -23,23 +23,15 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/debug/debug_page.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/printer.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final CollectionLens? collection;
final VoidCallback showInfo;
EntryActionDelegate({
required this.collection,
required this.showInfo,
});
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
switch (action) {
case EntryAction.toggleFavourite:
@ -52,7 +44,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
_showExportDialog(context, entry);
break;
case EntryAction.info:
showInfo();
ShowInfoNotification().dispatch(context);
break;
case EntryAction.rename:
_showRenameDialog(context, entry);
@ -100,6 +92,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.viewSource:
_goToSourceViewer(context, entry);
break;
case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break;
case EntryAction.debug:
_goToDebug(context, entry);
break;
@ -158,8 +153,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await entry.delete()) {
showFeedback(context, context.l10n.genericFailureFeedback);
} else {
if (collection != null) {
await collection!.source.removeEntries({entry.uri});
final source = context.read<CollectionSource>();
if (source.initialized) {
await source.removeEntries({entry.uri});
}
EntryDeletedNotification(entry).dispatch(context);
}
@ -186,7 +182,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selection = <AvesEntry>{};
if (entry.isMultiPage) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
final multiPageInfo = await entry.getMultiPageInfo();
if (multiPageInfo != null) {
if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
@ -212,8 +208,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
onDone: (processed) {
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length;
final _collection = collection;
final showAction = _collection != null && movedCount > 0
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
final showAction = isMainMode && movedCount > 0
? SnackBarAction(
label: context.l10n.showButtonLabel,
onPressed: () async {

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -12,7 +12,7 @@ class MultiEntryScroller extends StatefulWidget {
final CollectionLens collection;
final PageController pageController;
final ValueChanged<int> onPageChanged;
final void Function(String uri) onViewDisposed;
final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const MultiEntryScroller({
Key? key,
@ -44,27 +44,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
physics: const MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
onPageChanged: widget.onPageChanged,
itemBuilder: (context, index) {
final entry = entries[index];
final mainEntry = entries[index];
Widget? child;
if (entry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(entry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
child ??= _buildViewer(entry);
var child = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry),
)
: _buildViewer(mainEntry);
child = AnimatedBuilder(
animation: pageController,
@ -93,17 +80,11 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
}
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size,
builder: (c, mqSize, child) {
return EntryPageView(
key: const Key('imageview'),
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
onDisposed: () => widget.onViewDisposed(mainEntry.uri),
);
},
return EntryPageView(
key: const Key('imageview'),
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry),
);
}
@ -130,25 +111,12 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
Widget build(BuildContext context) {
super.build(context);
Widget? child;
if (mainEntry.isMultiPage) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
child = StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
child ??= _buildViewer();
var child = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildViewer(pageEntry: pageEntry),
)
: _buildViewer();
return MagnifierGestureDetectorScope(
axis: const [Axis.vertical],
@ -157,15 +125,9 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
}
Widget _buildViewer({AvesEntry? pageEntry}) {
return Selector<MediaQueryData, Size>(
selector: (c, mq) => mq.size,
builder: (c, mqSize, child) {
return EntryPageView(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
);
},
return EntryPageView(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
);
}

View file

@ -18,7 +18,7 @@ class ViewerVerticalPageView extends StatefulWidget {
final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImagePageRequested;
final void Function(String uri) onViewDisposed;
final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const ViewerVerticalPageView({
Key? key,
@ -98,7 +98,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
)
: const SizedBox();
final infoPage = NotificationListener<BackUpNotification>(
final infoPage = NotificationListener<ShowImageNotification>(
onNotification: (notification) {
widget.onImagePageRequested();
return true;

View file

@ -4,6 +4,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -23,15 +24,13 @@ class EntryViewerPage extends StatelessWidget {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: Provider<VideoConductor>(
create: (context) => VideoConductor(),
dispose: (context, value) => value.dispose(),
child: Provider<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: EntryViewerStack(
collection: collection,
initialEntry: initialEntry,
body: ViewStateConductorProvider(
child: VideoConductorProvider(
child: MultiPageConductorProvider(
child: EntryViewerStack(
collection: collection,
initialEntry: initialEntry,
),
),
),
),
@ -41,3 +40,61 @@ class EntryViewerPage extends StatelessWidget {
);
}
}
class ViewStateConductorProvider extends StatelessWidget {
final Widget? child;
const ViewStateConductorProvider({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProxyProvider<MediaQueryData, ViewStateConductor>(
create: (context) => ViewStateConductor(),
update: (context, mq, value) {
value!.viewportSize = mq.size;
return value;
},
dispose: (context, value) => value.dispose(),
child: child,
);
}
}
class VideoConductorProvider extends StatelessWidget {
final Widget? child;
const VideoConductorProvider({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Provider<VideoConductor>(
create: (context) => VideoConductor(),
dispose: (context, value) => value.dispose(),
child: child,
);
}
}
class MultiPageConductorProvider extends StatelessWidget {
final Widget? child;
const MultiPageConductorProvider({
Key? key,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Provider<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: child,
);
}
}

View file

@ -1,10 +1,8 @@
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
@ -12,28 +10,30 @@ import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/bottom/common.dart';
import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart';
import 'package:aves/widgets/viewer/overlay/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.dart';
import 'package:aves/widgets/viewer/visual/state.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryViewerStack extends StatefulWidget {
final CollectionLens? collection;
@ -49,7 +49,7 @@ class EntryViewerStack extends StatefulWidget {
_EntryViewerStackState createState() => _EntryViewerStackState();
}
class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
final ValueNotifier<AvesEntry?> _entryNotifier = ValueNotifier(null);
late int _currentHorizontalPage;
late ValueNotifier<int> _currentVerticalPage;
@ -60,9 +60,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
late Animation<double> _topOverlayScale, _bottomOverlayScale;
late Animation<Offset> _bottomOverlayOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late EntryActionDelegate _entryActionDelegate;
late VideoActionDelegate _videoActionDelegate;
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true;
@ -81,8 +80,19 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override
void initState() {
super.initState();
if (!settings.viewerUseCutout) {
windowService.setCutoutMode(false);
}
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
windowService.keepScreenOn(true);
}
// make sure initial entry is actually among the filtered collection entries
final entry = entries.contains(widget.initialEntry) ? widget.initialEntry : entries.firstOrNull;
// `initialEntry` may be a dynamic burst entry from another collection lens
// so it is, strictly speaking, not contained in the lens used by the viewer,
// but it can be found by content ID
final initialEntry = widget.initialEntry;
final entry = entries.firstWhereOrNull((v) => v.contentId == initialEntry.contentId) ?? entries.firstOrNull;
// opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry;
@ -109,20 +119,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
curve: Curves.easeOutQuad,
));
_overlayVisible.addListener(_onOverlayVisibleChange);
_entryActionDelegate = EntryActionDelegate(
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_videoActionDelegate = VideoActionDelegate(
collection: collection,
);
_initEntryControllers();
_initEntryControllers(entry);
_registerWidget(widget);
WidgetsBinding.instance!.addObserver(this);
WidgetsBinding.instance!.addPostFrameCallback((_) => _initOverlay());
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
windowService.keepScreenOn(true);
}
}
@override
@ -134,6 +137,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override
void dispose() {
_cleanEntryControllers(_entryNotifier.value);
_videoActionDelegate.dispose();
_overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange);
_verticalPager.removeListener(_onVerticalPageControllerChange);
@ -166,6 +171,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override
Widget build(BuildContext context) {
final viewStateConductor = context.read<ViewStateConductor>();
return WillPopScope(
onWillPop: () {
if (_currentVerticalPage.value == infoPage) {
@ -183,8 +189,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
} else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry);
}
@ -192,7 +196,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
},
child: NotificationListener<ToggleOverlayNotification>(
onNotification: (notification) {
_overlayVisible.value = !_overlayVisible.value;
_overlayVisible.value = notification.visible ?? !_overlayVisible.value;
return true;
},
child: Stack(
@ -205,7 +209,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged,
onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null),
onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
),
_buildTopOverlay(),
_buildBottomOverlay(),
@ -218,38 +222,36 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
);
}
void _updateViewState(String uri, ViewState? viewState) {
final viewStateNotifier = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri)?.item2;
viewStateNotifier?.value = viewState ?? ViewState.zero;
}
Widget _buildTopOverlay() {
Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: _entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink();
return ViewerTopOverlay(
mainEntry: mainEntry,
scale: _topOverlayScale,
canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
onActionSelected: (action) {
var targetEntry = mainEntry;
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
_entryActionDelegate.onActionSelected(context, targetEntry, action);
Widget _buildContent({AvesEntry? pageEntry}) {
return EmbeddedDataOpener(
entry: mainEntry,
child: ViewerTopOverlay(
mainEntry: mainEntry,
scale: _topOverlayScale,
canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
),
);
}
return NotificationListener<ShowInfoNotification>(
onNotification: (notification) {
_goToVerticalPage(infoPage);
return true;
},
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
child: mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: _buildContent(),
);
},
);
@ -284,14 +286,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
valueListenable: _entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink();
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) {
Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) {
final targetEntry = pageEntry ?? mainEntry;
Widget? child;
// a 360 video is both a video and a panorama but only the video controls are displayed
if (pageEntry.isVideo) {
return Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry),
if (targetEntry.isVideo) {
child = Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay(
entry: pageEntry,
entry: targetEntry,
controller: videoController,
scale: _bottomOverlayScale,
onActionSelected: (action) {
@ -301,40 +306,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
},
),
);
} else if (pageEntry.is360) {
return PanoramaOverlay(
entry: pageEntry,
} else if (targetEntry.is360) {
child = PanoramaOverlay(
entry: targetEntry,
scale: _bottomOverlayScale,
);
}
return null;
return child != null
? ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: child,
)
: null;
}
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
final extraBottomOverlay = multiPageController != null
? StreamBuilder<MultiPageInfo?>(
stream: multiPageController.infoStream,
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
if (multiPageInfo == null) return const SizedBox.shrink();
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return _buildExtraBottomOverlay(pageEntry) ?? const SizedBox();
},
);
})
: _buildExtraBottomOverlay(mainEntry);
final extraBottomOverlay = mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: multiPageController,
builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(),
)
: _buildExtraBottomOverlay();
return Column(
children: [
if (extraBottomOverlay != null)
ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: extraBottomOverlay,
),
if (extraBottomOverlay != null) extraBottomOverlay,
SlideTransition(
position: _bottomOverlayOffset,
child: ViewerBottomOverlay(
@ -421,7 +417,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page;
if (page == transitionPage) {
_entryActionDelegate.dismissFeedback(context);
dismissFeedback(context);
_popVisual();
} else if (page == infoPage) {
// prevent hero when viewer is offscreen
@ -463,10 +459,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
final newEntry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entryNotifier.value == newEntry) return;
_cleanEntryControllers(_entryNotifier.value);
_entryNotifier.value = newEntry;
_isEntryTracked = false;
await _pauseVideoControllers();
await _initEntryControllers();
await _initEntryControllers(newEntry);
}
void _popVisual() {
@ -507,11 +504,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
void _onLeave() {
_showSystemUI();
windowService.requestOrientation();
if (!settings.viewerUseCutout) {
windowService.setCutoutMode(true);
}
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
windowService.keepScreenOn(false);
}
_showSystemUI();
windowService.requestOrientation();
}
// system UI
@ -558,11 +559,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
// state controllers/monitors
Future<void> _initEntryControllers() async {
final entry = _entryNotifier.value;
Future<void> _initEntryControllers(AvesEntry? entry) async {
if (entry == null) return;
_initViewStateController(entry);
if (entry.isVideo) {
await _initVideoController(entry);
}
@ -571,17 +570,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
}
void _initViewStateController(AvesEntry entry) {
final uri = entry.uri;
var controller = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri);
if (controller != null) {
_viewStateNotifiers.remove(controller);
} else {
controller = Tuple2(uri, ValueNotifier<ViewState>(ViewState.zero));
}
_viewStateNotifiers.insert(0, controller);
while (_viewStateNotifiers.length > 3) {
_viewStateNotifiers.removeLast().item2.dispose();
void _cleanEntryControllers(AvesEntry? entry) {
if (entry == null) return;
if (entry.isMultiPage) {
_cleanMultiPageController(entry);
}
}
@ -626,11 +619,22 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}
}
_multiPageControllerPageListeners[multiPageController] = _onPageChange;
multiPageController.pageNotifier.addListener(_onPageChange);
await _onPageChange();
}
}
Future<void> _cleanMultiPageController(AvesEntry entry) async {
final multiPageController = _multiPageControllerPageListeners.keys.firstWhereOrNull((v) => v.entry == entry);
if (multiPageController != null) {
final _onPageChange = _multiPageControllerPageListeners.remove(multiPageController);
if (_onPageChange != null) {
multiPageController.pageNotifier.removeListener(_onPageChange);
}
}
}
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
// video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?)

View file

@ -3,14 +3,15 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/basic_section.dart';
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -34,9 +35,7 @@ class _InfoPageState extends State<InfoPage> {
final ScrollController _scrollController = ScrollController();
bool _scrollStartFromTop = false;
CollectionLens? get collection => widget.collection;
AvesEntry? get entry => widget.entryNotifier.value;
static const splitScreenWidthThreshold = 600;
@override
Widget build(BuildContext context) {
@ -47,31 +46,39 @@ class _InfoPageState extends State<InfoPage> {
bottom: false,
child: NotificationListener<ScrollNotification>(
onNotification: _handleTopScroll,
child: NotificationListener<OpenTempEntryNotification>(
onNotification: (notification) {
_openTempEntry(notification.entry);
return true;
},
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
return ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.entryNotifier,
builder: (context, entry, child) {
return entry != null
? _InfoPageContent(
collection: collection,
entry: entry,
isScrollingNotifier: widget.isScrollingNotifier,
scrollController: _scrollController,
split: mqWidth > 600,
goToViewer: _goToViewer,
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
return ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.entryNotifier,
builder: (context, mainEntry, child) {
if (mainEntry != null) {
Widget _buildContent({AvesEntry? pageEntry}) {
final targetEntry = pageEntry ?? mainEntry;
return EmbeddedDataOpener(
entry: targetEntry,
child: _InfoPageContent(
collection: widget.collection,
entry: targetEntry,
isScrollingNotifier: widget.isScrollingNotifier,
scrollController: _scrollController,
split: mqWidth > splitScreenWidthThreshold,
goToViewer: _goToViewer,
),
);
}
return mainEntry.isBurst
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: const SizedBox.shrink();
},
);
},
),
: _buildContent();
}
return const SizedBox();
},
);
},
),
),
),
@ -102,25 +109,13 @@ class _InfoPageState extends State<InfoPage> {
}
void _goToViewer() {
BackUpNotification().dispatch(context);
ShowImageNotification().dispatch(context);
_scrollController.animateTo(
0,
duration: Durations.viewerVerticalPageScrollAnimation,
curve: Curves.easeInOut,
);
}
void _openTempEntry(AvesEntry tempEntry) {
Navigator.push(
context,
TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (c, a, sa) => EntryViewerPage(
initialEntry: tempEntry,
),
),
);
}
}
class _InfoPageContent extends StatefulWidget {

View file

@ -1,12 +1,10 @@
import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@ -112,11 +110,8 @@ class InfoSearchDelegate extends SearchDelegate {
icon: AIcons.info,
text: context.l10n.viewerInfoSearchEmpty,
)
: NotificationListener<OpenTempEntryNotification>(
onNotification: (notification) {
_openTempEntry(context, notification.entry);
return true;
},
: EmbeddedDataOpener(
entry: entry,
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) => tiles[index],
@ -125,16 +120,4 @@ class InfoSearchDelegate extends SearchDelegate {
),
);
}
void _openTempEntry(BuildContext context, AvesEntry tempEntry) {
Navigator.push(
context,
TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (c, a, sa) => EntryViewerPage(
initialEntry: tempEntry,
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show more