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] ## [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 ## [v1.4.5] - 2021-07-08
### Added ### Added
- Video: added OGV/Theora/Vorbis support - Video: added OGV/Theora/Vorbis support

View file

@ -105,7 +105,7 @@ repositories {
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' 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.exifinterface:exifinterface:1.3.2'
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.caverock:androidsvg-aar:1.4' 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, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
@ -114,7 +115,7 @@ class MainActivity : FlutterActivity() {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) 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) onPermissionResult(requestCode, data?.data)
} }
} }
@ -196,6 +197,7 @@ class MainActivity : FlutterActivity() {
const val DELETE_PERMISSION_REQUEST = 2 const val DELETE_PERMISSION_REQUEST = 2
const val CREATE_FILE_REQUEST = 3 const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4 const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5
// permission request code to pending runnable // permission request code to pending runnable
val pendingResultHandlers = ConcurrentHashMap<Int, PendingResultHandler>() 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.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.metadata.MultiPage
import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ContentImageProvider import deckers.thibault.aves.model.provider.ContentImageProvider
import deckers.thibault.aves.model.provider.ImageProvider 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 // which is returned as a second XMP directory
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
try { try {
val pathParts = dataPropPath.split('/') val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
val propNs = XMP.namespaceForPropPath(dataPropPath)
val embedBytes: ByteArray = if (pathParts.size == 1) { xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first()
val propName = pathParts[0]
val propNs = XMP.namespaceForPropPath(propName)
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
} else { } else {
val structName = pathParts[0] xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let {
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 {
XMPUtils.decodeBase64(it.value) 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["Spherical Video"] = HashMap(GSpherical(bytes).describe())
metadataMap.remove(dirName) 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 bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
val fields = SonyVideoMetadata.parseUsmt(bytes) val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
if (fields.isNotEmpty()) { if (blocks.isNotEmpty()) {
dirMap.remove("Data") metadataMap.remove(dirName)
dirMap.putAll(fields) 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 package deckers.thibault.aves.channel.calls
import android.app.Activity import android.app.Activity
import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
@ -16,6 +17,8 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
"keepScreenOn" -> safe(call, result, ::keepScreenOn) "keepScreenOn" -> safe(call, result, ::keepScreenOn)
"isRotationLocked" -> safe(call, result, ::isRotationLocked) "isRotationLocked" -> safe(call, result, ::isRotationLocked)
"requestOrientation" -> safe(call, result, ::requestOrientation) "requestOrientation" -> safe(call, result, ::requestOrientation)
"canSetCutoutMode" -> result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
"setCutoutMode" -> safe(call, result, ::setCutoutMode)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -57,6 +60,24 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
result.success(true) 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 { companion object {
private val LOG_TAG = LogUtils.createTag<WindowHandler>() private val LOG_TAG = LogUtils.createTag<WindowHandler>()
const val CHANNEL = "deckers.thibault/aves/window" 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.PendingResultHandler
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -41,6 +42,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestVolumeAccess" -> requestVolumeAccess() "requestVolumeAccess" -> requestVolumeAccess()
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
else -> endOfStream() else -> endOfStream()
} }
} }
@ -128,6 +130,24 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) 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?) {} override fun onCancel(arguments: Any?) {}
private fun success(result: 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]+).*") 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_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 // directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_GPS = "GPS" // from metadata-extractor
@ -71,7 +71,7 @@ object Metadata {
// optional time zone // optional time zone
var timeZone: TimeZone? = null var timeZone: TimeZone? = null
val timeZoneMatcher = VIDEO_TIMEZONE_PATTERN.matcher(dateString) val timeZoneMatcher = VIDEO_TIME_ZONE_PATTERN.matcher(dateString)
if (timeZoneMatcher.find()) { if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z", "")}") timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z", "")}")
dateString = timeZoneMatcher.replaceAll("") dateString = timeZoneMatcher.replaceAll("")

View file

@ -10,6 +10,7 @@ import android.util.Log
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.XMP.getSafeLong import deckers.thibault.aves.metadata.XMP.getSafeLong
import deckers.thibault.aves.metadata.XMP.getSafeStructField
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -140,7 +141,23 @@ object MultiPage {
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
var offsetFromEnd: Long? = null 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 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.XMPError
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import com.adobe.internal.xmp.properties.XMPProperty
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import java.util.* import java.util.*
object XMP { object XMP {
@ -15,6 +17,15 @@ object XMP {
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" 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 PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/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 SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title" const val TITLE_PROP_NAME = "dc:title"
@ -26,11 +37,13 @@ object XMP {
private const val SPECIFIC_LANG = "en-US" private const val SPECIFIC_LANG = "en-US"
private val schemas = hashMapOf( private val schemas = hashMapOf(
"GAudio" to "http://ns.google.com/photos/1.0/audio/", "Container" to CONTAINER_SCHEMA_NS,
"GDepth" to "http://ns.google.com/photos/1.0/depthmap/", "GAudio" to GAUDIO_SCHEMA_NS,
"GImage" to "http://ns.google.com/photos/1.0/image/", "GDepth" to GDEPTH_SCHEMA_NS,
"GImage" to GIMAGE_SCHEMA_NS,
"Item" to CONTAINER_ITEM_SCHEMA_NS,
"xmp" to XMP_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]] fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
@ -44,9 +57,11 @@ object XMP {
// motion photo // 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 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 // panorama
// cf https://developers.google.com/streetview/spherical-metadata // cf https://developers.google.com/streetview/spherical-metadata
@ -79,7 +94,26 @@ object XMP {
fun XMPMeta.isMotionPhoto(): Boolean { fun XMPMeta.isMotionPhoto(): Boolean {
try { 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) { } catch (e: XMPException) {
if (e.errorCode != XMPError.BADSCHEMA) { if (e.errorCode != XMPError.BADSCHEMA) {
// `BADSCHEMA` code is reported when we check a property // `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) 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) @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun revokeDirectoryAccess(context: Context, path: String): Boolean { fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let { return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION releaseUriPermission(context, it)
context.contentResolver.releasePersistableUriPermission(it, flags)
true true
} ?: false } ?: false
} }
@ -158,4 +157,28 @@ object PermissionManager {
} }
return accessibleDirs 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 // 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)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
// cleanest API to get it // cleanest API to get it
PermissionManager.sanitizePersistedUriPermissions(context)
try { try {
val docUri = MediaStore.getDocumentUri(context, mediaUri) val docUri = MediaStore.getDocumentUri(context, mediaUri)
if (docUri != null) { if (docUri != null) {

View file

@ -6,7 +6,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { 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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.8' classpath 'com.google.gms:google-services:4.3.8'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

View file

@ -59,6 +59,8 @@
"@chipActionRename": {}, "@chipActionRename": {},
"chipActionSetCover": "Set cover", "chipActionSetCover": "Set cover",
"@chipActionSetCover": {}, "@chipActionSetCover": {},
"chipActionCreateAlbum": "Create album",
"@chipActionCreateAlbum": {},
"entryActionDelete": "Delete", "entryActionDelete": "Delete",
"@entryActionDelete": {}, "@entryActionDelete": {},
@ -80,6 +82,8 @@
"@entryActionShare": {}, "@entryActionShare": {},
"entryActionViewSource": "View source", "entryActionViewSource": "View source",
"@entryActionViewSource": {}, "@entryActionViewSource": {},
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
"@entryActionViewMotionPhotoVideo": {},
"entryActionEdit": "Edit with…", "entryActionEdit": "Edit with…",
"@entryActionEdit": {}, "@entryActionEdit": {},
"entryActionOpen": "Open with…", "entryActionOpen": "Open with…",
@ -165,6 +169,8 @@
"keepScreenOnAlways": "Always", "keepScreenOnAlways": "Always",
"@keepScreenOnAlways": {}, "@keepScreenOnAlways": {},
"albumTierNew": "New",
"@albumTierNew": {},
"albumTierPinned": "Pinned", "albumTierPinned": "Pinned",
"@albumTierPinned": {}, "@albumTierPinned": {},
"albumTierSpecial": "Common", "albumTierSpecial": "Common",
@ -272,8 +278,14 @@
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists", "renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
"@renameAlbumDialogLabelAlreadyExistsHelper": {}, "@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?}}", "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?}}",
"@deleteAlbumConfirmationDialogMessage": { "@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": { "placeholders": {
"count": {} "count": {}
} }
@ -509,6 +521,8 @@
"@createAlbumTooltip": {}, "@createAlbumTooltip": {},
"createAlbumButtonLabel": "CREATE", "createAlbumButtonLabel": "CREATE",
"@createAlbumButtonLabel": {}, "@createAlbumButtonLabel": {},
"newFilterBanner": "new",
"@newFilterBanner": {},
"countryPageTitle": "Countries", "countryPageTitle": "Countries",
"@countryPageTitle": {}, "@countryPageTitle": {},
@ -575,6 +589,10 @@
"@settingsViewerShowInformationSubtitle": {}, "@settingsViewerShowInformationSubtitle": {},
"settingsViewerShowShootingDetails": "Show shooting details", "settingsViewerShowShootingDetails": "Show shooting details",
"@settingsViewerShowShootingDetails": {}, "@settingsViewerShowShootingDetails": {},
"settingsViewerEnableOverlayBlurEffect": "Overlay blur effect",
"@settingsViewerEnableOverlayBlurEffect": {},
"settingsViewerUseCutout": "Use cutout area",
"@settingsViewerUseCutout": {},
"settingsViewerQuickActionsTile": "Quick actions", "settingsViewerQuickActionsTile": "Quick actions",
"@settingsViewerQuickActionsTile": {}, "@settingsViewerQuickActionsTile": {},
@ -630,11 +648,11 @@
"@settingsSubtitleThemeBackgroundColor": {}, "@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Background opacity", "settingsSubtitleThemeBackgroundOpacity": "Background opacity",
"@settingsSubtitleThemeBackgroundOpacity": {}, "@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentLeft": "Left", "settingsSubtitleThemeTextAlignmentLeft": "Left",
"@settingsSubtitleThemeTextAlignmentLeft": {}, "@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Center", "settingsSubtitleThemeTextAlignmentCenter": "Center",
"@settingsSubtitleThemeTextAlignmentCenter": {}, "@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsSubtitleThemeTextAlignmentRight": "Right", "settingsSubtitleThemeTextAlignmentRight": "Right",
"@settingsSubtitleThemeTextAlignmentRight": {}, "@settingsSubtitleThemeTextAlignmentRight": {},
"settingsSectionPrivacy": "Privacy", "settingsSectionPrivacy": "Privacy",
@ -653,6 +671,19 @@
"settingsHiddenFiltersEmpty": "No hidden filters", "settingsHiddenFiltersEmpty": "No hidden filters",
"@settingsHiddenFiltersEmpty": {}, "@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": "Storage access",
"@settingsStorageAccessTile": {}, "@settingsStorageAccessTile": {},
"settingsStorageAccessTitle": "Storage Access", "settingsStorageAccessTitle": "Storage Access",

View file

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

View file

@ -2,29 +2,16 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum ChipSetAction {
group,
sort,
stats,
}
enum ChipAction { enum ChipAction {
delete,
hide,
pin,
unpin,
rename,
setCover,
goToAlbumPage, goToAlbumPage,
goToCountryPage, goToCountryPage,
goToTagPage, goToTagPage,
hide,
} }
extension ExtraChipAction on ChipAction { extension ExtraChipAction on ChipAction {
String getText(BuildContext context) { String getText(BuildContext context) {
switch (this) { switch (this) {
case ChipAction.delete:
return context.l10n.chipActionDelete;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
return context.l10n.chipActionGoToAlbumPage; return context.l10n.chipActionGoToAlbumPage;
case ChipAction.goToCountryPage: case ChipAction.goToCountryPage:
@ -33,21 +20,11 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionGoToTagPage; return context.l10n.chipActionGoToTagPage;
case ChipAction.hide: case ChipAction.hide:
return context.l10n.chipActionHide; 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() { IconData getIcon() {
switch (this) { switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
return AIcons.album; return AIcons.album;
case ChipAction.goToCountryPage: case ChipAction.goToCountryPage:
@ -56,13 +33,6 @@ extension ExtraChipAction on ChipAction {
return AIcons.tag; return AIcons.tag;
case ChipAction.hide: case ChipAction.hide:
return AIcons.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, flip,
// vector // vector
viewSource, viewSource,
// motion photo,
viewMotionPhotoVideo,
// external // external
edit, edit,
open, open,
@ -42,6 +44,7 @@ class EntryActions {
EntryAction.export, EntryAction.export,
EntryAction.print, EntryAction.print,
EntryAction.viewSource, EntryAction.viewSource,
EntryAction.viewMotionPhotoVideo,
EntryAction.rotateScreen, EntryAction.rotateScreen,
]; ];
@ -87,6 +90,9 @@ extension ExtraEntryAction on EntryAction {
// vector // vector
case EntryAction.viewSource: case EntryAction.viewSource:
return context.l10n.entryActionViewSource; return context.l10n.entryActionViewSource;
// motion photo
case EntryAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// external // external
case EntryAction.edit: case EntryAction.edit:
return context.l10n.entryActionEdit; return context.l10n.entryActionEdit;
@ -132,6 +138,9 @@ extension ExtraEntryAction on EntryAction {
// vector // vector
case EntryAction.viewSource: case EntryAction.viewSource:
return AIcons.vector; return AIcons.vector;
// motion photo
case EntryAction.viewMotionPhotoVideo:
return AIcons.motionPhoto;
// external // external
case EntryAction.edit: case EntryAction.edit:
case EntryAction.open: 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/entry_cache.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart'; import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
@ -39,6 +40,8 @@ class AvesEntry {
CatalogMetadata? _catalogMetadata; CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails; AddressDetails? _addressDetails;
List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions // TODO TLAD make it dynamic if it depends on OS/lib versions
@ -64,6 +67,7 @@ class AvesEntry {
required int? dateModifiedSecs, required int? dateModifiedSecs,
required this.sourceDateTakenMillis, required this.sourceDateTakenMillis,
required int? durationMillis, required int? durationMillis,
this.burstEntries,
}) { }) {
this.path = path; this.path = path;
this.sourceTitle = sourceTitle; this.sourceTitle = sourceTitle;
@ -80,6 +84,7 @@ class AvesEntry {
String? path, String? path,
int? contentId, int? contentId,
int? dateModifiedSecs, int? dateModifiedSecs,
List<AvesEntry>? burstEntries,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry( final copied = AvesEntry(
@ -96,6 +101,7 @@ class AvesEntry {
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
burstEntries: burstEntries ?? this.burstEntries,
) )
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
@ -228,10 +234,6 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false; 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 canEdit => path != null;
bool get canRotateAndFlip => canEdit && canEditExif; 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: // compare by:
// 1) title ascending // 1) title ascending
// 2) extension 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/favourite.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
@ -22,6 +23,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
AlbumFilter.type, AlbumFilter.type,
LocationFilter.type, LocationFilter.type,
TagFilter.type, TagFilter.type,
PathFilter.type,
]; ];
static CollectionFilter? fromJson(String jsonString) { static CollectionFilter? fromJson(String jsonString) {
@ -43,6 +45,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
return QueryFilter.fromMap(jsonMap); return QueryFilter.fromMap(jsonMap);
case TagFilter.type: case TagFilter.type:
return TagFilter.fromMap(jsonMap); return TagFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
} }
} }
debugPrint('failed to parse filter from json=$jsonString'); debugPrint('failed to parse filter from json=$jsonString');
@ -65,7 +69,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
String getTooltip(BuildContext context) => getLabel(context); 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))); 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); final firstPage = _pages.removeAt(0);
_pages.insert(0, firstPage.copyWith(isDefault: true)); _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?; final projectionType = map['projectionType'] as String?;
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode) // handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
if (fHeight == null && cWidth != null && cHeight != null) { if (fHeight == null && fWidth != null && cHeight != null) {
// assume the cropped area is actually covering 360 degrees horizontally
// even when `croppedAreaLeft` is non zero
fWidth = cWidth;
fHeight = (fWidth / 2).round(); fHeight = (fWidth / 2).round();
cTop = ((fHeight - cHeight) / 2).round(); cTop = ((fHeight - cHeight) / 2).round();
cLeft = 0;
} }
Rect? croppedAreaRect; 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:convert';
import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums.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/settings/screen_on.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/device_service.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart'; import 'package:aves/utils/pedantic.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -61,13 +64,15 @@ class Settings extends ChangeNotifier {
static const hiddenFiltersKey = 'hidden_filters'; static const hiddenFiltersKey = 'hidden_filters';
// viewer // viewer
static const viewerQuickActionsKey = 'viewer_quick_actions';
static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayMinimapKey = 'show_overlay_minimap';
static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayInfoKey = 'show_overlay_info';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const viewerQuickActionsKey = 'viewer_quick_actions'; static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
static const videoQuickActionsKey = 'video_quick_actions'; static const viewerUseCutoutKey = 'viewer_use_cutout';
// video // video
static const videoQuickActionsKey = 'video_quick_actions';
static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec'; static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec';
static const enableVideoAutoPlayKey = 'video_auto_play'; static const enableVideoAutoPlayKey = 'video_auto_play';
static const videoLoopModeKey = 'video_loop'; 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 // app
bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false); bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false);
@ -192,9 +212,9 @@ class Settings extends ChangeNotifier {
// collection // 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); EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values);
@ -240,6 +260,10 @@ class Settings extends ChangeNotifier {
// viewer // 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); bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue); set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
@ -252,16 +276,20 @@ class Settings extends ChangeNotifier {
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); 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); List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values);
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList()); set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
// video
bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true); bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true);
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue); set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);
@ -452,6 +480,8 @@ class Settings extends ChangeNotifier {
case showOverlayMinimapKey: case showOverlayMinimapKey:
case showOverlayInfoKey: case showOverlayInfoKey:
case showOverlayShootingDetailsKey: case showOverlayShootingDetailsKey:
case enableOverlayBlurEffectKey:
case viewerUseCutoutKey:
case enableVideoHardwareAccelerationKey: case enableVideoHardwareAccelerationKey:
case enableVideoAutoPlayKey: case enableVideoAutoPlayKey:
case subtitleShowOutlineKey: case subtitleShowOutlineKey:

View file

@ -10,9 +10,12 @@ import 'package:flutter/widgets.dart';
mixin AlbumMixin on SourceBase { mixin AlbumMixin on SourceBase {
final Set<String?> _directories = {}; final Set<String?> _directories = {};
final Set<String> _newAlbums = {};
List<String> get rawAlbums => List.unmodifiable(_directories); 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) { int compareAlbumsByName(String a, String b) {
final ua = getAlbumDisplayName(null, a); final ua = getAlbumDisplayName(null, a);
final ub = getAlbumDisplayName(null, b); final ub = getAlbumDisplayName(null, b);
@ -109,7 +112,7 @@ mixin AlbumMixin on SourceBase {
} }
void cleanEmptyAlbums([Set<String?>? albums]) { 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) { if (emptyAlbums.isNotEmpty) {
_directories.removeAll(emptyAlbums); _directories.removeAll(emptyAlbums);
_notifyAlbumChange(); _notifyAlbumChange();
@ -148,6 +151,22 @@ mixin AlbumMixin on SourceBase {
AvesEntry? albumRecentEntry(AlbumFilter filter) { AvesEntry? albumRecentEntry(AlbumFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test)); 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 {} 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/section_keys.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'enums.dart'; import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin { class CollectionLens with ChangeNotifier {
final CollectionSource source; final CollectionSource source;
final Set<CollectionFilter> filters; final Set<CollectionFilter> filters;
EntryGroupFactor groupFactor; EntryGroupFactor sectionFactor;
EntrySortFactor sortFactor; EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier(); final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
int? id; int? id;
bool listenToSource; bool listenToSource;
@ -38,7 +39,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
this.id, this.id,
this.listenToSource = true, this.listenToSource = true,
}) : filters = (filters ?? {}).whereNotNull().toSet(), }) : filters = (filters ?? {}).whereNotNull().toSet(),
groupFactor = settings.collectionGroupFactor, sectionFactor = settings.collectionSectionFactor,
sortFactor = settings.collectionSortFactor { sortFactor = settings.collectionSortFactor {
id ??= hashCode; id ??= hashCode;
if (listenToSource) { if (listenToSource) {
@ -73,7 +74,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
int get entryCount => _filteredSortedEntries.length; 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>? _sortedEntries;
List<AvesEntry> get sortedEntries { List<AvesEntry> get sortedEntries {
@ -84,9 +85,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
bool get showHeaders { bool get showHeaders {
if (sortFactor == EntrySortFactor.size) return false; 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); final filterByAlbum = filters.any((f) => f is AlbumFilter);
if (albumSections && filterByAlbum) return false; if (albumSections && filterByAlbum) return false;
@ -113,9 +114,33 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
filterChangeNotifier.notifyListeners(); filterChangeNotifier.notifyListeners();
} }
final bool groupBursts = true;
void _applyFilters() { void _applyFilters() {
final entries = source.visibleEntries; final entries = source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); _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() { void _applySort() {
@ -132,10 +157,10 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
} }
} }
void _applyGroup() { void _applySection() {
switch (sortFactor) { switch (sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
switch (groupFactor) { switch (sectionFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break; break;
@ -168,11 +193,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
} }
// metadata change should also trigger a full refresh // metadata change should also trigger a full refresh
// as dates impact sorting and grouping // as dates impact sorting and sectioning
void _refresh() { void _refresh() {
_applyFilters(); _applyFilters();
_applySort(); _applySort();
_applyGroup(); _applySection();
} }
void _onFavouritesChanged() { void _onFavouritesChanged() {
@ -183,21 +208,21 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
void _onSettingsChanged() { void _onSettingsChanged() {
final newSortFactor = settings.collectionSortFactor; final newSortFactor = settings.collectionSortFactor;
final newGroupFactor = settings.collectionGroupFactor; final newSectionFactor = settings.collectionSectionFactor;
final needSort = sortFactor != newSortFactor; final needSort = sortFactor != newSortFactor;
final needGroup = needSort || groupFactor != newGroupFactor; final needSection = needSort || sectionFactor != newSectionFactor;
if (needSort) { if (needSort) {
sortFactor = newSortFactor; sortFactor = newSortFactor;
_applySort(); _applySort();
} }
if (needGroup) { if (needSection) {
groupFactor = newGroupFactor; sectionFactor = newSectionFactor;
_applyGroup(); _applySection();
} }
if (needSort || needGroup) { if (needSort || needSection) {
sortGroupChangeNotifier.notifyListeners(); sortSectionChangeNotifier.notifyListeners();
} }
} }
@ -206,62 +231,32 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
} }
void onEntryRemoved(Set<AvesEntry> entries) { 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 // 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 // as section order change would surprise the user while browsing
_filteredSortedEntries.removeWhere(entries.contains); _filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains);
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty))); sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
selection.removeAll(entries);
notifyListeners(); 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 pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter); final oldCoverContentId = covers.coverContentId(oldFilter);
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null; final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove( await updateAfterMove(
todoEntries: todoEntries, todoEntries: todoEntries,
copy: false, copy: false,
@ -275,13 +276,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return recentEntry(filter); return recentEntry(filter);
} }
void changeFilterVisibility(CollectionFilter filter, bool visible) { void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = settings.hiddenFilters;
if (visible) { if (visible) {
hiddenFilters.remove(filter); hiddenFilters.removeAll(filters);
} else { } else {
hiddenFilters.add(filter); hiddenFilters.addAll(filters);
settings.searchHistory = settings.searchHistory..remove(filter); settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
} }
settings.hiddenFilters = hiddenFilters; settings.hiddenFilters = hiddenFilters;
@ -292,10 +293,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateLocations(); updateLocations();
updateTags(); updateTags();
eventBus.fire(FilterVisibilityChangedEvent(filter, visible)); eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
if (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 { class FilterVisibilityChangedEvent {
final CollectionFilter filter; final Set<CollectionFilter> filters;
final bool visible; final bool visible;
const FilterVisibilityChangedEvent(this.filter, this.visible); const FilterVisibilityChangedEvent(this.filters, this.visible);
} }
class ProgressEvent { class ProgressEvent {

View file

@ -1,5 +1,3 @@
enum Activity { browse, select }
enum SourceState { loading, cataloguing, locating, ready } enum SourceState { loading, cataloguing, locating, ready }
enum ChipSortFactor { date, name, count } 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, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}); });
final pageMaps = (result as List).cast<Map>(); final pageMaps = ((result as List?) ?? []).cast<Map>();
if (entry.isMotionPhoto && pageMaps.isNotEmpty) { if (entry.isMotionPhoto && pageMaps.isNotEmpty) {
final imagePage = pageMaps[0]; final imagePage = pageMaps[0];
imagePage['width'] = entry.width; imagePage['width'] = entry.width;

View file

@ -33,6 +33,8 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes); Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType); Future<Uint8List> openFile(String mimeType);
Future<String?> selectDirectory();
} }
class PlatformStorageService implements StorageService { class PlatformStorageService implements StorageService {
@ -217,4 +219,25 @@ class PlatformStorageService implements StorageService {
} }
return Uint8List(0); 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<bool> isRotationLocked();
Future<void> requestOrientation([Orientation? orientation]); Future<void> requestOrientation([Orientation? orientation]);
Future<bool> canSetCutoutMode();
Future<void> setCutoutMode(bool use);
} }
class PlatformWindowService implements WindowService { class PlatformWindowService implements WindowService {
@ -41,12 +45,12 @@ class PlatformWindowService implements WindowService {
late final int orientationCode; late final int orientationCode;
switch (orientation) { switch (orientation) {
case Orientation.landscape: case Orientation.landscape:
// SCREEN_ORIENTATION_USER_LANDSCAPE // SCREEN_ORIENTATION_SENSOR_LANDSCAPE
orientationCode = 11; orientationCode = 6;
break; break;
case Orientation.portrait: case Orientation.portrait:
// SCREEN_ORIENTATION_USER_PORTRAIT // SCREEN_ORIENTATION_SENSOR_PORTRAIT
orientationCode = 12; orientationCode = 7;
break; break;
default: default:
// SCREEN_ORIENTATION_UNSPECIFIED // SCREEN_ORIENTATION_UNSPECIFIED
@ -61,4 +65,26 @@ class PlatformWindowService implements WindowService {
debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}'); 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 collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const highlightJumpDelay = Duration(milliseconds: 400); static const highlightJumpDelay = Duration(milliseconds: 400);
static const highlightScrollInitDelay = Duration(milliseconds: 800); static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoOverlayHideDelay = Duration(milliseconds: 500);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000); static const doubleBackTimerDelay = Duration(milliseconds: 1000);

View file

@ -30,6 +30,7 @@ class AIcons {
static const IconData tagOff = MdiIcons.tagOffOutline; static const IconData tagOff = MdiIcons.tagOffOutline;
// actions // actions
static const IconData addPath = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData replay10 = Icons.replay_10_outlined; static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_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 import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined; static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_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 openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_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 play = Icons.play_arrow;
static const IconData pause = Icons.pause; static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;

View file

@ -279,10 +279,10 @@ class Constants {
sourceUrl: 'https://github.com/dart-lang/intl', sourceUrl: 'https://github.com/dart-lang/intl',
), ),
Dependency( Dependency(
name: 'LatLong', name: 'LatLong2',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE', licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE',
sourceUrl: 'https://github.com/MikeMitterer/dart-latlong', sourceUrl: 'https://github.com/jifalops/dart-latlong',
), ),
Dependency( Dependency(
name: 'PDF for Dart and Flutter', 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/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.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/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
@ -45,10 +46,10 @@ class CollectionAppBar extends StatefulWidget {
} }
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin { class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController();
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader; late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@ -63,6 +64,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
duration: Durations.iconAnimation, duration: Durations.iconAnimation,
vsync: this, vsync: this,
); );
_isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = AppShortcutService.canPin(); _canAddShortcutsLoader = AppShortcutService.canPin();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight()); WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
@ -78,35 +80,34 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
void dispose() { void dispose() {
_unregisterWidget(widget); _unregisterWidget(widget);
_isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose(); _browseToSelectAnimation.dispose();
_searchFieldController.dispose();
super.dispose(); super.dispose();
} }
void _registerWidget(CollectionAppBar widget) { void _registerWidget(CollectionAppBar widget) {
widget.collection.activityNotifier.addListener(_onActivityChange);
widget.collection.filterChangeNotifier.addListener(_updateHeight); widget.collection.filterChangeNotifier.addListener(_updateHeight);
} }
void _unregisterWidget(CollectionAppBar widget) { void _unregisterWidget(CollectionAppBar widget) {
widget.collection.activityNotifier.removeListener(_onActivityChange);
widget.collection.filterChangeNotifier.removeListener(_updateHeight); widget.collection.filterChangeNotifier.removeListener(_updateHeight);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return ValueListenableBuilder<Activity>( return Selector<Selection<AvesEntry>, bool>(
valueListenable: collection.activityNotifier, selector: (context, selection) => selection.isSelecting,
builder: (context, activity, child) { builder: (context, isSelecting, child) {
_isSelectingNotifier.value = isSelecting;
return AnimatedBuilder( return AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
builder: (context, child) { builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal; final removableFilters = appMode != AppMode.pickInternal;
return SliverAppBar( return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading() : null, leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(), title: _buildAppBarTitle(isSelecting),
actions: _buildActions(), actions: _buildActions(isSelecting),
bottom: hasFilters bottom: hasFilters
? FilterBar( ? FilterBar(
filters: collection.filters, filters: collection.filters,
@ -123,15 +124,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
Widget _buildAppBarLeading() { Widget _buildAppBarLeading(bool isSelecting) {
VoidCallback? onPressed; VoidCallback? onPressed;
String? tooltip; String? tooltip;
if (collection.isBrowsing) { if (isSelecting) {
onPressed = () => context.read<Selection<AvesEntry>>().browse();
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} else {
onPressed = Scaffold.of(context).openDrawer; onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
} else if (collection.isSelecting) {
onPressed = collection.browse;
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} }
return IconButton( return IconButton(
key: const Key('appbar-leading-button'), key: const Key('appbar-leading-button'),
@ -144,8 +145,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
Widget? _buildAppBarTitle() { Widget? _buildAppBarTitle(bool isSelecting) {
if (collection.isBrowsing) { 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; final appMode = context.watch<ValueNotifier<AppMode>>().value;
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
if (appMode == AppMode.main) { if (appMode == AppMode.main) {
@ -158,36 +164,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
onTap: appMode.canSearch ? _goToSearch : null, onTap: appMode.canSearch ? _goToSearch : null,
child: title, 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; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return [ return [
if (collection.isBrowsing && appMode.canSearch) if (!isSelecting && appMode.canSearch)
CollectionSearchButton( CollectionSearchButton(
source: source, source: source,
parentCollection: collection, parentCollection: collection,
), ),
if (collection.isSelecting) if (isSelecting)
...EntryActions.selection.map((action) => AnimatedBuilder( ...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
animation: collection.selectionChangeNotifier, selector: (context, selection) => selection.selection.isEmpty,
builder: (context, child) { builder: (context, isEmpty, child) => IconButton(
return IconButton( icon: Icon(action.getIcon()),
icon: Icon(action.getIcon()), onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), tooltip: action.getText(context),
tooltip: action.getText(context), ),
);
},
)), )),
FutureBuilder<bool>( FutureBuilder<bool>(
future: _canAddShortcutsLoader, future: _canAddShortcutsLoader,
@ -196,8 +191,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return PopupMenuButton<CollectionAction>( return PopupMenuButton<CollectionAction>(
key: const Key('appbar-menu-button'), key: const Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
final selection = context.read<Selection<AvesEntry>>();
final isNotEmpty = !collection.isEmpty; final isNotEmpty = !collection.isEmpty;
final hasSelection = collection.selection.isNotEmpty; final hasSelection = selection.selection.isNotEmpty;
return [ return [
PopupMenuItem( PopupMenuItem(
key: const Key('menu-sort'), key: const Key('menu-sort'),
@ -210,7 +206,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.group, value: CollectionAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
), ),
if (collection.isBrowsing && appMode == AppMode.main) ...[ if (!selection.isSelecting && appMode == AppMode.main) ...[
PopupMenuItem( PopupMenuItem(
value: CollectionAction.select, value: CollectionAction.select,
enabled: isNotEmpty, enabled: isNotEmpty,
@ -227,7 +223,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut), child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
), ),
], ],
if (collection.isSelecting) ...[ if (selection.isSelecting) ...[
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.copy, value: CollectionAction.copy,
@ -247,7 +243,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.selectAll, value: CollectionAction.selectAll,
enabled: collection.selection.length < collection.entryCount, enabled: selection.selection.length < collection.entryCount,
child: MenuRow(text: context.l10n.collectionActionSelectAll), child: MenuRow(text: context.l10n.collectionActionSelectAll),
), ),
PopupMenuItem( PopupMenuItem(
@ -269,11 +265,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
void _onActivityChange() { void _onActivityChange() {
if (collection.isSelecting) { if (context.read<Selection<AvesEntry>>().isSelecting) {
_browseToSelectAnimation.forward(); _browseToSelectAnimation.forward();
} else { } else {
_browseToSelectAnimation.reverse(); _browseToSelectAnimation.reverse();
_searchFieldController.clear();
} }
} }
@ -289,13 +284,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_actionDelegate.onCollectionActionSelected(context, action); _actionDelegate.onCollectionActionSelected(context, action);
break; break;
case CollectionAction.select: case CollectionAction.select:
collection.select(); context.read<Selection<AvesEntry>>().select();
break; break;
case CollectionAction.selectAll: case CollectionAction.selectAll:
collection.addToSelection(collection.sortedEntries); context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
break; break;
case CollectionAction.selectNone: case CollectionAction.selectNone:
collection.clearSelection(); context.read<Selection<AvesEntry>>().clearSelection();
break; break;
case CollectionAction.stats: case CollectionAction.stats:
_goToStats(); _goToStats();
@ -307,7 +302,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final value = await showDialog<EntryGroupFactor>( final value = await showDialog<EntryGroupFactor>(
context: context, context: context,
builder: (context) => AvesSelectionDialog<EntryGroupFactor>( builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
initialValue: settings.collectionGroupFactor, initialValue: settings.collectionSectionFactor,
options: { options: {
EntryGroupFactor.album: context.l10n.collectionGroupAlbum, EntryGroupFactor.album: context.l10n.collectionGroupAlbum,
EntryGroupFactor.month: context.l10n.collectionGroupMonth, 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 // wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) { if (value != null) {
settings.collectionGroupFactor = value; settings.collectionSectionFactor = value;
} }
break; break;
case CollectionAction.sort: case CollectionAction.sort:
@ -372,13 +367,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void _goToSearch() { void _goToSearch() {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( SearchPageRoute(
delegate: CollectionSearchDelegate( delegate: CollectionSearchDelegate(
source: collection.source, source: collection.source,
parentCollection: collection, parentCollection: collection,
), ),
)); ),
);
} }
void _goToStats() { 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/app_bar.dart';
import 'package:aves/widgets/collection/draggable_thumb_label.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/section_layout.dart';
import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.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/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.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/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.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/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/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
@ -79,7 +79,7 @@ class _CollectionGridContent extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier), valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) { builder: (context, tileExtent, child) {
return ThumbnailTheme( return GridTheme(
extent: tileExtent, extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>( child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), 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 isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector( final selector = GridSelectionGestureDetector(
selectable: isMainMode, selectable: isMainMode,
collection: collection, items: collection.sortedEntries,
scrollController: scrollController, scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
child: scaler, child: scaler,
@ -210,14 +210,11 @@ class _CollectionScaler extends StatelessWidget {
), ),
child: child, child: child,
), ),
scaledBuilder: (entry, extent) => ThumbnailTheme( scaledBuilder: (entry, extent) => DecoratedThumbnail(
extent: extent, entry: entry,
child: DecoratedThumbnail( tileExtent: context.read<TileExtentController>().effectiveExtentMax,
entry: entry, selectable: false,
tileExtent: context.read<TileExtentController>().effectiveExtentMax, highlightable: false,
selectable: false,
highlightable: false,
),
), ),
child: child, child: child,
); );
@ -270,13 +267,13 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
void _registerWidget(_CollectionScrollView widget) { void _registerWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_scrollToTop); widget.collection.filterChangeNotifier.addListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop); widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop);
widget.scrollController.addListener(_onScrollChange); widget.scrollController.addListener(_onScrollChange);
} }
void _unregisterWidget(_CollectionScrollView widget) { void _unregisterWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_scrollToTop); widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop); widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop);
widget.scrollController.removeListener(_onScrollChange); 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/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.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/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -35,22 +38,27 @@ class _CollectionPageState extends State<CollectionPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: WillPopScope( body: SelectionProvider<AvesEntry>(
onWillPop: () { child: Builder(
if (collection.isSelecting) { builder: (context) => WillPopScope(
collection.browse(); onWillPop: () {
return SynchronousFuture(false); final selection = context.read<Selection<AvesEntry>>();
} if (selection.isSelecting) {
return SynchronousFuture(true); selection.browse();
}, return SynchronousFuture(false);
child: DoubleBackPopScope( }
child: GestureAreaProtectorStack( return SynchronousFuture(true);
child: SafeArea( },
bottom: false, child: DoubleBackPopScope(
child: ChangeNotifierProvider<CollectionLens>.value( child: GestureAreaProtectorStack(
value: collection, child: SafeArea(
child: const CollectionGrid( bottom: false,
key: Key('collection-grid'), 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) { lineBuilder: (context, entry) {
switch (collection.sortFactor) { switch (collection.sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
switch (collection.groupFactor) { switch (collection.sectionFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
return [ return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), 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/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/move_type.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/filters/album.dart';
import 'package:aves/model/highlight.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_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
@ -31,10 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_showDeleteDialog(context); _showDeleteDialog(context);
break; break;
case EntryAction.share: case EntryAction.share:
final collection = context.read<CollectionLens>(); _share(context);
AndroidAppService.shareEntries(collection.selection).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break; break;
default: default:
break; 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) { void _refreshMetadata(BuildContext context) {
final collection = context.read<CollectionLens>(); final source = context.read<CollectionSource>();
collection.source.refreshMetadata(collection.selection); final selection = context.read<Selection<AvesEntry>>();
collection.browse(); final selectedItems = _getExpandedSelectedItems(selection);
source.refreshMetadata(selectedItems);
selection.browse();
} }
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async { Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final collection = context.read<CollectionLens>(); final source = context.read<CollectionSource>();
final source = collection.source; final selection = context.read<Selection<AvesEntry>>();
final selection = collection.selection; 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) { if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions, // check whether moving is possible given OS restrictions,
// before asking to pick a destination album // 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 (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 // do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection // 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 copy = moveType == MoveType.copy;
final todoCount = todoEntries.length; final todoCount = todoEntries.length;
@ -118,7 +133,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
movedOps: movedOps, movedOps: movedOps,
); );
collection.browse(); selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
// cleanup // cleanup
@ -140,6 +155,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
label: context.l10n.showButtonLabel, label: context.l10n.showButtonLabel,
onPressed: () async { onPressed: () async {
final highlightInfo = context.read<HighlightInfo>(); final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>();
var targetCollection = collection; var targetCollection = collection;
if (collection.filters.any((f) => f is AlbumFilter)) { if (collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
@ -175,11 +191,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
Future<void> _showDeleteDialog(BuildContext context) async { Future<void> _showDeleteDialog(BuildContext context) async {
final collection = context.read<CollectionLens>(); final source = context.read<CollectionSource>();
final source = collection.source; final selection = context.read<Selection<AvesEntry>>();
final selection = collection.selection; final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet(); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selection.length; final todoCount = selectedItems.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@ -207,12 +223,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: imageFileService.delete(selection), opStream: imageFileService.delete(selectedItems),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
await source.removeEntries(deletedUris); await source.removeEntries(deletedUris);
collection.browse(); selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
final deletedCount = deletedUris.length; 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/collection_source.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -33,7 +34,7 @@ class AlbumSectionHeader extends StatelessWidget {
); );
} }
} }
return SectionHeader( return SectionHeader<AvesEntry>(
sectionKey: EntryAlbumSectionKey(directory), sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon, leading: albumIcon,
title: albumName ?? context.l10n.sectionUnknown, title: albumName ?? context.l10n.sectionUnknown,

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
@ -31,10 +32,11 @@ class InteractiveThumbnail extends StatelessWidget {
final appMode = context.read<ValueNotifier<AppMode>>().value; final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) { switch (appMode) {
case AppMode.main: case AppMode.main:
if (collection.isBrowsing) { final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) {
selection.toggleSelection(entry);
} else {
_goToViewer(context); _goToViewer(context);
} else if (collection.isSelecting) {
collection.toggleSelection(entry);
} }
break; break;
case AppMode.pickExternal: case AppMode.pickExternal:
@ -74,7 +76,7 @@ class InteractiveThumbnail extends StatelessWidget {
id: collection.id, id: collection.id,
listenToSource: false, listenToSource: false,
); );
assert(viewerCollection.sortedEntries.contains(entry)); assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
return EntryViewerPage( return EntryViewerPage(
collection: viewerCollection, collection: viewerCollection,
initialEntry: entry, 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/image.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart'; import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget { class DecoratedThumbnail extends StatelessWidget {
@ -10,7 +11,7 @@ class DecoratedThumbnail extends StatelessWidget {
final double tileExtent; final double tileExtent;
final CollectionLens? collection; final CollectionLens? collection;
final ValueNotifier<bool>? cancellableNotifier; final ValueNotifier<bool>? cancellableNotifier;
final bool selectable, highlightable; final bool selectable, highlightable, hero;
static final Color borderColor = Colors.grey.shade700; static final Color borderColor = Colors.grey.shade700;
static final double borderWidth = AvesBorder.borderWidth; static final double borderWidth = AvesBorder.borderWidth;
@ -23,6 +24,7 @@ class DecoratedThumbnail extends StatelessWidget {
this.cancellableNotifier, this.cancellableNotifier,
this.selectable = true, this.selectable = true,
this.highlightable = true, this.highlightable = true,
this.hero = true,
}) : super(key: key); }) : super(key: key);
@override @override
@ -32,7 +34,7 @@ class DecoratedThumbnail extends StatelessWidget {
// hero tag should include a collection identifier, so that it animates // 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) // 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) // 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; final isSvg = entry.isSvg;
Widget child = ThumbnailImage( Widget child = ThumbnailImage(
entry: entry, entry: entry,
@ -46,7 +48,7 @@ class DecoratedThumbnail extends StatelessWidget {
children: [ children: [
child, child,
if (!isSvg) ThumbnailEntryOverlay(entry: entry), if (!isSvg) ThumbnailEntryOverlay(entry: entry),
if (selectable) ThumbnailSelectionOverlay(entry: entry), if (selectable) GridItemSelectionOverlay(item: entry),
if (highlightable) ThumbnailHighlightOverlay(entry: 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/entry.dart';
import 'package:aves/model/highlight.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/fx/sweeper.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -23,7 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final children = [ 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) if (entry.isVideo)
VideoIcon( VideoIcon(
entry: entry, entry: entry,
@ -31,11 +27,11 @@ class ThumbnailEntryOverlay extends StatelessWidget {
else if (entry.isAnimated) else if (entry.isAnimated)
const AnimatedImageIcon() const AnimatedImageIcon()
else ...[ else ...[
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) const RawIcon(), if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) const GeotiffIcon(), if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(), if (entry.is360) const SphericalImageIcon(),
] ],
if (entry.isMultiPage) MultiPageIcon(entry: entry),
]; ];
if (children.isEmpty) return const SizedBox.shrink(); if (children.isEmpty) return const SizedBox.shrink();
if (children.length == 1) return children.first; 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 { class ThumbnailHighlightOverlay extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
@ -132,7 +71,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide( border: Border.fromBorderSide(BorderSide(
color: Theme.of(context).accentColor, 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 'dart:math';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
@ -14,10 +15,14 @@ mixin FeedbackMixin {
// provide the messenger if feedback happens as the widget is disposed // provide the messenger if feedback happens as the widget is disposed
void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) { void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) {
final duration = action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay;
messenger.showSnackBar(SnackBar( messenger.showSnackBar(SnackBar(
content: Text(message), content: _FeedbackMessage(
message: message,
duration: action != null ? duration : null,
),
action: action, 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 { class BlurredRRect extends StatelessWidget {
final bool enabled;
final double borderRadius; final double borderRadius;
final Widget child; final Widget child;
const BlurredRRect({ const BlurredRRect({
Key? key, Key? key,
this.enabled = true,
required this.borderRadius, required this.borderRadius,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipRRect( return enabled
borderRadius: BorderRadius.all(Radius.circular(borderRadius)), ? ClipRRect(
child: BackdropFilter( borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
filter: _filter, child: BackdropFilter(
child: child, filter: _filter,
), child: child,
); ),
)
: child;
} }
} }
class BlurredOval extends StatelessWidget { class BlurredOval extends StatelessWidget {
final bool enabled;
final Widget child; final Widget child;
const BlurredOval({ const BlurredOval({
Key? key, Key? key,
this.enabled = true,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClipOval( return ClipOval(
child: BackdropFilter( child: enabled
filter: _filter, ? BackdropFilter(
child: child, filter: _filter,
), child: child,
)
: child,
); );
} }
} }

View file

@ -1,15 +1,15 @@
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.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/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class SectionHeader extends StatelessWidget { class SectionHeader<T> extends StatelessWidget {
final SectionKey sectionKey; final SectionKey sectionKey;
final Widget? leading, trailing; final Widget? leading, trailing;
final String title; final String title;
@ -43,7 +43,7 @@ class SectionHeader extends StatelessWidget {
children: [ children: [
WidgetSpan( WidgetSpan(
alignment: widgetSpanAlignment, alignment: widgetSpanAlignment,
child: _SectionSelectableLeading( child: _SectionSelectableLeading<T>(
selectable: selectable, selectable: selectable,
sectionKey: sectionKey, sectionKey: sectionKey,
browsingBuilder: leading != null browsingBuilder: leading != null
@ -77,13 +77,13 @@ class SectionHeader extends StatelessWidget {
} }
void _toggleSectionSelection(BuildContext context) { void _toggleSectionSelection(BuildContext context) {
final collection = context.read<CollectionLens>(); final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
final sectionEntries = collection.sections[sectionKey]!; final selection = context.read<Selection<T>>();
final selected = collection.isSelected(sectionEntries); final isSelected = selection.isSelected(sectionEntries);
if (selected) { if (isSelected) {
collection.removeFromSelection(sectionEntries); selection.removeFromSelection(sectionEntries);
} else { } 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 bool selectable;
final SectionKey sectionKey; final SectionKey sectionKey;
final WidgetBuilder? browsingBuilder; final WidgetBuilder? browsingBuilder;
@ -142,72 +142,82 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!selectable) return _buildBrowsing(context); if (!selectable) return _buildBrowsing(context);
final collection = context.watch<CollectionLens>(); final isSelecting = context.select<Selection<T>, bool>((selection) => selection.isSelecting);
return ValueListenableBuilder<Activity>( final Widget child = isSelecting
valueListenable: collection.activityNotifier, ? _SectionSelectingLeading<T>(
builder: (context, activity, child) { sectionKey: sectionKey,
final child = collection.isSelecting onPressed: onPressed,
? AnimatedBuilder( )
animation: collection.selectionChangeNotifier, : _buildBrowsing(context);
builder: (context, child) {
final sectionEntries = collection.sections[sectionKey]!; return AnimatedSwitcher(
final selected = collection.isSelected(sectionEntries); duration: Durations.sectionHeaderAnimation,
final child = TooltipTheme( switchInCurve: Curves.easeInOut,
key: ValueKey(selected), switchOutCurve: Curves.easeInOut,
data: TooltipTheme.of(context).copyWith( transitionBuilder: (child, animation) {
preferBelow: false, Widget transition = ScaleTransition(
), scale: animation,
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;
},
child: child, 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); 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:async';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.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:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class GridSelectionGestureDetector extends StatefulWidget { class GridSelectionGestureDetector<T> extends StatefulWidget {
final bool selectable; final bool selectable;
final CollectionLens collection; final List<T> items;
final ScrollController scrollController; final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final Widget child; final Widget child;
@ -20,17 +19,17 @@ class GridSelectionGestureDetector extends StatefulWidget {
const GridSelectionGestureDetector({ const GridSelectionGestureDetector({
Key? key, Key? key,
this.selectable = true, this.selectable = true,
required this.collection, required this.items,
required this.scrollController, required this.scrollController,
required this.appBarHeightNotifier, required this.appBarHeightNotifier,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@override @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; bool _pressing = false, _selecting = false;
late int _fromIndex, _lastToIndex; late int _fromIndex, _lastToIndex;
late Offset _localPosition; late Offset _localPosition;
@ -38,9 +37,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
late double _scrollSpeedFactor; late double _scrollSpeedFactor;
Timer? _updateTimer; Timer? _updateTimer;
CollectionLens get collection => widget.collection; List<T> get items => widget.items;
List<AvesEntry> get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
@ -52,15 +49,17 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectable = widget.selectable;
return GestureDetector( return GestureDetector(
onLongPressStart: widget.selectable onLongPressStart: selectable
? (details) { ? (details) {
final fromEntry = _getEntryAt(details.localPosition); final fromItem = _getItemAt(details.localPosition);
if (fromEntry == null) return; if (fromItem == null) return;
collection.toggleSelection(fromEntry); final selection = context.read<Selection<T>>();
_selecting = collection.isSelected([fromEntry]); selection.toggleSelection(fromItem);
_fromIndex = entries.indexOf(fromEntry); _selecting = selection.isSelected([fromItem]);
_fromIndex = items.indexOf(fromItem);
_lastToIndex = _fromIndex; _lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only( _scrollableInsets = EdgeInsets.only(
top: appBarHeight, top: appBarHeight,
@ -70,20 +69,29 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
_pressing = true; _pressing = true;
} }
: null, : null,
onLongPressMoveUpdate: widget.selectable onLongPressMoveUpdate: selectable
? (details) { ? (details) {
if (!_pressing) return; if (!_pressing) return;
_localPosition = details.localPosition; _localPosition = details.localPosition;
_onLongPressUpdate(); _onLongPressUpdate();
} }
: null, : null,
onLongPressEnd: widget.selectable onLongPressEnd: selectable
? (details) { ? (details) {
if (!_pressing) return; if (!_pressing) return;
_setScrollSpeed(0); _setScrollSpeed(0);
_pressing = false; _pressing = false;
} }
: null, : 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, child: widget.child,
); );
} }
@ -102,9 +110,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
_setScrollSpeed(0); _setScrollSpeed(0);
} }
final toEntry = _getEntryAt(_localPosition); final toItem = _getItemAt(_localPosition);
if (toEntry != null) { if (toItem != null) {
_toggleSelectionToIndex(entries.indexOf(toEntry)); _toggleSelectionToIndex(items.indexOf(toItem));
} }
} }
@ -128,47 +136,48 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
duration: Duration(milliseconds: millis.round()), duration: Duration(milliseconds: millis.round()),
curve: Curves.linear, 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 // is not called when the pointer stays still while the view is scrolling
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate()); _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, // 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, // 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 offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>(); final sectionedListLayout = context.read<SectionedListLayout<T>>();
return sectionedListLayout.getItemAt(offset); return sectionedListLayout.getItemAt(offset);
} }
void _toggleSelectionToIndex(int toIndex) { void _toggleSelectionToIndex(int toIndex) {
if (toIndex == -1) return; if (toIndex == -1) return;
final selection = context.read<Selection<T>>();
if (_selecting) { if (_selecting) {
if (toIndex <= _fromIndex) { if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) { if (toIndex < _lastToIndex) {
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); selection.addToSelection(items.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_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) { } else if (_lastToIndex < toIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); selection.removeFromSelection(items.getRange(_lastToIndex, toIndex));
} }
} else if (_fromIndex < toIndex) { } else if (_fromIndex < toIndex) {
if (_lastToIndex < 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) { if (_lastToIndex < _fromIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); selection.removeFromSelection(items.getRange(_lastToIndex, _fromIndex));
} }
} else if (toIndex < _lastToIndex) { } else if (toIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); selection.removeFromSelection(items.getRange(toIndex + 1, _lastToIndex + 1));
} }
} }
_lastToIndex = toIndex; _lastToIndex = toIndex;
} else { } 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget { class GridTheme extends StatelessWidget {
final double extent; final double extent;
final bool? showLocation; final bool? showLocation;
final Widget child; final Widget child;
const ThumbnailTheme({ const GridTheme({
Key? key, Key? key,
required this.extent, required this.extent,
this.showLocation, this.showLocation,
@ -18,12 +18,12 @@ class ThumbnailTheme extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProxyProvider<Settings, ThumbnailThemeData>( return ProxyProvider<Settings, GridThemeData>(
update: (_, settings, __) { update: (_, settings, __) {
final iconSize = min(28.0, (extent / 4)).roundToDouble(); final iconSize = min(28.0, (extent / 4)).roundToDouble();
final fontSize = (iconSize / 2).floorToDouble(); final fontSize = (iconSize / 2).floorToDouble();
final highlightBorderWidth = extent * .1; final highlightBorderWidth = extent * .1;
return ThumbnailThemeData( return GridThemeData(
iconSize: iconSize, iconSize: iconSize,
fontSize: fontSize, fontSize: fontSize,
highlightBorderWidth: highlightBorderWidth, highlightBorderWidth: highlightBorderWidth,
@ -37,11 +37,11 @@ class ThumbnailTheme extends StatelessWidget {
} }
} }
class ThumbnailThemeData { class GridThemeData {
final double iconSize, fontSize, highlightBorderWidth; final double iconSize, fontSize, highlightBorderWidth;
final bool showLocation, showRaw, showVideoDuration; final bool showLocation, showRaw, showVideoDuration;
const ThumbnailThemeData({ const GridThemeData({
required this.iconSize, required this.iconSize,
required this.fontSize, required this.fontSize,
required this.highlightBorderWidth, required this.highlightBorderWidth,

View file

@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu_row.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/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -23,6 +23,7 @@ class AvesFilterChip extends StatefulWidget {
final bool removable; final bool removable;
final bool showGenericIcon; final bool showGenericIcon;
final Widget? background; final Widget? background;
final String? banner;
final Widget? details; final Widget? details;
final BorderRadius? borderRadius; final BorderRadius? borderRadius;
final double padding; final double padding;
@ -43,6 +44,7 @@ class AvesFilterChip extends StatefulWidget {
this.removable = false, this.removable = false,
this.showGenericIcon = true, this.showGenericIcon = true,
this.background, this.background,
this.banner,
this.details, this.details,
this.borderRadius, this.borderRadius,
this.padding = 6.0, 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 borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner;
Widget chip = Container( Widget chip = Container(
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: AvesFilterChip.minChipWidth, minWidth: AvesFilterChip.minChipWidth,
@ -209,51 +212,62 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius, borderRadius: borderRadius,
child: widget.background, child: widget.background,
), ),
Tooltip( Material(
message: filter.getTooltip(context), color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
preferBelow: false, shape: RoundedRectangleBorder(
child: Material( borderRadius: borderRadius,
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, ),
shape: RoundedRectangleBorder( child: InkWell(
borderRadius: borderRadius, // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
), // so we get the long press details from the tap instead
child: InkWell( onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, onTap: onTap != null
// so we get the long press details from the tap instead ? () {
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
onTap: onTap != null setState(() => _tapped = true);
? () {
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!;
} }
return DecoratedBox( : null,
decoration: BoxDecoration( onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
border: Border.fromBorderSide(BorderSide( borderRadius: borderRadius,
color: _outlineColor, child: FutureBuilder<Color>(
width: AvesFilterChip.outlineWidth, future: _colorFuture,
)), builder: (context, snapshot) {
borderRadius: borderRadius, if (snapshot.hasData) {
), _outlineColor = snapshot.data!;
position: DecorationPosition.foreground, }
child: Padding( return DecoratedBox(
padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(
child: content, 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/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.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:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -20,11 +20,11 @@ class VideoIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbnailTheme = context.watch<ThumbnailThemeData>(); final gridTheme = context.watch<GridThemeData>();
final showDuration = thumbnailTheme.showVideoDuration; final showDuration = gridTheme.showVideoDuration;
Widget child = OverlayIcon( Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb, icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
size: thumbnailTheme.iconSize, size: gridTheme.iconSize,
text: showDuration ? entry.durationText : null, text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1, iconScale: entry.is360 && showDuration ? .9 : 1,
); );
@ -32,7 +32,7 @@ class VideoIcon extends StatelessWidget {
child = DefaultTextStyle( child = DefaultTextStyle(
style: TextStyle( style: TextStyle(
color: Colors.grey.shade200, color: Colors.grey.shade200,
fontSize: thumbnailTheme.fontSize, fontSize: gridTheme.fontSize,
), ),
child: child, child: child,
); );
@ -48,7 +48,7 @@ class AnimatedImageIcon extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.animated, icon: AIcons.animated,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize), size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8, iconScale: .8,
); );
} }
@ -61,7 +61,7 @@ class GeotiffIcon extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.geo, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.threeSixty, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.location, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.raw, 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 @override
Widget build(BuildContext context) { 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( return OverlayIcon(
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage, icon: icon,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize), size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8, 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.relativeArcToPoint(Offset(dim * 1.917, dim * -4.63), radius: Radius.circular(dim * 2.712), rotation: 112.5, clockwise: false);
path3.close(); path3.close();
canvas.drawPath( canvas.drawPath(
path0, path0,
Paint() 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/model/highlight.dart';
import 'package:aves/theme/durations.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/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -79,7 +80,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
builder: (extent) => SizedBox( builder: (extent) => SizedBox(
width: extent, width: extent,
height: extent, height: extent,
child: widget.scaledBuilder(_metadata!.item, extent), child: GridTheme(
extent: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
),
), ),
center: thumbnailCenter, center: thumbnailCenter,
viewportWidth: gridWidth, 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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_pick_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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -89,7 +89,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
Container( Container(
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: DecoratedFilterChip( child: CoveredFilterChip(
filter: filter, filter: filter,
extent: extent, extent: extent,
coverEntry: _isCustom ? _customEntry : _recentEntry, 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 // 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 // then scroll to the bottom to make sure the field is in view
if (_nameFieldFocusNode.hasFocus) { if (_nameFieldFocusNode.hasFocus) {
await Future.delayed(Durations.softKeyboardDisplayDelay); await Future.delayed(Durations.softKeyboardDisplayDelay + const Duration(milliseconds: 500));
_scrollToBottom(); _scrollToBottom();
} }
} }

View file

@ -1,9 +1,11 @@
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.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/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -36,13 +38,15 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
value: ValueNotifier(AppMode.pickInternal), value: ValueNotifier(AppMode.pickInternal),
child: MediaQueryDataProvider( child: MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: GestureAreaProtectorStack( body: SelectionProvider<AvesEntry>(
child: SafeArea( child: GestureAreaProtectorStack(
bottom: false, child: SafeArea(
child: ChangeNotifierProvider<CollectionLens>.value( bottom: false,
value: collection, child: ChangeNotifierProvider<CollectionLens>.value(
child: const CollectionGrid( value: collection,
settingsRouteKey: CollectionPage.routeName, 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/actions/move_type.dart';
import 'package:aves/model/filters/album.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/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.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/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -46,37 +48,42 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget appBar = AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(),
queryNotifier: _queryNotifier,
);
return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>( return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>(
selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor), selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor),
builder: (context, s, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterGridPage<AlbumFilter>( builder: (context, snapshot) {
settingsRouteKey: AlbumListPage.routeName, final gridItems = AlbumListPage.getAlbumGridItems(context, source);
appBar: appBar, return SelectionProvider<FilterGridItem<AlbumFilter>>(
appBarHeight: AlbumPickAppBar.preferredHeight, child: FilterGridPage<AlbumFilter>(
filterSections: AlbumListPage.getAlbumEntries(context, source), settingsRouteKey: AlbumListPage.routeName,
sortFactor: settings.albumSortFactor, appBar: AlbumPickAppBar(
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, source: source,
queryNotifier: _queryNotifier, moveType: widget.moveType,
applyQuery: (filters, query) { actionDelegate: AlbumChipSetActionDelegate(gridItems),
if (query.isEmpty) return filters; queryNotifier: _queryNotifier,
query = query.toUpperCase(); ),
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList(); appBarHeight: AlbumPickAppBar.preferredHeight,
}, sections: AlbumListPage.groupToSections(context, source, gridItems),
emptyBuilder: () => EmptyContent( newFilters: source.getNewAlbumFilters(context),
icon: AIcons.album, sortFactor: settings.albumSortFactor,
text: context.l10n.albumEmpty, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
), selectable: false,
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album), 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(); FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action // 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/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.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/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -38,32 +36,23 @@ class AlbumListPage extends StatelessWidget {
animation: androidFileUtils.appNameChangeNotifier, animation: androidFileUtils.appNameChangeNotifier,
builder: (context, child) => StreamBuilder( builder: (context, child) => StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>( builder: (context, snapshot) {
source: source, final gridItems = getAlbumGridItems(context, source);
title: context.l10n.albumPageTitle, return FilterNavigationPage<AlbumFilter>(
sortFactor: settings.albumSortFactor, source: source,
groupable: true, title: context.l10n.albumPageTitle,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, sortFactor: settings.albumSortFactor,
chipSetActionDelegate: AlbumChipSetActionDelegate(), groupable: true,
chipActionDelegate: AlbumChipActionDelegate(), showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
chipActionsBuilder: (filter) { actionDelegate: AlbumChipSetActionDelegate(gridItems),
final dir = VolumeRelativeDirectory.fromPath(filter.album); filterSections: groupToSections(context, source, gridItems),
// do not allow renaming volume root newFilters: source.getNewAlbumFilters(context),
final canRename = dir != null && dir.relativeDir.isNotEmpty; emptyBuilder: () => EmptyContent(
return [ icon: AIcons.album,
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, text: context.l10n.albumEmpty,
ChipAction.setCover, ),
if (canRename) ChipAction.rename, );
ChipAction.delete, },
ChipAction.hide,
];
},
filterSections: getAlbumEntries(context, source),
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 // 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 filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); return FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
return _group(context, sorted);
} }
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 pinned = settings.pinnedFilters.whereType<AlbumFilter>();
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = byPin[true] ?? []; final List<FilterGridItem<AlbumFilter>> newMapEntries = [], pinnedMapEntries = [], unpinnedMapEntries = [];
final unpinnedMapEntries = byPin[false] ?? []; 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>>>{}; var sections = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
switch (settings.albumGroupFactor) { switch (settings.albumGroupFactor) {
@ -116,8 +114,9 @@ class AlbumListPage extends StatelessWidget {
break; break;
case AlbumChipGroupFactor.none: case AlbumChipGroupFactor.none:
return { return {
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty) if (sortedMapEntries.isNotEmpty)
const ChipSectionKey(): [ const ChipSectionKey(): [
...newMapEntries,
...pinnedMapEntries, ...pinnedMapEntries,
...unpinnedMapEntries, ...unpinnedMapEntries,
], ],
@ -131,6 +130,13 @@ class AlbumListPage extends StatelessWidget {
]); ]);
} }
if (newMapEntries.isNotEmpty) {
sections = Map.fromEntries([
MapEntry(AlbumImportanceSectionKey.newAlbum(context), newMapEntries),
...sections.entries,
]);
}
return sections; return sections;
} }
} }

View file

@ -1,151 +1,148 @@
import 'dart:io'; 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/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/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.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/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ChipActionDelegate { class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { final Iterable<FilterGridItem<AlbumFilter>> _items;
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;
}
}
Future<void> _hide(BuildContext context, CollectionFilter filter) async { AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
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 _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 @override
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { Iterable<FilterGridItem<AlbumFilter>> get allItems => _items;
super.onActionSelected(context, filter, action);
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) { switch (action) {
case ChipAction.delete: case ChipSetAction.delete:
_showDeleteDialog(context, filter as AlbumFilter); 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; break;
case ChipAction.rename: case ChipSetAction.createAlbum:
_showRenameDialog(context, filter as AlbumFilter); _createAlbum(context);
break;
// single/multiple filters
case ChipSetAction.delete:
_showDeleteDialog(context, filters);
break;
// single filter
case ChipSetAction.rename:
_showRenameDialog(context, filters.first);
break; break;
default: default:
break; 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 l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = filter.album; final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
final todoEntries = source.visibleEntries.where(filter.test).toSet();
final todoCount = todoEntries.length; 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>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AvesDialog( return AvesDialog(
context: context, context: context,
content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)), content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -161,7 +158,10 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
); );
if (confirmed == null || !confirmed) return; 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(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
@ -180,7 +180,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
} }
// cleanup // 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/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.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/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:decorated_icon/decorated_icon.dart'; import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget { class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final CollectionFilter filter; final T filter;
final double extent, thumbnailExtent; final double extent, thumbnailExtent;
final AvesEntry? coverEntry; final AvesEntry? coverEntry;
final bool pinned, highlightable; final bool pinned;
final String? banner;
final FilterCallback? onTap; final FilterCallback? onTap;
final OffsetFilterCallback? onLongPress;
const DecoratedFilterChip({ const CoveredFilterChip({
Key? key, Key? key,
required this.filter, required this.filter,
required this.extent, required this.extent,
double? thumbnailExtent, double? thumbnailExtent,
this.coverEntry, this.coverEntry,
this.pinned = false, this.pinned = false,
this.highlightable = true, this.banner,
this.onTap, this.onTap,
this.onLongPress,
}) : thumbnailExtent = thumbnailExtent ?? extent, }) : thumbnailExtent = thumbnailExtent ?? extent,
super(key: key); super(key: key);
@ -46,7 +44,7 @@ class DecoratedFilterChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<CollectionSource>( return Consumer<CollectionSource>(
builder: (context, source, child) { builder: (context, source, child) {
switch (filter.runtimeType) { switch (T) {
case AlbumFilter: case AlbumFilter:
{ {
final album = (filter as AlbumFilter).album; final album = (filter as AlbumFilter).album;
@ -89,41 +87,24 @@ class DecoratedFilterChip extends StatelessWidget {
extent: thumbnailExtent, extent: thumbnailExtent,
); );
final titlePadding = min<double>(4.0, extent / 32); final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(radius(extent)); return SizedBox(
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(
width: extent, width: extent,
height: 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 padding = min<double>(8.0, extent / 16);
final iconSize = min<double>(14.0, extent / 8); final iconSize = min<double>(14.0, extent / 8);
final fontSize = min<double>(14.0, extent / 6); 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 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/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/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.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/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/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/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.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/scaling.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/drawer/app_drawer.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/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_keys.dart';
import 'package:aves/widgets/filter_grids/common/section_layout.dart'; import 'package:aves/widgets/filter_grids/common/section_layout.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -38,28 +43,29 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final String? settingsRouteKey; final String? settingsRouteKey;
final Widget appBar; final Widget appBar;
final double appBarHeight; 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 ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery; final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGridPage({ const FilterGridPage({
Key? key, Key? key,
this.settingsRouteKey, this.settingsRouteKey,
required this.appBar, required this.appBar,
this.appBarHeight = kToolbarHeight, this.appBarHeight = kToolbarHeight,
required this.filterSections, required this.sections,
required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable,
required this.queryNotifier, required this.queryNotifier,
this.applyQuery, this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.onTap, required this.onTap,
this.onLongPress,
}) : super(key: key); }) : super(key: key);
static const Color detailColor = Color(0xFFE0E0E0); static const Color detailColor = Color(0xFFE0E0E0);
@ -68,24 +74,35 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: DoubleBackPopScope( body: WillPopScope(
child: GestureAreaProtectorStack( onWillPop: () {
child: SafeArea( final selection = context.read<Selection<FilterGridItem<T>>>();
bottom: false, if (selection.isSelecting) {
child: AnimatedBuilder( selection.browse();
animation: covers, return SynchronousFuture(false);
builder: (context, child) => FilterGrid<T>( }
settingsRouteKey: settingsRouteKey, return SynchronousFuture(true);
appBar: appBar, },
appBarHeight: appBarHeight, child: DoubleBackPopScope(
filterSections: filterSections, child: GestureAreaProtectorStack(
sortFactor: sortFactor, child: SafeArea(
showHeaders: showHeaders, bottom: false,
queryNotifier: queryNotifier, child: AnimatedBuilder(
applyQuery: applyQuery, animation: covers,
emptyBuilder: emptyBuilder, builder: (context, child) => FilterGrid<T>(
onTap: onTap, settingsRouteKey: settingsRouteKey,
onLongPress: onLongPress, 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 String? settingsRouteKey;
final Widget appBar; final Widget appBar;
final double appBarHeight; 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 ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery; final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGrid({ const FilterGrid({
Key? key, Key? key,
required this.settingsRouteKey, required this.settingsRouteKey,
required this.appBar, required this.appBar,
required this.appBarHeight, required this.appBarHeight,
required this.filterSections, required this.sections,
required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable,
required this.queryNotifier, required this.queryNotifier,
required this.applyQuery, required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.onTap, required this.onTap,
required this.onLongPress,
}) : super(key: key); }) : super(key: key);
@override @override
@ -152,14 +170,15 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
child: _FilterGridContent<T>( child: _FilterGridContent<T>(
appBar: widget.appBar, appBar: widget.appBar,
appBarHeight: widget.appBarHeight, appBarHeight: widget.appBarHeight,
filterSections: widget.filterSections, sections: widget.sections,
newFilters: widget.newFilters,
sortFactor: widget.sortFactor, sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders, showHeaders: widget.showHeaders,
selectable: widget.selectable,
queryNotifier: widget.queryNotifier, queryNotifier: widget.queryNotifier,
applyQuery: widget.applyQuery, applyQuery: widget.applyQuery,
emptyBuilder: widget.emptyBuilder, emptyBuilder: widget.emptyBuilder,
onTap: widget.onTap, 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 { class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar; 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 ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final QueryTest<T>? applyQuery; final QueryTest<T>? applyQuery;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
@ -182,14 +201,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
Key? key, Key? key,
required this.appBar, required this.appBar,
required double appBarHeight, required double appBarHeight,
required this.filterSections, required this.sections,
required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable,
required this.queryNotifier, required this.queryNotifier,
required this.applyQuery, required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.onTap, required this.onTap,
required this.onLongPress,
}) : super(key: key) { }) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight; _appBarHeightNotifier.value = appBarHeight;
} }
@ -199,15 +219,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
return ValueListenableBuilder<String>( return ValueListenableBuilder<String>(
valueListenable: queryNotifier, valueListenable: queryNotifier,
builder: (context, query, child) { builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections; Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
if (applyQuery == null) { if (applyQuery == null) {
visibleFilterSections = filterSections; visibleSections = sections;
} else { } else {
visibleFilterSections = {}; visibleSections = {};
filterSections.forEach((sectionKey, sectionFilters) { sections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery!(sectionFilters, query); final visibleFilters = applyQuery!(sectionFilters, query);
if (visibleFilters.isNotEmpty) { 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>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier), valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) { builder: (context, tileExtent, child) {
return Selector<TileExtentController, Tuple3<double, int, double>>( return GridTheme(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), extent: tileExtent,
builder: (context, c, child) { child: Selector<TileExtentController, Tuple3<double, int, double>>(
final scrollableWidth = c.item1; selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
final columnCount = c.item2; builder: (context, c, child) {
final tileSpacing = c.item3; final scrollableWidth = c.item1;
// do not listen for animation delay change final columnCount = c.item2;
final controller = Provider.of<TileExtentController>(context, listen: false); final tileSpacing = c.item3;
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget); // do not listen for animation delay change
return SectionedFilterListLayoutProvider<T>( final controller = Provider.of<TileExtentController>(context, listen: false);
sections: visibleFilterSections, final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
showHeaders: showHeaders, return SectionedFilterListLayoutProvider<T>(
scrollableWidth: scrollableWidth, sections: visibleSections,
columnCount: columnCount, showHeaders: showHeaders,
spacing: tileSpacing, scrollableWidth: scrollableWidth,
tileExtent: tileExtent, columnCount: columnCount,
tileBuilder: (gridItem) { spacing: tileSpacing,
final filter = gridItem.filter; tileExtent: tileExtent,
final entry = gridItem.entry; tileBuilder: (gridItem) {
return MetaData( final filter = gridItem.filter;
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)), return MetaData(
child: DecoratedFilterChip( metaData: ScalerMetadata(gridItem),
key: Key(filter.key), child: FilterChipGridDecorator<T, FilterGridItem<T>>(
filter: filter, gridItem: gridItem,
extent: tileExtent, extent: tileExtent,
pinned: pinnedFilters.contains(filter), child: CoveredFilterChip(
onTap: onTap, key: Key(filter.key),
onLongPress: onLongPress, filter: filter,
), extent: tileExtent,
); pinned: pinnedFilters.contains(filter),
}, banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null,
tileAnimationDelay: tileAnimationDelay, onTap: onTap,
child: _FilterSectionedContent<T>( ),
appBar: appBar, ),
appBarHeightNotifier: _appBarHeightNotifier, );
visibleFilterSections: visibleFilterSections, },
sortFactor: sortFactor, tileAnimationDelay: tileAnimationDelay,
emptyBuilder: emptyBuilder, child: _FilterSectionedContent<T>(
scrollController: PrimaryScrollController.of(context)!, appBar: appBar,
), appBarHeightNotifier: _appBarHeightNotifier,
); visibleSections: visibleSections,
}); sortFactor: sortFactor,
selectable: selectable,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
}),
);
}, },
); );
return sectionedListLayoutProvider; return sectionedListLayoutProvider;
@ -269,16 +296,18 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget { class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar; final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool selectable;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final ScrollController scrollController; final ScrollController scrollController;
const _FilterSectionedContent({ const _FilterSectionedContent({
required this.appBar, required this.appBar,
required this.appBarHeightNotifier, required this.appBarHeightNotifier,
required this.visibleFilterSections, required this.visibleSections,
required this.sortFactor, required this.sortFactor,
required this.selectable,
required this.emptyBuilder, required this.emptyBuilder,
required this.scrollController, required this.scrollController,
}); });
@ -293,7 +322,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
@override @override
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier; 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; Widget Function() get emptyBuilder => widget.emptyBuilder;
@ -328,14 +357,23 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
child: scrollView, 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 { Future<void> _checkInitHighlight() async {
final highlightInfo = context.read<HighlightInfo>(); final highlightInfo = context.read<HighlightInfo>();
final filter = highlightInfo.clear(); final filter = highlightInfo.clear();
if (filter is T) { 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) { if (gridItem != null) {
await Future.delayed(Durations.highlightScrollInitDelay); await Future.delayed(Durations.highlightScrollInitDelay);
highlightInfo.trackItem(gridItem, highlightItem: filter); highlightInfo.trackItem(gridItem, highlightItem: filter);
@ -367,19 +405,18 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
extent: extent, extent: extent,
spacing: tileSpacing, spacing: tileSpacing,
borderWidth: AvesFilterChip.outlineWidth, borderWidth: AvesFilterChip.outlineWidth,
borderRadius: DecoratedFilterChip.radius(extent), borderRadius: CoveredFilterChip.radius(extent),
color: Colors.grey.shade700, color: Colors.grey.shade700,
), ),
child: child, child: child,
), ),
scaledBuilder: (item, extent) { scaledBuilder: (item, extent) {
final filter = item.filter; final filter = item.filter;
return DecoratedFilterChip( return CoveredFilterChip(
filter: filter, filter: filter,
extent: extent, extent: extent,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax, thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
pinned: pinnedFilters.contains(filter), pinned: pinnedFilters.contains(filter),
highlightable: false,
); );
}, },
highlightItem: (item) => item.filter, 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/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/filter_grids/common/app_bar.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/filter_grids/common/filter_grid_page.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/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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget { class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final String title; final String title;
final ChipSetActionDelegate chipSetActionDelegate;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool groupable, showHeaders; final bool groupable, showHeaders;
final ChipActionDelegate chipActionDelegate; final ChipSetActionDelegate actionDelegate;
final List<ChipAction> Function(T filter) chipActionsBuilder;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Set<T>? newFilters;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
const FilterNavigationPage({ const FilterNavigationPage({
@ -43,114 +28,56 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
required this.sortFactor, required this.sortFactor,
this.groupable = false, this.groupable = false,
this.showHeaders = false, this.showHeaders = false,
required this.chipSetActionDelegate, required this.actionDelegate,
required this.chipActionDelegate,
required this.chipActionsBuilder,
required this.filterSections, required this.filterSections,
this.newFilters,
required this.emptyBuilder, required this.emptyBuilder,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main); return SelectionProvider<FilterGridItem<T>>(
return FilterGridPage<T>( child: Builder(
key: const Key('filter-grid-page'), builder: (context) => FilterGridPage<T>(
appBar: SliverAppBar( key: const Key('filter-grid-page'),
title: InteractiveAppBarTitle( appBar: FilterGridAppBar<T>(
onTap: () => _goToSearch(context),
child: SourceStateAwareAppBarTitle(
title: Text(title),
source: source, source: source,
title: title,
actionDelegate: actionDelegate,
groupable: groupable,
isEmpty: filterSections.isEmpty,
), ),
), sections: filterSections,
actions: _buildActions(context), newFilters: newFilters ?? {},
titleSpacing: 0, sortFactor: sortFactor,
floating: true, showHeaders: showHeaders,
), selectable: true,
filterSections: filterSections, queryNotifier: ValueNotifier(''),
sortFactor: sortFactor, emptyBuilder: () => ValueListenableBuilder<SourceState>(
showHeaders: showHeaders, valueListenable: source.stateNotifier,
queryNotifier: ValueNotifier(''), builder: (context, sourceState, child) {
emptyBuilder: () => ValueListenableBuilder<SourceState>( return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
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],
),
), ),
onTap: (filter) => _goToCollection(context, filter),
), ),
), ),
onLongPress: isMainMode ? _showMenu as OffsetFilterCallback : null,
); );
} }
void _showMenu(BuildContext context, T filter, Offset? tapPosition) async { void _goToCollection(BuildContext context, CollectionFilter filter) {
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) {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( MaterialPageRoute(
delegate: CollectionSearchDelegate( settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: CollectionLens(
source: source, source: source,
filters: [filter],
), ),
)); ),
),
);
} }
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) { 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); return a.filter.compareTo(b.filter);
} }
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) { static List<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) { List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters.map((filter) => FilterGridItem( return filters
filter, .map((filter) => FilterGridItem(
source.recentEntry(filter), filter,
)); source.recentEntry(filter),
))
.toList();
} }
Iterable<FilterGridItem<T>> allMapEntries = {}; List<FilterGridItem<T>> allMapEntries = [];
switch (sortFactor) { switch (sortFactor) {
case ChipSortFactor.name: case ChipSortFactor.name:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName); allMapEntries = toGridItem(source, filters)..sort(compareFiltersByName);
break; break;
case ChipSortFactor.date: case ChipSortFactor.date:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate); allMapEntries = toGridItem(source, filters)..sort(compareFiltersByDate);
break; break;
case ChipSortFactor.count: case ChipSortFactor.count:
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter)))); 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:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class FilterChipSectionHeader extends StatelessWidget { class FilterChipSectionHeader<T> extends StatelessWidget {
final ChipSectionKey sectionKey; final ChipSectionKey sectionKey;
const FilterChipSectionHeader({ const FilterChipSectionHeader({
@ -12,11 +12,10 @@ class FilterChipSectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionHeader( return SectionHeader<T>(
sectionKey: sectionKey, sectionKey: sectionKey,
leading: sectionKey.leading, leading: sectionKey.leading,
title: sectionKey.title, 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)); 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.pinned(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.pinned);
factory AlbumImportanceSectionKey.special(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.special); factory AlbumImportanceSectionKey.special(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.special);
@ -44,11 +46,13 @@ class AlbumImportanceSectionKey extends ChipSectionKey {
Widget get leading => Icon(importance.getIcon()); Widget get leading => Icon(importance.getIcon());
} }
enum AlbumImportance { pinned, special, apps, regular } enum AlbumImportance { newAlbum, pinned, special, apps, regular }
extension ExtraAlbumImportance on AlbumImportance { extension ExtraAlbumImportance on AlbumImportance {
String getText(BuildContext context) { String getText(BuildContext context) {
switch (this) { switch (this) {
case AlbumImportance.newAlbum:
return context.l10n.albumTierNew;
case AlbumImportance.pinned: case AlbumImportance.pinned:
return context.l10n.albumTierPinned; return context.l10n.albumTierPinned;
case AlbumImportance.special: case AlbumImportance.special:
@ -62,6 +66,8 @@ extension ExtraAlbumImportance on AlbumImportance {
IconData getIcon() { IconData getIcon() {
switch (this) { switch (this) {
case AlbumImportance.newAlbum:
return AIcons.newTier;
case AlbumImportance.pinned: case AlbumImportance.pinned:
return AIcons.pin; return AIcons.pin;
case AlbumImportance.special: case AlbumImportance.special:

View file

@ -41,7 +41,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
@override @override
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) { Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
return FilterChipSectionHeader( return FilterChipSectionHeader<FilterGridItem<T>>(
sectionKey: sectionKey as ChipSectionKey, 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/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.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/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/action_delegates/country_set.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class CountryListPage extends StatelessWidget {
builder: (context, s, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(), stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>( builder: (context, snapshot) {
source: source, final gridItems = _getGridItems(source);
title: context.l10n.countryPageTitle, return FilterNavigationPage<LocationFilter>(
sortFactor: settings.countrySortFactor, source: source,
chipSetActionDelegate: CountryChipSetActionDelegate(), title: context.l10n.countryPageTitle,
chipActionDelegate: ChipActionDelegate(), sortFactor: settings.countrySortFactor,
chipActionsBuilder: (filter) => [ actionDelegate: CountryChipSetActionDelegate(gridItems),
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, filterSections: _groupToSections(gridItems),
ChipAction.setCover, emptyBuilder: () => EmptyContent(
ChipAction.hide, icon: AIcons.location,
], text: context.l10n.countryEmpty,
filterSections: _getCountryEntries(source), ),
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 filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters); return FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
return _group(sorted);
} }
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 pinned = settings.pinnedFilters.whereType<LocationFilter>();
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []); 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/filters.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.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/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/action_delegates/tag_set.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class TagListPage extends StatelessWidget {
builder: (context, s, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(), stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<TagFilter>( builder: (context, snapshot) {
source: source, final gridItems = _getGridItems(source);
title: context.l10n.tagPageTitle, return FilterNavigationPage<TagFilter>(
sortFactor: settings.tagSortFactor, source: source,
chipSetActionDelegate: TagChipSetActionDelegate(), title: context.l10n.tagPageTitle,
chipActionDelegate: ChipActionDelegate(), sortFactor: settings.tagSortFactor,
chipActionsBuilder: (filter) => [ actionDelegate: TagChipSetActionDelegate(gridItems),
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, filterSections: _groupToSections(gridItems),
ChipAction.setCover, emptyBuilder: () => EmptyContent(
ChipAction.hide, icon: AIcons.tag,
], text: context.l10n.tagEmpty,
filterSections: _getTagEntries(source), ),
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 filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters); return FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
return _group(sorted);
} }
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 pinned = settings.pinnedFilters.whereType<TagFilter>();
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []); final pinnedMapEntries = (byPin[true] ?? []);

View file

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

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/filters.dart'; 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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -29,7 +30,7 @@ class HiddenFilterTile extends StatelessWidget {
} }
class HiddenFilterPage extends StatelessWidget { class HiddenFilterPage extends StatelessWidget {
static const routeName = '/settings/hidden'; static const routeName = '/settings/hidden_filters';
const HiddenFilterPage({Key? key}) : super(key: key); const HiddenFilterPage({Key? key}) : super(key: key);
@ -41,7 +42,7 @@ class HiddenFilterPage extends StatelessWidget {
), ),
body: SafeArea( body: SafeArea(
child: Selector<Settings, Set<CollectionFilter>>( 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) { builder: (context, hiddenFilters, child) {
if (hiddenFilters.isEmpty) { if (hiddenFilters.isEmpty) {
return Column( return Column(
@ -76,7 +77,7 @@ class HiddenFilterPage extends StatelessWidget {
.map((filter) => AvesFilterChip( .map((filter) => AvesFilterChip(
filter: filter, filter: filter,
removable: true, removable: true,
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true), onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility({filter}, true),
onLongPress: null, onLongPress: null,
)) ))
.toList(), .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/common/tile_leading.dart';
import 'package:aves/widgets/settings/privacy/access_grants.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_filters.dart';
import 'package:aves/widgets/settings/privacy/hidden_paths.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -47,6 +48,7 @@ class PrivacySection extends StatelessWidget {
title: Text(context.l10n.settingsSaveSearchHistory), title: Text(context.l10n.settingsSaveSearchHistory),
), ),
const HiddenFilterTile(), const HiddenFilterTile(),
const HiddenPathTile(),
const StorageAccessTile(), const StorageAccessTile(),
], ],
); );

View file

@ -36,7 +36,7 @@ class VideoSection extends StatelessWidget {
if (!standalonePage) if (!standalonePage)
SwitchListTile( SwitchListTile(
value: currentShowVideos, 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), title: Text(context.l10n.settingsVideoShowVideos),
), ),
const VideoActionsTile(), const VideoActionsTile(),

View file

@ -1,5 +1,6 @@
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.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:aves/widgets/settings/viewer/viewer_actions_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ViewerSection extends StatelessWidget { class ViewerSection extends StatelessWidget {
final ValueNotifier<String?> expandedNotifier; final ValueNotifier<String?> expandedNotifier;
@ -20,11 +22,6 @@ class ViewerSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return AvesExpansionTile(
leading: SettingsTileLeading( leading: SettingsTileLeading(
icon: AIcons.image, icon: AIcons.image,
@ -35,30 +32,92 @@ class ViewerSection extends StatelessWidget {
showHighlight: false, showHighlight: false,
children: [ children: [
const ViewerActionsTile(), const ViewerActionsTile(),
SwitchListTile( Selector<Settings, bool>(
value: currentShowOverlayMinimap, selector: (context, s) => s.showOverlayMinimap,
onChanged: (v) => settings.showOverlayMinimap = v, builder: (context, current, child) => SwitchListTile(
title: Text(context.l10n.settingsViewerShowMinimap), value: current,
onChanged: (v) => settings.showOverlayMinimap = v,
title: Text(context.l10n.settingsViewerShowMinimap),
),
), ),
SwitchListTile( Selector<Settings, bool>(
value: currentShowOverlayInfo, selector: (context, s) => s.showOverlayInfo,
onChanged: (v) => settings.showOverlayInfo = v, builder: (context, current, child) => SwitchListTile(
title: Text(context.l10n.settingsViewerShowInformation), value: current,
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle), onChanged: (v) => settings.showOverlayInfo = v,
title: Text(context.l10n.settingsViewerShowInformation),
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
),
), ),
SwitchListTile( Selector<Settings, Tuple2<bool, bool>>(
value: currentShowOverlayShootingDetails, selector: (context, s) => Tuple2(s.showOverlayInfo, s.showOverlayShootingDetails),
onChanged: currentShowOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null, builder: (context, s, child) {
title: Text(context.l10n.settingsViewerShowShootingDetails), 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( Selector<Settings, bool>(
title: Text(context.l10n.settingsImageBackground), selector: (context, s) => s.enableOverlayBlurEffect,
trailing: EntryBackgroundSelector( builder: (context, current, child) => SwitchListTile(
getter: () => currentImageBackground, value: current,
setter: (value) => settings.imageBackground = value, 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.export,
EntryAction.print, EntryAction.print,
EntryAction.rotateScreen, EntryAction.rotateScreen,
EntryAction.viewSource,
EntryAction.flip, EntryAction.flip,
EntryAction.rotateCCW, EntryAction.rotateCCW,
EntryAction.rotateCW, EntryAction.rotateCW,

View file

@ -28,10 +28,9 @@ class StatsPage extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final CollectionLens? parentCollection; final CollectionLens? parentCollection;
late final Set<AvesEntry> entries;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
Set<AvesEntry> get entries => parentCollection?.sortedEntries.toSet() ?? source.visibleEntries;
static const mimeDonutMinWidth = 124.0; static const mimeDonutMinWidth = 124.0;
StatsPage({ StatsPage({
@ -39,6 +38,7 @@ class StatsPage extends StatelessWidget {
required this.source, required this.source,
this.parentCollection, this.parentCollection,
}) : super(key: key) { }) : super(key: key) {
entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries;
entries.forEach((entry) { entries.forEach((entry) {
if (entry.hasAddress) { if (entry.hasAddress) {
final address = entry.addressDetails!; 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/dialogs/rename_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/debug/debug_page.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/info/notifications.dart';
import 'package:aves/widgets/viewer/printer.dart'; import 'package:aves/widgets/viewer/printer.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { 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) { void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
@ -52,7 +44,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
_showExportDialog(context, entry); _showExportDialog(context, entry);
break; break;
case EntryAction.info: case EntryAction.info:
showInfo(); ShowInfoNotification().dispatch(context);
break; break;
case EntryAction.rename: case EntryAction.rename:
_showRenameDialog(context, entry); _showRenameDialog(context, entry);
@ -100,6 +92,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.viewSource: case EntryAction.viewSource:
_goToSourceViewer(context, entry); _goToSourceViewer(context, entry);
break; break;
case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break;
case EntryAction.debug: case EntryAction.debug:
_goToDebug(context, entry); _goToDebug(context, entry);
break; break;
@ -158,8 +153,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await entry.delete()) { if (!await entry.delete()) {
showFeedback(context, context.l10n.genericFailureFeedback); showFeedback(context, context.l10n.genericFailureFeedback);
} else { } else {
if (collection != null) { final source = context.read<CollectionSource>();
await collection!.source.removeEntries({entry.uri}); if (source.initialized) {
await source.removeEntries({entry.uri});
} }
EntryDeletedNotification(entry).dispatch(context); EntryDeletedNotification(entry).dispatch(context);
} }
@ -186,7 +182,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultiPage) { if (entry.isMultiPage) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry); final multiPageInfo = await entry.getMultiPageInfo();
if (multiPageInfo != null) { if (multiPageInfo != null) {
if (entry.isMotionPhoto) { if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo(); await multiPageInfo.extractMotionPhotoVideo();
@ -212,8 +208,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
onDone: (processed) { onDone: (processed) {
final movedOps = processed.where((e) => e.success); final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length; final movedCount = movedOps.length;
final _collection = collection; final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
final showAction = _collection != null && movedCount > 0 final showAction = isMainMode && movedCount > 0
? SnackBarAction( ? SnackBarAction(
label: context.l10n.showButtonLabel, label: context.l10n.showButtonLabel,
onPressed: () async { onPressed: () async {

View file

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

View file

@ -18,7 +18,7 @@ class ViewerVerticalPageView extends StatefulWidget {
final PageController horizontalPager, verticalPager; final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImagePageRequested; final VoidCallback onImagePageRequested;
final void Function(String uri) onViewDisposed; final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const ViewerVerticalPageView({ const ViewerVerticalPageView({
Key? key, Key? key,
@ -98,7 +98,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
) )
: const SizedBox(); : const SizedBox();
final infoPage = NotificationListener<BackUpNotification>( final infoPage = NotificationListener<ShowImageNotification>(
onNotification: (notification) { onNotification: (notification) {
widget.onImagePageRequested(); widget.onImagePageRequested();
return true; 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/entry_viewer_stack.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/video/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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -23,15 +24,13 @@ class EntryViewerPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: Provider<VideoConductor>( body: ViewStateConductorProvider(
create: (context) => VideoConductor(), child: VideoConductorProvider(
dispose: (context, value) => value.dispose(), child: MultiPageConductorProvider(
child: Provider<MultiPageConductor>( child: EntryViewerStack(
create: (context) => MultiPageConductor(), collection: collection,
dispose: (context, value) => value.dispose(), initialEntry: initialEntry,
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 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.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/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
@ -12,28 +10,30 @@ import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/collection/collection_page.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/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/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/multipage/conductor.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/common.dart';
import 'package:aves/widgets/viewer/overlay/bottom/panorama.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/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/top.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/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.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:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryViewerStack extends StatefulWidget { class EntryViewerStack extends StatefulWidget {
final CollectionLens? collection; final CollectionLens? collection;
@ -49,7 +49,7 @@ class EntryViewerStack extends StatefulWidget {
_EntryViewerStackState createState() => _EntryViewerStackState(); _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); final ValueNotifier<AvesEntry?> _entryNotifier = ValueNotifier(null);
late int _currentHorizontalPage; late int _currentHorizontalPage;
late ValueNotifier<int> _currentVerticalPage; late ValueNotifier<int> _currentVerticalPage;
@ -60,9 +60,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
late Animation<double> _topOverlayScale, _bottomOverlayScale; late Animation<double> _topOverlayScale, _bottomOverlayScale;
late Animation<Offset> _bottomOverlayOffset; late Animation<Offset> _bottomOverlayOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding; EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late EntryActionDelegate _entryActionDelegate;
late VideoActionDelegate _videoActionDelegate; late VideoActionDelegate _videoActionDelegate;
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = []; final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null); final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true; bool _isEntryTracked = true;
@ -81,8 +80,19 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override @override
void initState() { void initState() {
super.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 // 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 // opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry); _heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry; _entryNotifier.value = entry;
@ -109,20 +119,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
curve: Curves.easeOutQuad, curve: Curves.easeOutQuad,
)); ));
_overlayVisible.addListener(_onOverlayVisibleChange); _overlayVisible.addListener(_onOverlayVisibleChange);
_entryActionDelegate = EntryActionDelegate(
collection: collection,
showInfo: () => _goToVerticalPage(infoPage),
);
_videoActionDelegate = VideoActionDelegate( _videoActionDelegate = VideoActionDelegate(
collection: collection, collection: collection,
); );
_initEntryControllers(); _initEntryControllers(entry);
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance!.addObserver(this); WidgetsBinding.instance!.addObserver(this);
WidgetsBinding.instance!.addPostFrameCallback((_) => _initOverlay()); WidgetsBinding.instance!.addPostFrameCallback((_) => _initOverlay());
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
windowService.keepScreenOn(true);
}
} }
@override @override
@ -134,6 +137,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override @override
void dispose() { void dispose() {
_cleanEntryControllers(_entryNotifier.value);
_videoActionDelegate.dispose();
_overlayAnimationController.dispose(); _overlayAnimationController.dispose();
_overlayVisible.removeListener(_onOverlayVisibleChange); _overlayVisible.removeListener(_onOverlayVisibleChange);
_verticalPager.removeListener(_onVerticalPageControllerChange); _verticalPager.removeListener(_onVerticalPageControllerChange);
@ -166,6 +171,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final viewStateConductor = context.read<ViewStateConductor>();
return WillPopScope( return WillPopScope(
onWillPop: () { onWillPop: () {
if (_currentVerticalPage.value == infoPage) { if (_currentVerticalPage.value == infoPage) {
@ -183,8 +189,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
onNotification: (dynamic notification) { onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) { if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter); _goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
} else if (notification is EntryDeletedNotification) { } else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry); _onEntryDeleted(context, notification.entry);
} }
@ -192,7 +196,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}, },
child: NotificationListener<ToggleOverlayNotification>( child: NotificationListener<ToggleOverlayNotification>(
onNotification: (notification) { onNotification: (notification) {
_overlayVisible.value = !_overlayVisible.value; _overlayVisible.value = notification.visible ?? !_overlayVisible.value;
return true; return true;
}, },
child: Stack( child: Stack(
@ -205,7 +209,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
onVerticalPageChanged: _onVerticalPageChanged, onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged, onHorizontalPageChanged: _onHorizontalPageChanged,
onImagePageRequested: () => _goToVerticalPage(imagePage), onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null), onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
), ),
_buildTopOverlay(), _buildTopOverlay(),
_buildBottomOverlay(), _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 _buildTopOverlay() {
Widget child = ValueListenableBuilder<AvesEntry?>( Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, mainEntry, child) { builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink(); if (mainEntry == null) return const SizedBox.shrink();
return ViewerTopOverlay( Widget _buildContent({AvesEntry? pageEntry}) {
mainEntry: mainEntry, return EmbeddedDataOpener(
scale: _topOverlayScale, entry: mainEntry,
canToggleFavourite: hasCollection, child: ViewerTopOverlay(
viewInsets: _frozenViewInsets, mainEntry: mainEntry,
viewPadding: _frozenViewPadding, scale: _topOverlayScale,
onActionSelected: (action) { canToggleFavourite: hasCollection,
var targetEntry = mainEntry; viewInsets: _frozenViewInsets,
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) { viewPadding: _frozenViewPadding,
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry); ),
if (multiPageController != null) { );
final multiPageInfo = multiPageController.info; }
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) { return NotificationListener<ShowInfoNotification>(
targetEntry = pageEntry; onNotification: (notification) {
} _goToVerticalPage(infoPage);
} return true;
}
_entryActionDelegate.onActionSelected(context, targetEntry, action);
}, },
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, valueListenable: _entryNotifier,
builder: (context, mainEntry, child) { builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink(); 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 // a 360 video is both a video and a panorama but only the video controls are displayed
if (pageEntry.isVideo) { if (targetEntry.isVideo) {
return Selector<VideoConductor, AvesVideoController?>( child = Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry), selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay( builder: (context, videoController, child) => VideoControlOverlay(
entry: pageEntry, entry: targetEntry,
controller: videoController, controller: videoController,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
onActionSelected: (action) { onActionSelected: (action) {
@ -301,40 +306,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
}, },
), ),
); );
} else if (pageEntry.is360) { } else if (targetEntry.is360) {
return PanoramaOverlay( child = PanoramaOverlay(
entry: pageEntry, entry: targetEntry,
scale: _bottomOverlayScale, 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 = mainEntry.isMultiPage
final extraBottomOverlay = multiPageController != null ? PageEntryBuilder(
? StreamBuilder<MultiPageInfo?>( multiPageController: multiPageController,
stream: multiPageController.infoStream, builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(),
builder: (context, snapshot) { )
final multiPageInfo = multiPageController.info; : _buildExtraBottomOverlay();
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);
return Column( return Column(
children: [ children: [
if (extraBottomOverlay != null) if (extraBottomOverlay != null) extraBottomOverlay,
ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: extraBottomOverlay,
),
SlideTransition( SlideTransition(
position: _bottomOverlayOffset, position: _bottomOverlayOffset,
child: ViewerBottomOverlay( child: ViewerBottomOverlay(
@ -421,7 +417,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
void _onVerticalPageChanged(int page) { void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page; _currentVerticalPage.value = page;
if (page == transitionPage) { if (page == transitionPage) {
_entryActionDelegate.dismissFeedback(context); dismissFeedback(context);
_popVisual(); _popVisual();
} else if (page == infoPage) { } else if (page == infoPage) {
// prevent hero when viewer is offscreen // 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; final newEntry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entryNotifier.value == newEntry) return; if (_entryNotifier.value == newEntry) return;
_cleanEntryControllers(_entryNotifier.value);
_entryNotifier.value = newEntry; _entryNotifier.value = newEntry;
_isEntryTracked = false; _isEntryTracked = false;
await _pauseVideoControllers(); await _pauseVideoControllers();
await _initEntryControllers(); await _initEntryControllers(newEntry);
} }
void _popVisual() { void _popVisual() {
@ -507,11 +504,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
} }
void _onLeave() { void _onLeave() {
_showSystemUI(); if (!settings.viewerUseCutout) {
windowService.requestOrientation(); windowService.setCutoutMode(true);
}
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
windowService.keepScreenOn(false); windowService.keepScreenOn(false);
} }
_showSystemUI();
windowService.requestOrientation();
} }
// system UI // system UI
@ -558,11 +559,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
// state controllers/monitors // state controllers/monitors
Future<void> _initEntryControllers() async { Future<void> _initEntryControllers(AvesEntry? entry) async {
final entry = _entryNotifier.value;
if (entry == null) return; if (entry == null) return;
_initViewStateController(entry);
if (entry.isVideo) { if (entry.isVideo) {
await _initVideoController(entry); await _initVideoController(entry);
} }
@ -571,17 +570,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
} }
} }
void _initViewStateController(AvesEntry entry) { void _cleanEntryControllers(AvesEntry? entry) {
final uri = entry.uri; if (entry == null) return;
var controller = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri);
if (controller != null) { if (entry.isMultiPage) {
_viewStateNotifiers.remove(controller); _cleanMultiPageController(entry);
} else {
controller = Tuple2(uri, ValueNotifier<ViewState>(ViewState.zero));
}
_viewStateNotifiers.insert(0, controller);
while (_viewStateNotifiers.length > 3) {
_viewStateNotifiers.removeLast().item2.dispose();
} }
} }
@ -626,11 +619,22 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
} }
} }
_multiPageControllerPageListeners[multiPageController] = _onPageChange;
multiPageController.pageNotifier.addListener(_onPageChange); multiPageController.pageNotifier.addListener(_onPageChange);
await _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 { Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
// video decoding may fail or have initial artifacts when the player initializes // video decoding may fail or have initial artifacts when the player initializes
// during this widget initialization (because of the page transition and hero animation?) // 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/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/insets.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/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/basic_section.dart';
import 'package:aves/widgets/viewer/info/info_app_bar.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/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_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/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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -34,9 +35,7 @@ class _InfoPageState extends State<InfoPage> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _scrollStartFromTop = false; bool _scrollStartFromTop = false;
CollectionLens? get collection => widget.collection; static const splitScreenWidthThreshold = 600;
AvesEntry? get entry => widget.entryNotifier.value;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -47,31 +46,39 @@ class _InfoPageState extends State<InfoPage> {
bottom: false, bottom: false,
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: _handleTopScroll, onNotification: _handleTopScroll,
child: NotificationListener<OpenTempEntryNotification>( child: Selector<MediaQueryData, double>(
onNotification: (notification) { selector: (c, mq) => mq.size.width,
_openTempEntry(notification.entry); builder: (c, mqWidth, child) {
return true; return ValueListenableBuilder<AvesEntry?>(
}, valueListenable: widget.entryNotifier,
child: Selector<MediaQueryData, double>( builder: (context, mainEntry, child) {
selector: (c, mq) => mq.size.width, if (mainEntry != null) {
builder: (c, mqWidth, child) { Widget _buildContent({AvesEntry? pageEntry}) {
return ValueListenableBuilder<AvesEntry?>( final targetEntry = pageEntry ?? mainEntry;
valueListenable: widget.entryNotifier, return EmbeddedDataOpener(
builder: (context, entry, child) { entry: targetEntry,
return entry != null child: _InfoPageContent(
? _InfoPageContent( collection: widget.collection,
collection: collection, entry: targetEntry,
entry: entry, isScrollingNotifier: widget.isScrollingNotifier,
isScrollingNotifier: widget.isScrollingNotifier, scrollController: _scrollController,
scrollController: _scrollController, split: mqWidth > splitScreenWidthThreshold,
split: mqWidth > 600, goToViewer: _goToViewer,
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() { void _goToViewer() {
BackUpNotification().dispatch(context); ShowImageNotification().dispatch(context);
_scrollController.animateTo( _scrollController.animateTo(
0, 0,
duration: Durations.viewerVerticalPageScrollAnimation, duration: Durations.viewerVerticalPageScrollAnimation,
curve: Curves.easeInOut, 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 { class _InfoPageContent extends StatefulWidget {

View file

@ -1,12 +1,10 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.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/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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_dir_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -112,11 +110,8 @@ class InfoSearchDelegate extends SearchDelegate {
icon: AIcons.info, icon: AIcons.info,
text: context.l10n.viewerInfoSearchEmpty, text: context.l10n.viewerInfoSearchEmpty,
) )
: NotificationListener<OpenTempEntryNotification>( : EmbeddedDataOpener(
onNotification: (notification) { entry: entry,
_openTempEntry(context, notification.entry);
return true;
},
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemBuilder: (context, index) => tiles[index], 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