Merge branch 'develop'
This commit is contained in:
commit
55e4710545
128 changed files with 3306 additions and 1816 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.4.6] - 2021-07-22
|
||||
### Added
|
||||
- Albums / Countries / Tags: multiple selection
|
||||
- Albums: action to create empty albums
|
||||
- Collection: burst shot grouping (Samsung naming pattern)
|
||||
- Collection: support motion photos defined by XMP Container namespace
|
||||
- Settings: hidden paths to exclude folders and their subfolders
|
||||
- Settings: option to disable viewer overlay blur effect (for older/slower devices)
|
||||
- Settings: option to exclude cutout area in viewer
|
||||
|
||||
### Changed
|
||||
- Video: restored overlay hiding when pressing play button
|
||||
|
||||
### Fixed
|
||||
- Viewer: fixed manual screen rotation to follow sensor
|
||||
|
||||
## [v1.4.5] - 2021-07-08
|
||||
### Added
|
||||
- Video: added OGV/Theora/Vorbis support
|
||||
|
|
|
@ -105,7 +105,7 @@ repositories {
|
|||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
|
|
Binary file not shown.
|
@ -36,6 +36,7 @@ class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
|
@ -114,7 +115,7 @@ class MainActivity : FlutterActivity() {
|
|||
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
|
||||
}
|
||||
}
|
||||
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST -> {
|
||||
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> {
|
||||
onPermissionResult(requestCode, data?.data)
|
||||
}
|
||||
}
|
||||
|
@ -196,6 +197,7 @@ class MainActivity : FlutterActivity() {
|
|||
const val DELETE_PERMISSION_REQUEST = 2
|
||||
const val CREATE_FILE_REQUEST = 3
|
||||
const val OPEN_FILE_REQUEST = 4
|
||||
const val SELECT_DIRECTORY_REQUEST = 5
|
||||
|
||||
// permission request code to pending runnable
|
||||
val pendingResultHandlers = ConcurrentHashMap<Int, PendingResultHandler>()
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ import deckers.thibault.aves.metadata.Metadata
|
|||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ContentImageProvider
|
||||
import deckers.thibault.aves.model.provider.ImageProvider
|
||||
|
@ -157,18 +158,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
// which is returned as a second XMP directory
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
try {
|
||||
val pathParts = dataPropPath.split('/')
|
||||
|
||||
val embedBytes: ByteArray = if (pathParts.size == 1) {
|
||||
val propName = pathParts[0]
|
||||
val propNs = XMP.namespaceForPropPath(propName)
|
||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
|
||||
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
||||
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first()
|
||||
} else {
|
||||
val structName = pathParts[0]
|
||||
val structNs = XMP.namespaceForPropPath(structName)
|
||||
val fieldName = pathParts[1]
|
||||
val fieldNs = XMP.namespaceForPropPath(fieldName)
|
||||
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
|
||||
xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let {
|
||||
XMPUtils.decodeBase64(it.value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -184,12 +184,33 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
|
||||
metadataMap.remove(dirName)
|
||||
}
|
||||
SonyVideoMetadata.USMT_UUID -> {
|
||||
QuickTimeMetadata.PROF_UUID -> {
|
||||
// redundant with info derived on the Dart side
|
||||
metadataMap.remove(dirName)
|
||||
}
|
||||
QuickTimeMetadata.USMT_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
val fields = SonyVideoMetadata.parseUsmt(bytes)
|
||||
if (fields.isNotEmpty()) {
|
||||
dirMap.remove("Data")
|
||||
dirMap.putAll(fields)
|
||||
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
|
||||
if (blocks.isNotEmpty()) {
|
||||
metadataMap.remove(dirName)
|
||||
dirName = "QuickTime User Media"
|
||||
val usmt = metadataMap[dirName] ?: HashMap()
|
||||
metadataMap[dirName] = usmt
|
||||
|
||||
blocks.forEach {
|
||||
var key = it.type
|
||||
var value = it.value
|
||||
val language = it.language
|
||||
|
||||
var i = 0
|
||||
while (usmt.containsKey(key)) {
|
||||
key = it.type + " (${++i})"
|
||||
}
|
||||
if (language != "und") {
|
||||
value += " ($language)"
|
||||
}
|
||||
usmt[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
|
@ -16,6 +17,8 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
|||
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
|
||||
"isRotationLocked" -> safe(call, result, ::isRotationLocked)
|
||||
"requestOrientation" -> safe(call, result, ::requestOrientation)
|
||||
"canSetCutoutMode" -> result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
"setCutoutMode" -> safe(call, result, ::setCutoutMode)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -57,6 +60,24 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
|||
result.success(true)
|
||||
}
|
||||
|
||||
private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
|
||||
val use = call.argument<Boolean>("use")
|
||||
if (use == null) {
|
||||
result.error("setCutoutMode-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val mode = if (use) {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
} else {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
|
||||
}
|
||||
activity.window.attributes.layoutInDisplayCutoutMode = mode
|
||||
}
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<WindowHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/window"
|
||||
|
|
|
@ -10,6 +10,7 @@ import deckers.thibault.aves.MainActivity
|
|||
import deckers.thibault.aves.PendingResultHandler
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -41,6 +42,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
"requestVolumeAccess" -> requestVolumeAccess()
|
||||
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
||||
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
|
||||
else -> endOfStream()
|
||||
}
|
||||
}
|
||||
|
@ -128,6 +130,24 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
|
||||
}
|
||||
|
||||
private fun selectDirectory() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
MainActivity.pendingResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingResultHandler(null, { uri ->
|
||||
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
|
||||
endOfStream()
|
||||
}, {
|
||||
success(null)
|
||||
endOfStream()
|
||||
})
|
||||
activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
|
||||
} else {
|
||||
success(null)
|
||||
endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: Any?) {
|
||||
|
|
|
@ -24,7 +24,7 @@ object Metadata {
|
|||
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
|
||||
|
||||
private val VIDEO_DATE_SUBSECOND_PATTERN = Pattern.compile("(\\d{6})(\\.\\d+)")
|
||||
private val VIDEO_TIMEZONE_PATTERN = Pattern.compile("(Z|[+-]\\d{4})$")
|
||||
private val VIDEO_TIME_ZONE_PATTERN = Pattern.compile("(Z|[+-]\\d{4})$")
|
||||
|
||||
// directory names, as shown when listing all metadata
|
||||
const val DIR_GPS = "GPS" // from metadata-extractor
|
||||
|
@ -71,7 +71,7 @@ object Metadata {
|
|||
|
||||
// optional time zone
|
||||
var timeZone: TimeZone? = null
|
||||
val timeZoneMatcher = VIDEO_TIMEZONE_PATTERN.matcher(dateString)
|
||||
val timeZoneMatcher = VIDEO_TIME_ZONE_PATTERN.matcher(dateString)
|
||||
if (timeZoneMatcher.find()) {
|
||||
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z", "")}")
|
||||
dateString = timeZoneMatcher.replaceAll("")
|
||||
|
|
|
@ -10,6 +10,7 @@ import android.util.Log
|
|||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLong
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
@ -140,7 +141,23 @@ object MultiPage {
|
|||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
var offsetFromEnd: Long? = null
|
||||
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
val xmpMeta = dir.xmpMeta
|
||||
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||
// GCamera motion photo
|
||||
xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
} else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
// Container motion photo
|
||||
val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)
|
||||
if (count == 2) {
|
||||
// expect the video to be the second item
|
||||
val i = 2
|
||||
val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value
|
||||
val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value
|
||||
if (MimeTypes.isVideo(mime) && length != null) {
|
||||
offsetFromEnd = length.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
return offsetFromEnd
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -4,7 +4,9 @@ import android.util.Log
|
|||
import com.adobe.internal.xmp.XMPError
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import com.adobe.internal.xmp.properties.XMPProperty
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import java.util.*
|
||||
|
||||
object XMP {
|
||||
|
@ -15,6 +17,15 @@ object XMP {
|
|||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||
private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
||||
|
||||
// other namespaces
|
||||
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
|
||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
|
||||
const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
||||
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
|
||||
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
|
||||
|
||||
const val SUBJECT_PROP_NAME = "dc:subject"
|
||||
const val TITLE_PROP_NAME = "dc:title"
|
||||
|
@ -26,11 +37,13 @@ object XMP {
|
|||
private const val SPECIFIC_LANG = "en-US"
|
||||
|
||||
private val schemas = hashMapOf(
|
||||
"GAudio" to "http://ns.google.com/photos/1.0/audio/",
|
||||
"GDepth" to "http://ns.google.com/photos/1.0/depthmap/",
|
||||
"GImage" to "http://ns.google.com/photos/1.0/image/",
|
||||
"Container" to CONTAINER_SCHEMA_NS,
|
||||
"GAudio" to GAUDIO_SCHEMA_NS,
|
||||
"GDepth" to GDEPTH_SCHEMA_NS,
|
||||
"GImage" to GIMAGE_SCHEMA_NS,
|
||||
"Item" to CONTAINER_ITEM_SCHEMA_NS,
|
||||
"xmp" to XMP_SCHEMA_NS,
|
||||
"xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/",
|
||||
"xmpGImg" to XMP_GIMG_SCHEMA_NS,
|
||||
)
|
||||
|
||||
fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
|
||||
|
@ -44,9 +57,11 @@ object XMP {
|
|||
|
||||
// motion photo
|
||||
|
||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||
|
||||
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
|
||||
const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory"
|
||||
const val CONTAINER_ITEM_PROP_NAME = "Container:Item"
|
||||
const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length"
|
||||
const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime"
|
||||
|
||||
// panorama
|
||||
// cf https://developers.google.com/streetview/spherical-metadata
|
||||
|
@ -79,7 +94,26 @@ object XMP {
|
|||
|
||||
fun XMPMeta.isMotionPhoto(): Boolean {
|
||||
try {
|
||||
return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)
|
||||
// GCamera motion photo
|
||||
if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
|
||||
|
||||
// Container motion photo
|
||||
if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)
|
||||
if (count == 2) {
|
||||
var hasImage = false
|
||||
var hasVideo = false
|
||||
for (i in 1 until count + 1) {
|
||||
val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value
|
||||
val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value
|
||||
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
||||
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
||||
}
|
||||
if (hasImage && hasVideo) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
|
@ -188,4 +222,21 @@ object XMP {
|
|||
Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e)
|
||||
}
|
||||
}
|
||||
|
||||
// e.g. 'Container:Directory[42]/Container:Item/Item:Mime'
|
||||
fun XMPMeta.getSafeStructField(path: String): XMPProperty? {
|
||||
val separator = path.lastIndexOf("/")
|
||||
if (separator != -1) {
|
||||
val structName = path.substring(0, separator)
|
||||
val structNs = namespaceForPropPath(structName)
|
||||
val fieldName = path.substring(separator + 1)
|
||||
val fieldNs = namespaceForPropPath(fieldName)
|
||||
try {
|
||||
return getStructField(structNs, structName, fieldNs, fieldName)
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -133,8 +133,7 @@ object PermissionManager {
|
|||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
||||
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||
releaseUriPermission(context, it)
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
|
@ -158,4 +157,28 @@ object PermissionManager {
|
|||
}
|
||||
return accessibleDirs
|
||||
}
|
||||
|
||||
// As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
|
||||
// URI permissions we hold points to a folder that no longer exists,
|
||||
// so we should remove these obsolete URIs before proceeding.
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun sanitizePersistedUriPermissions(context: Context) {
|
||||
try {
|
||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||
val uri = uriPermission.uri
|
||||
val path = StorageUtils.convertTreeUriToDirPath(context, uri)
|
||||
if (path != null && !File(path).exists()) {
|
||||
Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path")
|
||||
releaseUriPermission(context, uri)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to sanitize persisted URI permissions", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseUriPermission(context: Context, it: Uri) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||
}
|
||||
}
|
|
@ -293,6 +293,7 @@ object StorageUtils {
|
|||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
|
||||
// cleanest API to get it
|
||||
PermissionManager.sanitizePersistedUriPermissions(context)
|
||||
try {
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
if (docUri != null) {
|
||||
|
|
|
@ -6,7 +6,7 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.2.1'
|
||||
classpath 'com.android.tools.build:gradle:4.2.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.8'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
||||
|
|
|
@ -59,6 +59,8 @@
|
|||
"@chipActionRename": {},
|
||||
"chipActionSetCover": "Set cover",
|
||||
"@chipActionSetCover": {},
|
||||
"chipActionCreateAlbum": "Create album",
|
||||
"@chipActionCreateAlbum": {},
|
||||
|
||||
"entryActionDelete": "Delete",
|
||||
"@entryActionDelete": {},
|
||||
|
@ -80,6 +82,8 @@
|
|||
"@entryActionShare": {},
|
||||
"entryActionViewSource": "View source",
|
||||
"@entryActionViewSource": {},
|
||||
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
|
||||
"@entryActionViewMotionPhotoVideo": {},
|
||||
"entryActionEdit": "Edit with…",
|
||||
"@entryActionEdit": {},
|
||||
"entryActionOpen": "Open with…",
|
||||
|
@ -165,6 +169,8 @@
|
|||
"keepScreenOnAlways": "Always",
|
||||
"@keepScreenOnAlways": {},
|
||||
|
||||
"albumTierNew": "New",
|
||||
"@albumTierNew": {},
|
||||
"albumTierPinned": "Pinned",
|
||||
"@albumTierPinned": {},
|
||||
"albumTierSpecial": "Common",
|
||||
|
@ -272,8 +278,14 @@
|
|||
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
|
||||
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
|
||||
|
||||
"deleteAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
|
||||
"@deleteAlbumConfirmationDialogMessage": {
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
|
||||
"@deleteSingleAlbumConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete these albums and their item?} other{Are you sure you want to delete these albums and their {count} items?}}",
|
||||
"@deleteMultiAlbumConfirmationDialogMessage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
|
@ -509,6 +521,8 @@
|
|||
"@createAlbumTooltip": {},
|
||||
"createAlbumButtonLabel": "CREATE",
|
||||
"@createAlbumButtonLabel": {},
|
||||
"newFilterBanner": "new",
|
||||
"@newFilterBanner": {},
|
||||
|
||||
"countryPageTitle": "Countries",
|
||||
"@countryPageTitle": {},
|
||||
|
@ -575,6 +589,10 @@
|
|||
"@settingsViewerShowInformationSubtitle": {},
|
||||
"settingsViewerShowShootingDetails": "Show shooting details",
|
||||
"@settingsViewerShowShootingDetails": {},
|
||||
"settingsViewerEnableOverlayBlurEffect": "Overlay blur effect",
|
||||
"@settingsViewerEnableOverlayBlurEffect": {},
|
||||
"settingsViewerUseCutout": "Use cutout area",
|
||||
"@settingsViewerUseCutout": {},
|
||||
|
||||
"settingsViewerQuickActionsTile": "Quick actions",
|
||||
"@settingsViewerQuickActionsTile": {},
|
||||
|
@ -630,11 +648,11 @@
|
|||
"@settingsSubtitleThemeBackgroundColor": {},
|
||||
"settingsSubtitleThemeBackgroundOpacity": "Background opacity",
|
||||
"@settingsSubtitleThemeBackgroundOpacity": {},
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "Left",
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "Left",
|
||||
"@settingsSubtitleThemeTextAlignmentLeft": {},
|
||||
"settingsSubtitleThemeTextAlignmentCenter": "Center",
|
||||
"@settingsSubtitleThemeTextAlignmentCenter": {},
|
||||
"settingsSubtitleThemeTextAlignmentRight": "Right",
|
||||
"settingsSubtitleThemeTextAlignmentRight": "Right",
|
||||
"@settingsSubtitleThemeTextAlignmentRight": {},
|
||||
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
|
@ -653,6 +671,19 @@
|
|||
"settingsHiddenFiltersEmpty": "No hidden filters",
|
||||
"@settingsHiddenFiltersEmpty": {},
|
||||
|
||||
"settingsHiddenPathsTile": "Hidden paths",
|
||||
"@settingsHiddenPathsTile": {},
|
||||
"settingsHiddenPathsTitle": "Hidden Paths",
|
||||
"@settingsHiddenPathsTitle": {},
|
||||
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
|
||||
"@settingsHiddenPathsBanner": {},
|
||||
"settingsHiddenPathsEmpty": "No hidden paths",
|
||||
"@settingsHiddenPathsEmpty": {},
|
||||
"settingsHiddenPathsRemoveTooltip": "Remove",
|
||||
"@settingsHiddenPathsRemoveTooltip": {},
|
||||
"addPathTooltip": "Add path",
|
||||
"@addPathTooltip": {},
|
||||
|
||||
"settingsStorageAccessTile": "Storage access",
|
||||
"@settingsStorageAccessTile": {},
|
||||
"settingsStorageAccessTitle": "Storage Access",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"chipActionUnpin": "고정 해제",
|
||||
"chipActionRename": "이름 변경",
|
||||
"chipActionSetCover": "대표 이미지 변경",
|
||||
"chipActionCreateAlbum": "앨범 만들기",
|
||||
|
||||
"entryActionDelete": "삭제",
|
||||
"entryActionExport": "내보내기",
|
||||
|
@ -40,6 +41,7 @@
|
|||
"entryActionPrint": "인쇄",
|
||||
"entryActionShare": "공유",
|
||||
"entryActionViewSource": "소스 코드 보기",
|
||||
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
|
||||
"entryActionEdit": "편집…",
|
||||
"entryActionOpen": "다른 앱에서 열기…",
|
||||
"entryActionSetAs": "다음 용도로 사용…",
|
||||
|
@ -86,6 +88,7 @@
|
|||
"keepScreenOnViewerOnly": "뷰어 이용 시 작동",
|
||||
"keepScreenOnAlways": "항상 켜짐",
|
||||
|
||||
"albumTierNew": "신규",
|
||||
"albumTierPinned": "고정",
|
||||
"albumTierSpecial": "기본",
|
||||
"albumTierApps": "앱",
|
||||
|
@ -124,7 +127,8 @@
|
|||
"renameAlbumDialogLabel": "앨범 이름",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "사용 중인 이름입니다",
|
||||
|
||||
"deleteAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
|
||||
|
||||
"renameEntryDialogLabel": "이름",
|
||||
|
||||
|
@ -231,8 +235,9 @@
|
|||
|
||||
"albumPageTitle": "앨범",
|
||||
"albumEmpty": "앨범이 없습니다",
|
||||
"createAlbumTooltip": "새 앨범 만들기",
|
||||
"createAlbumTooltip": "앨범 만들기",
|
||||
"createAlbumButtonLabel": "추가",
|
||||
"newFilterBanner": "신규",
|
||||
|
||||
"countryPageTitle": "국가",
|
||||
"countryEmpty": "국가가 없습니다",
|
||||
|
@ -270,6 +275,8 @@
|
|||
"settingsViewerShowInformation": "상세 정보 표시",
|
||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
|
||||
"settingsViewerUseCutout": "컷아웃 영역 사용",
|
||||
|
||||
"settingsViewerQuickActionsTile": "빠른 작업",
|
||||
"settingsViewerQuickActionEditorTitle": "빠른 작업",
|
||||
|
@ -299,9 +306,9 @@
|
|||
"settingsSubtitleThemeTextOpacity": "글자 투명도",
|
||||
"settingsSubtitleThemeBackgroundColor": "배경 색상",
|
||||
"settingsSubtitleThemeBackgroundOpacity": "배경 투명도",
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
|
||||
"settingsSubtitleThemeTextAlignmentCenter": "가운데",
|
||||
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
|
||||
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
|
||||
|
||||
"settingsSectionPrivacy": "개인정보 보호",
|
||||
"settingsEnableCrashReport": "오류 보고서 보내기",
|
||||
|
@ -312,6 +319,13 @@
|
|||
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
|
||||
|
||||
"settingsHiddenPathsTile": "숨겨진 경로",
|
||||
"settingsHiddenPathsTitle": "숨겨진 경로",
|
||||
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
|
||||
"settingsHiddenPathsRemoveTooltip": "제거",
|
||||
"addPathTooltip": "경로 추가",
|
||||
|
||||
"settingsStorageAccessTile": "저장공간 접근",
|
||||
"settingsStorageAccessTitle": "저장공간 접근",
|
||||
"settingsStorageAccessBanner": "어떤 폴더는 사용자의 허용을 받아야만 앱이 파일에 접근이 가능합니다. 이 화면에 허용을 받은 폴더를 확인할 수 있으며 원하지 않으면 취소할 수 있습니다.",
|
||||
|
|
|
@ -2,29 +2,16 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum ChipSetAction {
|
||||
group,
|
||||
sort,
|
||||
stats,
|
||||
}
|
||||
|
||||
enum ChipAction {
|
||||
delete,
|
||||
hide,
|
||||
pin,
|
||||
unpin,
|
||||
rename,
|
||||
setCover,
|
||||
goToAlbumPage,
|
||||
goToCountryPage,
|
||||
goToTagPage,
|
||||
hide,
|
||||
}
|
||||
|
||||
extension ExtraChipAction on ChipAction {
|
||||
String getText(BuildContext context) {
|
||||
switch (this) {
|
||||
case ChipAction.delete:
|
||||
return context.l10n.chipActionDelete;
|
||||
case ChipAction.goToAlbumPage:
|
||||
return context.l10n.chipActionGoToAlbumPage;
|
||||
case ChipAction.goToCountryPage:
|
||||
|
@ -33,21 +20,11 @@ extension ExtraChipAction on ChipAction {
|
|||
return context.l10n.chipActionGoToTagPage;
|
||||
case ChipAction.hide:
|
||||
return context.l10n.chipActionHide;
|
||||
case ChipAction.pin:
|
||||
return context.l10n.chipActionPin;
|
||||
case ChipAction.unpin:
|
||||
return context.l10n.chipActionUnpin;
|
||||
case ChipAction.rename:
|
||||
return context.l10n.chipActionRename;
|
||||
case ChipAction.setCover:
|
||||
return context.l10n.chipActionSetCover;
|
||||
}
|
||||
}
|
||||
|
||||
IconData getIcon() {
|
||||
switch (this) {
|
||||
case ChipAction.delete:
|
||||
return AIcons.delete;
|
||||
case ChipAction.goToAlbumPage:
|
||||
return AIcons.album;
|
||||
case ChipAction.goToCountryPage:
|
||||
|
@ -56,13 +33,6 @@ extension ExtraChipAction on ChipAction {
|
|||
return AIcons.tag;
|
||||
case ChipAction.hide:
|
||||
return AIcons.hide;
|
||||
case ChipAction.pin:
|
||||
case ChipAction.unpin:
|
||||
return AIcons.pin;
|
||||
case ChipAction.rename:
|
||||
return AIcons.rename;
|
||||
case ChipAction.setCover:
|
||||
return AIcons.setCover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
91
lib/model/actions/chip_set_actions.dart
Normal file
91
lib/model/actions/chip_set_actions.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,6 +16,8 @@ enum EntryAction {
|
|||
flip,
|
||||
// vector
|
||||
viewSource,
|
||||
// motion photo,
|
||||
viewMotionPhotoVideo,
|
||||
// external
|
||||
edit,
|
||||
open,
|
||||
|
@ -42,6 +44,7 @@ class EntryActions {
|
|||
EntryAction.export,
|
||||
EntryAction.print,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.viewMotionPhotoVideo,
|
||||
EntryAction.rotateScreen,
|
||||
];
|
||||
|
||||
|
@ -87,6 +90,9 @@ extension ExtraEntryAction on EntryAction {
|
|||
// vector
|
||||
case EntryAction.viewSource:
|
||||
return context.l10n.entryActionViewSource;
|
||||
// motion photo
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
return context.l10n.entryActionViewMotionPhotoVideo;
|
||||
// external
|
||||
case EntryAction.edit:
|
||||
return context.l10n.entryActionEdit;
|
||||
|
@ -132,6 +138,9 @@ extension ExtraEntryAction on EntryAction {
|
|||
// vector
|
||||
case EntryAction.viewSource:
|
||||
return AIcons.vector;
|
||||
// motion photo
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
return AIcons.motionPhoto;
|
||||
// external
|
||||
case EntryAction.edit:
|
||||
case EntryAction.open:
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart';
|
|||
import 'package:aves/model/entry_cache.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/video/metadata.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
|
@ -39,6 +40,8 @@ class AvesEntry {
|
|||
CatalogMetadata? _catalogMetadata;
|
||||
AddressDetails? _addressDetails;
|
||||
|
||||
List<AvesEntry>? burstEntries;
|
||||
|
||||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||
|
||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
|
@ -64,6 +67,7 @@ class AvesEntry {
|
|||
required int? dateModifiedSecs,
|
||||
required this.sourceDateTakenMillis,
|
||||
required int? durationMillis,
|
||||
this.burstEntries,
|
||||
}) {
|
||||
this.path = path;
|
||||
this.sourceTitle = sourceTitle;
|
||||
|
@ -80,6 +84,7 @@ class AvesEntry {
|
|||
String? path,
|
||||
int? contentId,
|
||||
int? dateModifiedSecs,
|
||||
List<AvesEntry>? burstEntries,
|
||||
}) {
|
||||
final copyContentId = contentId ?? this.contentId;
|
||||
final copied = AvesEntry(
|
||||
|
@ -96,6 +101,7 @@ class AvesEntry {
|
|||
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
burstEntries: burstEntries ?? this.burstEntries,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
||||
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
||||
|
@ -228,10 +234,6 @@ class AvesEntry {
|
|||
|
||||
bool get is360 => _catalogMetadata?.is360 ?? false;
|
||||
|
||||
bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false;
|
||||
|
||||
bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg;
|
||||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
@ -652,6 +654,51 @@ class AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
// multipage
|
||||
|
||||
static final _burstFilenamePattern = RegExp(r'^(\d{8}_\d{6})_(\d+)$');
|
||||
|
||||
bool get isMultiPage => (_catalogMetadata?.isMultiPage ?? false) || isBurst;
|
||||
|
||||
bool get isBurst => burstEntries?.isNotEmpty == true;
|
||||
|
||||
bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
|
||||
|
||||
String? get burstKey {
|
||||
if (filenameWithoutExtension != null) {
|
||||
final match = _burstFilenamePattern.firstMatch(filenameWithoutExtension!);
|
||||
if (match != null) {
|
||||
return '$directory/${match.group(1)}';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<MultiPageInfo?> getMultiPageInfo() async {
|
||||
if (isBurst) {
|
||||
return MultiPageInfo(
|
||||
mainEntry: this,
|
||||
pages: burstEntries!
|
||||
.mapIndexed((index, entry) => SinglePageInfo(
|
||||
index: index,
|
||||
pageId: entry.contentId!,
|
||||
isDefault: index == 0,
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
durationMillis: entry.durationMillis,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return await metadataService.getMultiPageInfo(this);
|
||||
}
|
||||
}
|
||||
|
||||
// sort
|
||||
|
||||
// compare by:
|
||||
// 1) title ascending
|
||||
// 2) extension ascending
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
|
|||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/filters/path.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/filters/type.dart';
|
||||
|
@ -22,6 +23,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
AlbumFilter.type,
|
||||
LocationFilter.type,
|
||||
TagFilter.type,
|
||||
PathFilter.type,
|
||||
];
|
||||
|
||||
static CollectionFilter? fromJson(String jsonString) {
|
||||
|
@ -43,6 +45,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
return QueryFilter.fromMap(jsonMap);
|
||||
case TagFilter.type:
|
||||
return TagFilter.fromMap(jsonMap);
|
||||
case PathFilter.type:
|
||||
return PathFilter.fromMap(jsonMap);
|
||||
}
|
||||
}
|
||||
debugPrint('failed to parse filter from json=$jsonString');
|
||||
|
@ -65,7 +69,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
|
||||
String getTooltip(BuildContext context) => getLabel(context);
|
||||
|
||||
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false});
|
||||
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => null;
|
||||
|
||||
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));
|
||||
|
||||
|
|
46
lib/model/filters/path.dart
Normal file
46
lib/model/filters/path.dart
Normal 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}';
|
||||
}
|
|
@ -22,6 +22,14 @@ class MultiPageInfo {
|
|||
final firstPage = _pages.removeAt(0);
|
||||
_pages.insert(0, firstPage.copyWith(isDefault: true));
|
||||
}
|
||||
|
||||
final burstEntries = mainEntry.burstEntries;
|
||||
if (burstEntries != null) {
|
||||
_pageEntries.addEntries(pages.map((pageInfo) {
|
||||
final pageEntry = burstEntries.firstWhere((entry) => entry.uri == pageInfo.uri);
|
||||
return MapEntry(pageInfo, pageEntry);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,13 +22,9 @@ class PanoramaInfo {
|
|||
final projectionType = map['projectionType'] as String?;
|
||||
|
||||
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
|
||||
if (fHeight == null && cWidth != null && cHeight != null) {
|
||||
// assume the cropped area is actually covering 360 degrees horizontally
|
||||
// even when `croppedAreaLeft` is non zero
|
||||
fWidth = cWidth;
|
||||
if (fHeight == null && fWidth != null && cHeight != null) {
|
||||
fHeight = (fWidth / 2).round();
|
||||
cTop = ((fHeight - cHeight) / 2).round();
|
||||
cLeft = 0;
|
||||
}
|
||||
|
||||
Rect? croppedAreaRect;
|
||||
|
|
45
lib/model/selection.dart
Normal file
45
lib/model/selection.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/video_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/device_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -61,13 +64,15 @@ class Settings extends ChangeNotifier {
|
|||
static const hiddenFiltersKey = 'hidden_filters';
|
||||
|
||||
// viewer
|
||||
static const viewerQuickActionsKey = 'viewer_quick_actions';
|
||||
static const showOverlayMinimapKey = 'show_overlay_minimap';
|
||||
static const showOverlayInfoKey = 'show_overlay_info';
|
||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||
static const viewerQuickActionsKey = 'viewer_quick_actions';
|
||||
static const videoQuickActionsKey = 'video_quick_actions';
|
||||
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
|
||||
static const viewerUseCutoutKey = 'viewer_use_cutout';
|
||||
|
||||
// video
|
||||
static const videoQuickActionsKey = 'video_quick_actions';
|
||||
static const enableVideoHardwareAccelerationKey = 'video_hwaccel_mediacodec';
|
||||
static const enableVideoAutoPlayKey = 'video_auto_play';
|
||||
static const videoLoopModeKey = 'video_loop';
|
||||
|
@ -126,6 +131,21 @@ class Settings extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> setContextualDefaults() async {
|
||||
// performance
|
||||
final performanceClass = await DeviceService.getPerformanceClass();
|
||||
enableOverlayBlurEffect = performanceClass >= 30;
|
||||
|
||||
// availability
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
if (hasPlayServices) {
|
||||
infoMapStyle = EntryMapStyle.googleNormal;
|
||||
} else {
|
||||
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
|
||||
infoMapStyle = styles[Random().nextInt(styles.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// app
|
||||
|
||||
bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false);
|
||||
|
@ -192,9 +212,9 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// collection
|
||||
|
||||
EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
|
||||
EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
|
||||
|
||||
set collectionGroupFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
|
||||
set collectionSectionFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
|
||||
|
||||
EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values);
|
||||
|
||||
|
@ -240,6 +260,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// viewer
|
||||
|
||||
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
|
||||
|
||||
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||
|
||||
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, false);
|
||||
|
||||
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
|
||||
|
@ -252,16 +276,20 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue);
|
||||
|
||||
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, viewerQuickActionsDefault, EntryAction.values);
|
||||
bool get enableOverlayBlurEffect => getBoolOrDefault(enableOverlayBlurEffectKey, true);
|
||||
|
||||
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||
set enableOverlayBlurEffect(bool newValue) => setAndNotify(enableOverlayBlurEffectKey, newValue);
|
||||
|
||||
bool get viewerUseCutout => getBoolOrDefault(viewerUseCutoutKey, true);
|
||||
|
||||
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
|
||||
|
||||
// video
|
||||
|
||||
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, videoQuickActionsDefault, VideoAction.values);
|
||||
|
||||
set videoQuickActions(List<VideoAction> newValue) => setAndNotify(videoQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||
|
||||
// video
|
||||
|
||||
bool get enableVideoHardwareAcceleration => getBoolOrDefault(enableVideoHardwareAccelerationKey, true);
|
||||
|
||||
set enableVideoHardwareAcceleration(bool newValue) => setAndNotify(enableVideoHardwareAccelerationKey, newValue);
|
||||
|
@ -452,6 +480,8 @@ class Settings extends ChangeNotifier {
|
|||
case showOverlayMinimapKey:
|
||||
case showOverlayInfoKey:
|
||||
case showOverlayShootingDetailsKey:
|
||||
case enableOverlayBlurEffectKey:
|
||||
case viewerUseCutoutKey:
|
||||
case enableVideoHardwareAccelerationKey:
|
||||
case enableVideoAutoPlayKey:
|
||||
case subtitleShowOutlineKey:
|
||||
|
|
|
@ -10,9 +10,12 @@ import 'package:flutter/widgets.dart';
|
|||
|
||||
mixin AlbumMixin on SourceBase {
|
||||
final Set<String?> _directories = {};
|
||||
final Set<String> _newAlbums = {};
|
||||
|
||||
List<String> get rawAlbums => List.unmodifiable(_directories);
|
||||
|
||||
Set<AlbumFilter> getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => AlbumFilter(v, getAlbumDisplayName(context, v))));
|
||||
|
||||
int compareAlbumsByName(String a, String b) {
|
||||
final ua = getAlbumDisplayName(null, a);
|
||||
final ub = getAlbumDisplayName(null, b);
|
||||
|
@ -109,7 +112,7 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
|
||||
void cleanEmptyAlbums([Set<String?>? albums]) {
|
||||
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet();
|
||||
final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet();
|
||||
if (emptyAlbums.isNotEmpty) {
|
||||
_directories.removeAll(emptyAlbums);
|
||||
_notifyAlbumChange();
|
||||
|
@ -148,6 +151,22 @@ mixin AlbumMixin on SourceBase {
|
|||
AvesEntry? albumRecentEntry(AlbumFilter filter) {
|
||||
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
|
||||
}
|
||||
|
||||
void createAlbum(String directory) {
|
||||
_newAlbums.add(directory);
|
||||
addDirectories({directory});
|
||||
}
|
||||
|
||||
void renameNewAlbum(String source, String destination) {
|
||||
if (_newAlbums.remove(source)) {
|
||||
cleanEmptyAlbums({source});
|
||||
createAlbum(destination);
|
||||
}
|
||||
}
|
||||
|
||||
void forgetNewAlbums(Set<String> directories) {
|
||||
_newAlbums.removeAll(directories);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumsChangedEvent {}
|
||||
|
|
|
@ -13,17 +13,18 @@ import 'package:aves/model/source/location.dart';
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/collection_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
||||
class CollectionLens with ChangeNotifier {
|
||||
final CollectionSource source;
|
||||
final Set<CollectionFilter> filters;
|
||||
EntryGroupFactor groupFactor;
|
||||
EntryGroupFactor sectionFactor;
|
||||
EntrySortFactor sortFactor;
|
||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier();
|
||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
int? id;
|
||||
bool listenToSource;
|
||||
|
@ -38,7 +39,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
this.id,
|
||||
this.listenToSource = true,
|
||||
}) : filters = (filters ?? {}).whereNotNull().toSet(),
|
||||
groupFactor = settings.collectionGroupFactor,
|
||||
sectionFactor = settings.collectionSectionFactor,
|
||||
sortFactor = settings.collectionSortFactor {
|
||||
id ??= hashCode;
|
||||
if (listenToSource) {
|
||||
|
@ -73,7 +74,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
|
||||
int get entryCount => _filteredSortedEntries.length;
|
||||
|
||||
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
|
||||
// sorted as displayed to the user, i.e. sorted then sectioned, not an absolute order on all entries
|
||||
List<AvesEntry>? _sortedEntries;
|
||||
|
||||
List<AvesEntry> get sortedEntries {
|
||||
|
@ -84,9 +85,9 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
bool get showHeaders {
|
||||
if (sortFactor == EntrySortFactor.size) return false;
|
||||
|
||||
if (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.none) return false;
|
||||
if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false;
|
||||
|
||||
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.album);
|
||||
final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album);
|
||||
final filterByAlbum = filters.any((f) => f is AlbumFilter);
|
||||
if (albumSections && filterByAlbum) return false;
|
||||
|
||||
|
@ -113,9 +114,33 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
filterChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
final bool groupBursts = true;
|
||||
|
||||
void _applyFilters() {
|
||||
final entries = source.visibleEntries;
|
||||
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
|
||||
|
||||
if (groupBursts) {
|
||||
_groupBursts();
|
||||
}
|
||||
}
|
||||
|
||||
void _groupBursts() {
|
||||
final byBurstKey = groupBy<AvesEntry, String?>(_filteredSortedEntries, (entry) => entry.burstKey).whereNotNullKey();
|
||||
byBurstKey.forEach((burstKey, entries) {
|
||||
if (entries.length > 1) {
|
||||
entries.sort(AvesEntry.compareByName);
|
||||
final mainEntry = entries.first;
|
||||
final burstEntry = mainEntry.copyWith(burstEntries: entries);
|
||||
|
||||
entries.skip(1).toList().forEach((subEntry) {
|
||||
_filteredSortedEntries.remove(subEntry);
|
||||
});
|
||||
final index = _filteredSortedEntries.indexOf(mainEntry);
|
||||
_filteredSortedEntries.removeAt(index);
|
||||
_filteredSortedEntries.insert(index, burstEntry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _applySort() {
|
||||
|
@ -132,10 +157,10 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
}
|
||||
}
|
||||
|
||||
void _applyGroup() {
|
||||
void _applySection() {
|
||||
switch (sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (groupFactor) {
|
||||
switch (sectionFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||
break;
|
||||
|
@ -168,11 +193,11 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
}
|
||||
|
||||
// metadata change should also trigger a full refresh
|
||||
// as dates impact sorting and grouping
|
||||
// as dates impact sorting and sectioning
|
||||
void _refresh() {
|
||||
_applyFilters();
|
||||
_applySort();
|
||||
_applyGroup();
|
||||
_applySection();
|
||||
}
|
||||
|
||||
void _onFavouritesChanged() {
|
||||
|
@ -183,21 +208,21 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
|
||||
void _onSettingsChanged() {
|
||||
final newSortFactor = settings.collectionSortFactor;
|
||||
final newGroupFactor = settings.collectionGroupFactor;
|
||||
final newSectionFactor = settings.collectionSectionFactor;
|
||||
|
||||
final needSort = sortFactor != newSortFactor;
|
||||
final needGroup = needSort || groupFactor != newGroupFactor;
|
||||
final needSection = needSort || sectionFactor != newSectionFactor;
|
||||
|
||||
if (needSort) {
|
||||
sortFactor = newSortFactor;
|
||||
_applySort();
|
||||
}
|
||||
if (needGroup) {
|
||||
groupFactor = newGroupFactor;
|
||||
_applyGroup();
|
||||
if (needSection) {
|
||||
sectionFactor = newSectionFactor;
|
||||
_applySection();
|
||||
}
|
||||
if (needSort || needGroup) {
|
||||
sortGroupChangeNotifier.notifyListeners();
|
||||
if (needSort || needSection) {
|
||||
sortSectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -206,62 +231,32 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
}
|
||||
|
||||
void onEntryRemoved(Set<AvesEntry> entries) {
|
||||
if (groupBursts) {
|
||||
// find impacted burst groups
|
||||
final obsoleteBurstEntries = <AvesEntry>{};
|
||||
final burstKeys = entries.map((entry) => entry.burstKey).whereNotNull().toSet();
|
||||
if (burstKeys.isNotEmpty) {
|
||||
_filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.burstKey)).forEach((mainEntry) {
|
||||
final subEntries = mainEntry.burstEntries!;
|
||||
// remove the deleted sub-entries
|
||||
subEntries.removeWhere(entries.contains);
|
||||
if (subEntries.isEmpty) {
|
||||
// remove the burst entry itself
|
||||
obsoleteBurstEntries.add(mainEntry);
|
||||
}
|
||||
// TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted
|
||||
});
|
||||
entries.addAll(obsoleteBurstEntries);
|
||||
}
|
||||
}
|
||||
|
||||
// we should remove obsolete entries and sections
|
||||
// but do not apply sort/group
|
||||
// but do not apply sort/section
|
||||
// as section order change would surprise the user while browsing
|
||||
_filteredSortedEntries.removeWhere(entries.contains);
|
||||
_sortedEntries?.removeWhere(entries.contains);
|
||||
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
|
||||
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
|
||||
selection.removeAll(entries);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
mixin CollectionActivityMixin {
|
||||
final ValueNotifier<Activity> _activityNotifier = ValueNotifier(Activity.browse);
|
||||
|
||||
ValueNotifier<Activity> get activityNotifier => _activityNotifier;
|
||||
|
||||
bool get isBrowsing => _activityNotifier.value == Activity.browse;
|
||||
|
||||
bool get isSelecting => _activityNotifier.value == Activity.select;
|
||||
|
||||
void browse() {
|
||||
clearSelection();
|
||||
_activityNotifier.value = Activity.browse;
|
||||
}
|
||||
|
||||
void select() => _activityNotifier.value = Activity.select;
|
||||
|
||||
// selection
|
||||
|
||||
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
|
||||
|
||||
final Set<AvesEntry> _selection = {};
|
||||
|
||||
Set<AvesEntry> get selection => _selection;
|
||||
|
||||
bool isSelected(Iterable<AvesEntry> entries) => entries.every(selection.contains);
|
||||
|
||||
void addToSelection(Iterable<AvesEntry> entries) {
|
||||
_selection.addAll(entries);
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromSelection(Iterable<AvesEntry> entries) {
|
||||
_selection.removeAll(entries);
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
_selection.clear();
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
void toggleSelection(AvesEntry entry) {
|
||||
if (_selection.isEmpty) select();
|
||||
if (!_selection.remove(entry)) _selection.add(entry);
|
||||
selectionChangeNotifier.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -162,6 +162,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final pinned = settings.pinnedFilters.contains(oldFilter);
|
||||
final oldCoverContentId = covers.coverContentId(oldFilter);
|
||||
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
|
||||
renameNewAlbum(sourceAlbum, destinationAlbum);
|
||||
await updateAfterMove(
|
||||
todoEntries: todoEntries,
|
||||
copy: false,
|
||||
|
@ -275,13 +276,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return recentEntry(filter);
|
||||
}
|
||||
|
||||
void changeFilterVisibility(CollectionFilter filter, bool visible) {
|
||||
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
if (visible) {
|
||||
hiddenFilters.remove(filter);
|
||||
hiddenFilters.removeAll(filters);
|
||||
} else {
|
||||
hiddenFilters.add(filter);
|
||||
settings.searchHistory = settings.searchHistory..remove(filter);
|
||||
hiddenFilters.addAll(filters);
|
||||
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
|
||||
}
|
||||
settings.hiddenFilters = hiddenFilters;
|
||||
|
||||
|
@ -292,10 +293,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
updateLocations();
|
||||
updateTags();
|
||||
|
||||
eventBus.fire(FilterVisibilityChangedEvent(filter, visible));
|
||||
eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
|
||||
|
||||
if (visible) {
|
||||
refreshMetadata(visibleEntries.where(filter.test).toSet());
|
||||
refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -319,10 +320,10 @@ class EntryMovedEvent {
|
|||
}
|
||||
|
||||
class FilterVisibilityChangedEvent {
|
||||
final CollectionFilter filter;
|
||||
final Set<CollectionFilter> filters;
|
||||
final bool visible;
|
||||
|
||||
const FilterVisibilityChangedEvent(this.filter, this.visible);
|
||||
const FilterVisibilityChangedEvent(this.filters, this.visible);
|
||||
}
|
||||
|
||||
class ProgressEvent {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
enum Activity { browse, select }
|
||||
|
||||
enum SourceState { loading, cataloguing, locating, ready }
|
||||
|
||||
enum ChipSortFactor { date, name, count }
|
||||
|
|
18
lib/services/device_service.dart
Normal file
18
lib/services/device_service.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -105,7 +105,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
final pageMaps = (result as List).cast<Map>();
|
||||
final pageMaps = ((result as List?) ?? []).cast<Map>();
|
||||
if (entry.isMotionPhoto && pageMaps.isNotEmpty) {
|
||||
final imagePage = pageMaps[0];
|
||||
imagePage['width'] = entry.width;
|
||||
|
|
|
@ -33,6 +33,8 @@ abstract class StorageService {
|
|||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
||||
|
||||
Future<Uint8List> openFile(String mimeType);
|
||||
|
||||
Future<String?> selectDirectory();
|
||||
}
|
||||
|
||||
class PlatformStorageService implements StorageService {
|
||||
|
@ -217,4 +219,25 @@ class PlatformStorageService implements StorageService {
|
|||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> selectDirectory() async {
|
||||
try {
|
||||
final completer = Completer<String>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'selectDirectory',
|
||||
}).listen(
|
||||
(data) => completer.complete(data as String?),
|
||||
onError: completer.completeError,
|
||||
onDone: () {
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,10 @@ abstract class WindowService {
|
|||
Future<bool> isRotationLocked();
|
||||
|
||||
Future<void> requestOrientation([Orientation? orientation]);
|
||||
|
||||
Future<bool> canSetCutoutMode();
|
||||
|
||||
Future<void> setCutoutMode(bool use);
|
||||
}
|
||||
|
||||
class PlatformWindowService implements WindowService {
|
||||
|
@ -41,12 +45,12 @@ class PlatformWindowService implements WindowService {
|
|||
late final int orientationCode;
|
||||
switch (orientation) {
|
||||
case Orientation.landscape:
|
||||
// SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
orientationCode = 11;
|
||||
// SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
orientationCode = 6;
|
||||
break;
|
||||
case Orientation.portrait:
|
||||
// SCREEN_ORIENTATION_USER_PORTRAIT
|
||||
orientationCode = 12;
|
||||
// SCREEN_ORIENTATION_SENSOR_PORTRAIT
|
||||
orientationCode = 7;
|
||||
break;
|
||||
default:
|
||||
// SCREEN_ORIENTATION_UNSPECIFIED
|
||||
|
@ -61,4 +65,26 @@ class PlatformWindowService implements WindowService {
|
|||
debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> canSetCutoutMode() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('canSetCutoutMode');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('canSetCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setCutoutMode(bool use) async {
|
||||
try {
|
||||
await platform.invokeMethod('setCutoutMode', <String, dynamic>{
|
||||
'use': use,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('setCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,6 +60,7 @@ class Durations {
|
|||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
static const highlightJumpDelay = Duration(milliseconds: 400);
|
||||
static const highlightScrollInitDelay = Duration(milliseconds: 800);
|
||||
static const videoOverlayHideDelay = Duration(milliseconds: 500);
|
||||
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||
|
|
|
@ -30,6 +30,7 @@ class AIcons {
|
|||
static const IconData tagOff = MdiIcons.tagOffOutline;
|
||||
|
||||
// actions
|
||||
static const IconData addPath = Icons.add_circle_outline;
|
||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||
static const IconData replay10 = Icons.replay_10_outlined;
|
||||
static const IconData skip10 = Icons.forward_10_outlined;
|
||||
|
@ -48,8 +49,10 @@ class AIcons {
|
|||
static const IconData import = MdiIcons.fileImportOutline;
|
||||
static const IconData info = Icons.info_outlined;
|
||||
static const IconData layers = Icons.layers_outlined;
|
||||
static const IconData newTier = Icons.fiber_new_outlined;
|
||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||
static const IconData pin = Icons.push_pin_outlined;
|
||||
static const IconData unpin = MdiIcons.pinOffOutline;
|
||||
static const IconData play = Icons.play_arrow;
|
||||
static const IconData pause = Icons.pause;
|
||||
static const IconData print = Icons.print_outlined;
|
||||
|
|
|
@ -279,10 +279,10 @@ class Constants {
|
|||
sourceUrl: 'https://github.com/dart-lang/intl',
|
||||
),
|
||||
Dependency(
|
||||
name: 'LatLong',
|
||||
name: 'LatLong2',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/MikeMitterer/dart-latlong',
|
||||
licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/jifalops/dart-latlong',
|
||||
),
|
||||
Dependency(
|
||||
name: 'PDF for Dart and Flutter',
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/actions/collection_actions.dart';
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
|
@ -45,10 +46,10 @@ class CollectionAppBar extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
||||
final TextEditingController _searchFieldController = TextEditingController();
|
||||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||
late AnimationController _browseToSelectAnimation;
|
||||
late Future<bool> _canAddShortcutsLoader;
|
||||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
|
@ -63,6 +64,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
duration: Durations.iconAnimation,
|
||||
vsync: this,
|
||||
);
|
||||
_isSelectingNotifier.addListener(_onActivityChange);
|
||||
_canAddShortcutsLoader = AppShortcutService.canPin();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
|
||||
|
@ -78,35 +80,34 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
_isSelectingNotifier.removeListener(_onActivityChange);
|
||||
_browseToSelectAnimation.dispose();
|
||||
_searchFieldController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(CollectionAppBar widget) {
|
||||
widget.collection.activityNotifier.addListener(_onActivityChange);
|
||||
widget.collection.filterChangeNotifier.addListener(_updateHeight);
|
||||
}
|
||||
|
||||
void _unregisterWidget(CollectionAppBar widget) {
|
||||
widget.collection.activityNotifier.removeListener(_onActivityChange);
|
||||
widget.collection.filterChangeNotifier.removeListener(_updateHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
builder: (context, activity, child) {
|
||||
return Selector<Selection<AvesEntry>, bool>(
|
||||
selector: (context, selection) => selection.isSelecting,
|
||||
builder: (context, isSelecting, child) {
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading() : null,
|
||||
title: _buildAppBarTitle(),
|
||||
actions: _buildActions(),
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(isSelecting),
|
||||
bottom: hasFilters
|
||||
? FilterBar(
|
||||
filters: collection.filters,
|
||||
|
@ -123,15 +124,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarLeading() {
|
||||
Widget _buildAppBarLeading(bool isSelecting) {
|
||||
VoidCallback? onPressed;
|
||||
String? tooltip;
|
||||
if (collection.isBrowsing) {
|
||||
if (isSelecting) {
|
||||
onPressed = () => context.read<Selection<AvesEntry>>().browse();
|
||||
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
|
||||
} else {
|
||||
onPressed = Scaffold.of(context).openDrawer;
|
||||
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
|
||||
} else if (collection.isSelecting) {
|
||||
onPressed = collection.browse;
|
||||
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
|
||||
}
|
||||
return IconButton(
|
||||
key: const Key('appbar-leading-button'),
|
||||
|
@ -144,8 +145,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
Widget? _buildAppBarTitle() {
|
||||
if (collection.isBrowsing) {
|
||||
Widget? _buildAppBarTitle(bool isSelecting) {
|
||||
if (isSelecting) {
|
||||
return Selector<Selection<AvesEntry>, int>(
|
||||
selector: (context, selection) => selection.selection.length,
|
||||
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
|
||||
);
|
||||
} else {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
|
||||
if (appMode == AppMode.main) {
|
||||
|
@ -158,36 +164,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
onTap: appMode.canSearch ? _goToSearch : null,
|
||||
child: title,
|
||||
);
|
||||
} else if (collection.isSelecting) {
|
||||
return AnimatedBuilder(
|
||||
animation: collection.selectionChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final count = collection.selection.length;
|
||||
return Text(context.l10n.collectionSelectionPageTitle(count));
|
||||
},
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Widget> _buildActions() {
|
||||
List<Widget> _buildActions(bool isSelecting) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
return [
|
||||
if (collection.isBrowsing && appMode.canSearch)
|
||||
if (!isSelecting && appMode.canSearch)
|
||||
CollectionSearchButton(
|
||||
source: source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
if (collection.isSelecting)
|
||||
...EntryActions.selection.map((action) => AnimatedBuilder(
|
||||
animation: collection.selectionChangeNotifier,
|
||||
builder: (context, child) {
|
||||
return IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
|
||||
tooltip: action.getText(context),
|
||||
);
|
||||
},
|
||||
if (isSelecting)
|
||||
...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
|
||||
selector: (context, selection) => selection.selection.isEmpty,
|
||||
builder: (context, isEmpty, child) => IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
|
||||
tooltip: action.getText(context),
|
||||
),
|
||||
)),
|
||||
FutureBuilder<bool>(
|
||||
future: _canAddShortcutsLoader,
|
||||
|
@ -196,8 +191,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
return PopupMenuButton<CollectionAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final isNotEmpty = !collection.isEmpty;
|
||||
final hasSelection = collection.selection.isNotEmpty;
|
||||
final hasSelection = selection.selection.isNotEmpty;
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: const Key('menu-sort'),
|
||||
|
@ -210,7 +206,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
value: CollectionAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
|
||||
),
|
||||
if (collection.isBrowsing && appMode == AppMode.main) ...[
|
||||
if (!selection.isSelecting && appMode == AppMode.main) ...[
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.select,
|
||||
enabled: isNotEmpty,
|
||||
|
@ -227,7 +223,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
|
||||
),
|
||||
],
|
||||
if (collection.isSelecting) ...[
|
||||
if (selection.isSelecting) ...[
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.copy,
|
||||
|
@ -247,7 +243,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.selectAll,
|
||||
enabled: collection.selection.length < collection.entryCount,
|
||||
enabled: selection.selection.length < collection.entryCount,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelectAll),
|
||||
),
|
||||
PopupMenuItem(
|
||||
|
@ -269,11 +265,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
void _onActivityChange() {
|
||||
if (collection.isSelecting) {
|
||||
if (context.read<Selection<AvesEntry>>().isSelecting) {
|
||||
_browseToSelectAnimation.forward();
|
||||
} else {
|
||||
_browseToSelectAnimation.reverse();
|
||||
_searchFieldController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -289,13 +284,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
_actionDelegate.onCollectionActionSelected(context, action);
|
||||
break;
|
||||
case CollectionAction.select:
|
||||
collection.select();
|
||||
context.read<Selection<AvesEntry>>().select();
|
||||
break;
|
||||
case CollectionAction.selectAll:
|
||||
collection.addToSelection(collection.sortedEntries);
|
||||
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
|
||||
break;
|
||||
case CollectionAction.selectNone:
|
||||
collection.clearSelection();
|
||||
context.read<Selection<AvesEntry>>().clearSelection();
|
||||
break;
|
||||
case CollectionAction.stats:
|
||||
_goToStats();
|
||||
|
@ -307,7 +302,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
final value = await showDialog<EntryGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
|
||||
initialValue: settings.collectionGroupFactor,
|
||||
initialValue: settings.collectionSectionFactor,
|
||||
options: {
|
||||
EntryGroupFactor.album: context.l10n.collectionGroupAlbum,
|
||||
EntryGroupFactor.month: context.l10n.collectionGroupMonth,
|
||||
|
@ -320,7 +315,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (value != null) {
|
||||
settings.collectionGroupFactor = value;
|
||||
settings.collectionSectionFactor = value;
|
||||
}
|
||||
break;
|
||||
case CollectionAction.sort:
|
||||
|
@ -372,13 +367,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
|
||||
void _goToSearch() {
|
||||
Navigator.push(
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
source: collection.source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
));
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
source: collection.source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToStats() {
|
||||
|
|
|
@ -12,17 +12,17 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/collection/app_bar.dart';
|
||||
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/theme.dart';
|
||||
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/item_tracker.dart';
|
||||
import 'package:aves/widgets/common/grid/selector.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
|
||||
|
@ -79,7 +79,7 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
||||
builder: (context, tileExtent, child) {
|
||||
return ThumbnailTheme(
|
||||
return GridTheme(
|
||||
extent: tileExtent,
|
||||
child: Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||
|
@ -173,7 +173,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
|
|||
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
|
||||
final selector = GridSelectionGestureDetector(
|
||||
selectable: isMainMode,
|
||||
collection: collection,
|
||||
items: collection.sortedEntries,
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
child: scaler,
|
||||
|
@ -210,14 +210,11 @@ class _CollectionScaler extends StatelessWidget {
|
|||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (entry, extent) => ThumbnailTheme(
|
||||
extent: extent,
|
||||
child: DecoratedThumbnail(
|
||||
entry: entry,
|
||||
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
),
|
||||
scaledBuilder: (entry, extent) => DecoratedThumbnail(
|
||||
entry: entry,
|
||||
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
@ -270,13 +267,13 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
|
|||
|
||||
void _registerWidget(_CollectionScrollView widget) {
|
||||
widget.collection.filterChangeNotifier.addListener(_scrollToTop);
|
||||
widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop);
|
||||
widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop);
|
||||
widget.scrollController.addListener(_onScrollChange);
|
||||
}
|
||||
|
||||
void _unregisterWidget(_CollectionScrollView widget) {
|
||||
widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
|
||||
widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop);
|
||||
widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop);
|
||||
widget.scrollController.removeListener(_onScrollChange);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/collection_grid.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -35,22 +38,27 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: WillPopScope(
|
||||
onWillPop: () {
|
||||
if (collection.isSelecting) {
|
||||
collection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
key: Key('collection-grid'),
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: Builder(
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
key: Key('collection-grid'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -25,7 +25,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
|||
lineBuilder: (context, entry) {
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
switch (collection.sectionFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
return [
|
||||
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
|
||||
|
|
|
@ -3,9 +3,12 @@ import 'dart:async';
|
|||
import 'package:aves/model/actions/collection_actions.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
|
@ -31,10 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
_showDeleteDialog(context);
|
||||
break;
|
||||
case EntryAction.share:
|
||||
final collection = context.read<CollectionLens>();
|
||||
AndroidAppService.shareEntries(collection.selection).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
_share(context);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
@ -57,18 +57,33 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
}
|
||||
|
||||
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
|
||||
return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet();
|
||||
}
|
||||
|
||||
void _share(BuildContext context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
AndroidAppService.shareEntries(selectedItems).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshMetadata(BuildContext context) {
|
||||
final collection = context.read<CollectionLens>();
|
||||
collection.source.refreshMetadata(collection.selection);
|
||||
collection.browse();
|
||||
final source = context.read<CollectionSource>();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
source.refreshMetadata(selectedItems);
|
||||
selection.browse();
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
final source = collection.source;
|
||||
final selection = collection.selection;
|
||||
final source = context.read<CollectionSource>();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet();
|
||||
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
|
||||
if (moveType == MoveType.move) {
|
||||
// check whether moving is possible given OS restrictions,
|
||||
// before asking to pick a destination album
|
||||
|
@ -95,11 +110,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return;
|
||||
|
||||
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
|
||||
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return;
|
||||
|
||||
// do not directly use selection when moving and post-processing items
|
||||
// as source monitoring may remove obsolete items from the original selection
|
||||
final todoEntries = selection.toSet();
|
||||
final todoEntries = selectedItems.toSet();
|
||||
|
||||
final copy = moveType == MoveType.copy;
|
||||
final todoCount = todoEntries.length;
|
||||
|
@ -118,7 +133,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
destinationAlbum: destinationAlbum,
|
||||
movedOps: movedOps,
|
||||
);
|
||||
collection.browse();
|
||||
selection.browse();
|
||||
source.resumeMonitoring();
|
||||
|
||||
// cleanup
|
||||
|
@ -140,6 +155,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
final collection = context.read<CollectionLens>();
|
||||
var targetCollection = collection;
|
||||
if (collection.filters.any((f) => f is AlbumFilter)) {
|
||||
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
|
||||
|
@ -175,11 +191,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
final source = collection.source;
|
||||
final selection = collection.selection;
|
||||
final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet();
|
||||
final todoCount = selection.length;
|
||||
final source = context.read<CollectionSource>();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
|
||||
final todoCount = selectedItems.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
|
@ -207,12 +223,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: imageFileService.delete(selection),
|
||||
opStream: imageFileService.delete(selectedItems),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
await source.removeEntries(deletedUris);
|
||||
collection.browse();
|
||||
selection.browse();
|
||||
source.resumeMonitoring();
|
||||
|
||||
final deletedCount = deletedUris.length;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
@ -33,7 +34,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
return SectionHeader(
|
||||
return SectionHeader<AvesEntry>(
|
||||
sectionKey: EntryAlbumSectionKey(directory),
|
||||
leading: albumIcon,
|
||||
title: albumName ?? context.l10n.sectionUnknown,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
|
@ -35,13 +36,13 @@ class CollectionSectionHeader extends StatelessWidget {
|
|||
Widget? _buildHeader(BuildContext context) {
|
||||
switch (collection.sortFactor) {
|
||||
case EntrySortFactor.date:
|
||||
switch (collection.groupFactor) {
|
||||
switch (collection.sectionFactor) {
|
||||
case EntryGroupFactor.album:
|
||||
return _buildAlbumHeader(context);
|
||||
case EntryGroupFactor.month:
|
||||
return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
|
||||
return MonthSectionHeader<AvesEntry>(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
|
||||
case EntryGroupFactor.day:
|
||||
return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
|
||||
return DaySectionHeader<AvesEntry>(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
|
||||
case EntryGroupFactor.none:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/widgets/common/grid/header.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DaySectionHeader extends StatelessWidget {
|
||||
class DaySectionHeader<T> extends StatelessWidget {
|
||||
final DateTime? date;
|
||||
|
||||
const DaySectionHeader({
|
||||
|
@ -45,14 +45,14 @@ class DaySectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionHeader(
|
||||
return SectionHeader<T>(
|
||||
sectionKey: EntryDateSectionKey(date),
|
||||
title: _formatDate(context, date),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MonthSectionHeader extends StatelessWidget {
|
||||
class MonthSectionHeader<T> extends StatelessWidget {
|
||||
final DateTime? date;
|
||||
|
||||
const MonthSectionHeader({
|
||||
|
@ -71,7 +71,7 @@ class MonthSectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionHeader(
|
||||
return SectionHeader<T>(
|
||||
sectionKey: EntryDateSectionKey(date),
|
||||
title: _formatDate(context, date),
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
|
@ -31,10 +32,11 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
final appMode = context.read<ValueNotifier<AppMode>>().value;
|
||||
switch (appMode) {
|
||||
case AppMode.main:
|
||||
if (collection.isBrowsing) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.toggleSelection(entry);
|
||||
} else {
|
||||
_goToViewer(context);
|
||||
} else if (collection.isSelecting) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
break;
|
||||
case AppMode.pickExternal:
|
||||
|
@ -74,7 +76,7 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
id: collection.id,
|
||||
listenToSource: false,
|
||||
);
|
||||
assert(viewerCollection.sortedEntries.contains(entry));
|
||||
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
|
||||
return EntryViewerPage(
|
||||
collection: viewerCollection,
|
||||
initialEntry: entry,
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
||||
import 'package:aves/widgets/common/fx/borders.dart';
|
||||
import 'package:aves/widgets/common/grid/overlay.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DecoratedThumbnail extends StatelessWidget {
|
||||
|
@ -10,7 +11,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
final double tileExtent;
|
||||
final CollectionLens? collection;
|
||||
final ValueNotifier<bool>? cancellableNotifier;
|
||||
final bool selectable, highlightable;
|
||||
final bool selectable, highlightable, hero;
|
||||
|
||||
static final Color borderColor = Colors.grey.shade700;
|
||||
static final double borderWidth = AvesBorder.borderWidth;
|
||||
|
@ -23,6 +24,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
this.cancellableNotifier,
|
||||
this.selectable = true,
|
||||
this.highlightable = true,
|
||||
this.hero = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -32,7 +34,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
// hero tag should include a collection identifier, so that it animates
|
||||
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
|
||||
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
|
||||
final heroTag = hashValues(collection?.id, entry);
|
||||
final heroTag = hero ? hashValues(collection?.id, entry.uri) : null;
|
||||
final isSvg = entry.isSvg;
|
||||
Widget child = ThumbnailImage(
|
||||
entry: entry,
|
||||
|
@ -46,7 +48,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
children: [
|
||||
child,
|
||||
if (!isSvg) ThumbnailEntryOverlay(entry: entry),
|
||||
if (selectable) ThumbnailSelectionOverlay(entry: entry),
|
||||
if (selectable) GridItemSelectionOverlay(item: entry),
|
||||
if (highlightable) ThumbnailHighlightOverlay(entry: entry),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -2,12 +2,8 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/theme.dart';
|
||||
import 'package:aves/widgets/common/fx/sweeper.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -23,7 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = [
|
||||
if (entry.hasGps && context.select<ThumbnailThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
|
||||
if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
|
||||
if (entry.isVideo)
|
||||
VideoIcon(
|
||||
entry: entry,
|
||||
|
@ -31,11 +27,11 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
else if (entry.isAnimated)
|
||||
const AnimatedImageIcon()
|
||||
else ...[
|
||||
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) const RawIcon(),
|
||||
if (entry.isMultiPage) MultiPageIcon(entry: entry),
|
||||
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
|
||||
if (entry.isGeotiff) const GeotiffIcon(),
|
||||
if (entry.is360) const SphericalImageIcon(),
|
||||
]
|
||||
],
|
||||
if (entry.isMultiPage) MultiPageIcon(entry: entry),
|
||||
];
|
||||
if (children.isEmpty) return const SizedBox.shrink();
|
||||
if (children.length == 1) return children.first;
|
||||
|
@ -47,63 +43,6 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
|
||||
static const duration = Durations.thumbnailOverlayAnimation;
|
||||
|
||||
const ThumbnailSelectionOverlay({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final collection = context.watch<CollectionLens>();
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
builder: (context, activity, child) {
|
||||
final child = collection.isSelecting
|
||||
? AnimatedBuilder(
|
||||
animation: collection.selectionChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final selected = collection.isSelected([entry]);
|
||||
var child = collection.isSelecting
|
||||
? OverlayIcon(
|
||||
key: ValueKey(selected),
|
||||
icon: selected ? AIcons.selected : AIcons.unselected,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
child = AnimatedSwitcher(
|
||||
duration: duration,
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeOutBack,
|
||||
transitionBuilder: (child, animation) => ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
child = AnimatedContainer(
|
||||
duration: duration,
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
color: selected ? Colors.black54 : Colors.transparent,
|
||||
child: child,
|
||||
);
|
||||
return child;
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
return AnimatedSwitcher(
|
||||
duration: duration,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
|
||||
|
@ -132,7 +71,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
|||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: Theme.of(context).accentColor,
|
||||
width: context.select<ThumbnailThemeData, double>((t) => t.highlightBorderWidth),
|
||||
width: context.select<GridThemeData, double>((t) => t.highlightBorderWidth),
|
||||
)),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/theme/durations.dart';
|
||||
|
@ -14,10 +15,14 @@ mixin FeedbackMixin {
|
|||
|
||||
// provide the messenger if feedback happens as the widget is disposed
|
||||
void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) {
|
||||
final duration = action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay;
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
content: _FeedbackMessage(
|
||||
message: message,
|
||||
duration: action != null ? duration : null,
|
||||
),
|
||||
action: action,
|
||||
duration: action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay,
|
||||
duration: duration,
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -136,3 +141,68 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedbackMessage extends StatefulWidget {
|
||||
final String message;
|
||||
final Duration? duration;
|
||||
|
||||
const _FeedbackMessage({
|
||||
Key? key,
|
||||
required this.message,
|
||||
this.duration,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_FeedbackMessageState createState() => _FeedbackMessageState();
|
||||
}
|
||||
|
||||
class _FeedbackMessageState extends State<_FeedbackMessage> {
|
||||
double _percent = 0;
|
||||
late int _remainingSecs;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final duration = widget.duration;
|
||||
if (duration != null) {
|
||||
_remainingSecs = duration.inSeconds;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
setState(() => _remainingSecs--);
|
||||
});
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => setState(() => _percent = 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Text(widget.message);
|
||||
final duration = widget.duration;
|
||||
return duration == null
|
||||
? text
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: text),
|
||||
const SizedBox(width: 16),
|
||||
CircularPercentIndicator(
|
||||
percent: _percent,
|
||||
lineWidth: 2,
|
||||
radius: 32,
|
||||
backgroundColor: Theme.of(context).accentColor,
|
||||
progressColor: Colors.grey,
|
||||
animation: true,
|
||||
animationDuration: duration.inMilliseconds,
|
||||
center: Text('$_remainingSecs'),
|
||||
animateFromLastPercent: true,
|
||||
reverse: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,42 +28,50 @@ class BlurredRect extends StatelessWidget {
|
|||
}
|
||||
|
||||
class BlurredRRect extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final double borderRadius;
|
||||
final Widget child;
|
||||
|
||||
const BlurredRRect({
|
||||
Key? key,
|
||||
this.enabled = true,
|
||||
required this.borderRadius,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
|
||||
child: BackdropFilter(
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
return enabled
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
|
||||
child: BackdropFilter(
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
: child;
|
||||
}
|
||||
}
|
||||
|
||||
class BlurredOval extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
|
||||
const BlurredOval({
|
||||
Key? key,
|
||||
this.enabled = true,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipOval(
|
||||
child: BackdropFilter(
|
||||
filter: _filter,
|
||||
child: child,
|
||||
),
|
||||
child: enabled
|
||||
? BackdropFilter(
|
||||
filter: _filter,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
class SectionHeader<T> extends StatelessWidget {
|
||||
final SectionKey sectionKey;
|
||||
final Widget? leading, trailing;
|
||||
final String title;
|
||||
|
@ -43,7 +43,7 @@ class SectionHeader extends StatelessWidget {
|
|||
children: [
|
||||
WidgetSpan(
|
||||
alignment: widgetSpanAlignment,
|
||||
child: _SectionSelectableLeading(
|
||||
child: _SectionSelectableLeading<T>(
|
||||
selectable: selectable,
|
||||
sectionKey: sectionKey,
|
||||
browsingBuilder: leading != null
|
||||
|
@ -77,13 +77,13 @@ class SectionHeader extends StatelessWidget {
|
|||
}
|
||||
|
||||
void _toggleSectionSelection(BuildContext context) {
|
||||
final collection = context.read<CollectionLens>();
|
||||
final sectionEntries = collection.sections[sectionKey]!;
|
||||
final selected = collection.isSelected(sectionEntries);
|
||||
if (selected) {
|
||||
collection.removeFromSelection(sectionEntries);
|
||||
final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
|
||||
final selection = context.read<Selection<T>>();
|
||||
final isSelected = selection.isSelected(sectionEntries);
|
||||
if (isSelected) {
|
||||
selection.removeFromSelection(sectionEntries);
|
||||
} else {
|
||||
collection.addToSelection(sectionEntries);
|
||||
selection.addToSelection(sectionEntries);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,7 +122,7 @@ class SectionHeader extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _SectionSelectableLeading extends StatelessWidget {
|
||||
class _SectionSelectableLeading<T> extends StatelessWidget {
|
||||
final bool selectable;
|
||||
final SectionKey sectionKey;
|
||||
final WidgetBuilder? browsingBuilder;
|
||||
|
@ -142,72 +142,82 @@ class _SectionSelectableLeading extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
if (!selectable) return _buildBrowsing(context);
|
||||
|
||||
final collection = context.watch<CollectionLens>();
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
builder: (context, activity, child) {
|
||||
final child = collection.isSelecting
|
||||
? AnimatedBuilder(
|
||||
animation: collection.selectionChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final sectionEntries = collection.sections[sectionKey]!;
|
||||
final selected = collection.isSelected(sectionEntries);
|
||||
final child = TooltipTheme(
|
||||
key: ValueKey(selected),
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 26,
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
icon: Icon(selected ? AIcons.selected : AIcons.unselected),
|
||||
onPressed: onPressed,
|
||||
tooltip: selected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: leadingDimension,
|
||||
minWidth: leadingDimension,
|
||||
),
|
||||
),
|
||||
);
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.sectionHeaderAnimation,
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeOutBack,
|
||||
transitionBuilder: (child, animation) => ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
)
|
||||
: _buildBrowsing(context);
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.sectionHeaderAnimation,
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
Widget transition = ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
if (browsingBuilder == null) {
|
||||
// when switching with a header that has no icon,
|
||||
// we also transition the size for a smooth push to the text
|
||||
transition = SizeTransition(
|
||||
axis: Axis.horizontal,
|
||||
sizeFactor: animation,
|
||||
child: transition,
|
||||
);
|
||||
}
|
||||
return transition;
|
||||
},
|
||||
final isSelecting = context.select<Selection<T>, bool>((selection) => selection.isSelecting);
|
||||
final Widget child = isSelecting
|
||||
? _SectionSelectingLeading<T>(
|
||||
sectionKey: sectionKey,
|
||||
onPressed: onPressed,
|
||||
)
|
||||
: _buildBrowsing(context);
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.sectionHeaderAnimation,
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
Widget transition = ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
if (browsingBuilder == null) {
|
||||
// when switching with a header that has no icon,
|
||||
// we also transition the size for a smooth push to the text
|
||||
transition = SizeTransition(
|
||||
axis: Axis.horizontal,
|
||||
sizeFactor: animation,
|
||||
child: transition,
|
||||
);
|
||||
}
|
||||
return transition;
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension);
|
||||
}
|
||||
|
||||
class _SectionSelectingLeading<T> extends StatelessWidget {
|
||||
final SectionKey sectionKey;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _SectionSelectingLeading({
|
||||
Key? key,
|
||||
required this.sectionKey,
|
||||
required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionEntries = context.watch<SectionedListLayout<T>>().sections[sectionKey] ?? [];
|
||||
final selection = context.watch<Selection<T>>();
|
||||
final isSelected = selection.isSelected(sectionEntries);
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.sectionHeaderAnimation,
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeOutBack,
|
||||
transitionBuilder: (child, animation) => ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
),
|
||||
child: TooltipTheme(
|
||||
key: ValueKey(isSelected),
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 26,
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
icon: Icon(isSelected ? AIcons.selected : AIcons.unselected),
|
||||
onPressed: onPressed,
|
||||
tooltip: isSelected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: SectionHeader.leadingDimension,
|
||||
minWidth: SectionHeader.leadingDimension,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
66
lib/widgets/common/grid/overlay.dart
Normal file
66
lib/widgets/common/grid/overlay.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
|
@ -10,9 +9,9 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/rendering.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class GridSelectionGestureDetector extends StatefulWidget {
|
||||
class GridSelectionGestureDetector<T> extends StatefulWidget {
|
||||
final bool selectable;
|
||||
final CollectionLens collection;
|
||||
final List<T> items;
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final Widget child;
|
||||
|
@ -20,17 +19,17 @@ class GridSelectionGestureDetector extends StatefulWidget {
|
|||
const GridSelectionGestureDetector({
|
||||
Key? key,
|
||||
this.selectable = true,
|
||||
required this.collection,
|
||||
required this.items,
|
||||
required this.scrollController,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState();
|
||||
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState<T>();
|
||||
}
|
||||
|
||||
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||
class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDetector<T>> {
|
||||
bool _pressing = false, _selecting = false;
|
||||
late int _fromIndex, _lastToIndex;
|
||||
late Offset _localPosition;
|
||||
|
@ -38,9 +37,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
late double _scrollSpeedFactor;
|
||||
Timer? _updateTimer;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
List<AvesEntry> get entries => collection.sortedEntries;
|
||||
List<T> get items => widget.items;
|
||||
|
||||
ScrollController get scrollController => widget.scrollController;
|
||||
|
||||
|
@ -52,15 +49,17 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectable = widget.selectable;
|
||||
return GestureDetector(
|
||||
onLongPressStart: widget.selectable
|
||||
onLongPressStart: selectable
|
||||
? (details) {
|
||||
final fromEntry = _getEntryAt(details.localPosition);
|
||||
if (fromEntry == null) return;
|
||||
final fromItem = _getItemAt(details.localPosition);
|
||||
if (fromItem == null) return;
|
||||
|
||||
collection.toggleSelection(fromEntry);
|
||||
_selecting = collection.isSelected([fromEntry]);
|
||||
_fromIndex = entries.indexOf(fromEntry);
|
||||
final selection = context.read<Selection<T>>();
|
||||
selection.toggleSelection(fromItem);
|
||||
_selecting = selection.isSelected([fromItem]);
|
||||
_fromIndex = items.indexOf(fromItem);
|
||||
_lastToIndex = _fromIndex;
|
||||
_scrollableInsets = EdgeInsets.only(
|
||||
top: appBarHeight,
|
||||
|
@ -70,20 +69,29 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
_pressing = true;
|
||||
}
|
||||
: null,
|
||||
onLongPressMoveUpdate: widget.selectable
|
||||
onLongPressMoveUpdate: selectable
|
||||
? (details) {
|
||||
if (!_pressing) return;
|
||||
_localPosition = details.localPosition;
|
||||
_onLongPressUpdate();
|
||||
}
|
||||
: null,
|
||||
onLongPressEnd: widget.selectable
|
||||
onLongPressEnd: selectable
|
||||
? (details) {
|
||||
if (!_pressing) return;
|
||||
_setScrollSpeed(0);
|
||||
_pressing = false;
|
||||
}
|
||||
: null,
|
||||
onTapUp: selectable && context.select<Selection<T>, bool>((selection) => selection.isSelecting)
|
||||
? (details) {
|
||||
final item = _getItemAt(details.localPosition);
|
||||
if (item == null) return;
|
||||
|
||||
final selection = context.read<Selection<T>>();
|
||||
selection.toggleSelection(item);
|
||||
}
|
||||
: null,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
@ -102,9 +110,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
_setScrollSpeed(0);
|
||||
}
|
||||
|
||||
final toEntry = _getEntryAt(_localPosition);
|
||||
if (toEntry != null) {
|
||||
_toggleSelectionToIndex(entries.indexOf(toEntry));
|
||||
final toItem = _getItemAt(_localPosition);
|
||||
if (toItem != null) {
|
||||
_toggleSelectionToIndex(items.indexOf(toItem));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,47 +136,48 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
duration: Duration(milliseconds: millis.round()),
|
||||
curve: Curves.linear,
|
||||
);
|
||||
// use a timer to update the entry selection, because `onLongPressMoveUpdate`
|
||||
// use a timer to update the selection, because `onLongPressMoveUpdate`
|
||||
// is not called when the pointer stays still while the view is scrolling
|
||||
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
AvesEntry? _getEntryAt(Offset localPosition) {
|
||||
T? _getItemAt(Offset localPosition) {
|
||||
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
|
||||
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
||||
// so we use custom layout computation instead to find the entry.
|
||||
// so we use custom layout computation instead to find the item.
|
||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
final sectionedListLayout = context.read<SectionedListLayout<T>>();
|
||||
return sectionedListLayout.getItemAt(offset);
|
||||
}
|
||||
|
||||
void _toggleSelectionToIndex(int toIndex) {
|
||||
if (toIndex == -1) return;
|
||||
|
||||
final selection = context.read<Selection<T>>();
|
||||
if (_selecting) {
|
||||
if (toIndex <= _fromIndex) {
|
||||
if (toIndex < _lastToIndex) {
|
||||
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
|
||||
selection.addToSelection(items.getRange(toIndex, min(_fromIndex, _lastToIndex)));
|
||||
if (_fromIndex < _lastToIndex) {
|
||||
collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1));
|
||||
selection.removeFromSelection(items.getRange(_fromIndex + 1, _lastToIndex + 1));
|
||||
}
|
||||
} else if (_lastToIndex < toIndex) {
|
||||
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
|
||||
selection.removeFromSelection(items.getRange(_lastToIndex, toIndex));
|
||||
}
|
||||
} else if (_fromIndex < toIndex) {
|
||||
if (_lastToIndex < toIndex) {
|
||||
collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
|
||||
selection.addToSelection(items.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
|
||||
if (_lastToIndex < _fromIndex) {
|
||||
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
|
||||
selection.removeFromSelection(items.getRange(_lastToIndex, _fromIndex));
|
||||
}
|
||||
} else if (toIndex < _lastToIndex) {
|
||||
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
|
||||
selection.removeFromSelection(items.getRange(toIndex + 1, _lastToIndex + 1));
|
||||
}
|
||||
}
|
||||
_lastToIndex = toIndex;
|
||||
} else {
|
||||
collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
|
||||
selection.removeFromSelection(items.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,12 +4,12 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ThumbnailTheme extends StatelessWidget {
|
||||
class GridTheme extends StatelessWidget {
|
||||
final double extent;
|
||||
final bool? showLocation;
|
||||
final Widget child;
|
||||
|
||||
const ThumbnailTheme({
|
||||
const GridTheme({
|
||||
Key? key,
|
||||
required this.extent,
|
||||
this.showLocation,
|
||||
|
@ -18,12 +18,12 @@ class ThumbnailTheme extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider<Settings, ThumbnailThemeData>(
|
||||
return ProxyProvider<Settings, GridThemeData>(
|
||||
update: (_, settings, __) {
|
||||
final iconSize = min(28.0, (extent / 4)).roundToDouble();
|
||||
final fontSize = (iconSize / 2).floorToDouble();
|
||||
final highlightBorderWidth = extent * .1;
|
||||
return ThumbnailThemeData(
|
||||
return GridThemeData(
|
||||
iconSize: iconSize,
|
||||
fontSize: fontSize,
|
||||
highlightBorderWidth: highlightBorderWidth,
|
||||
|
@ -37,11 +37,11 @@ class ThumbnailTheme extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class ThumbnailThemeData {
|
||||
class GridThemeData {
|
||||
final double iconSize, fontSize, highlightBorderWidth;
|
||||
final bool showLocation, showRaw, showVideoDuration;
|
||||
|
||||
const ThumbnailThemeData({
|
||||
const GridThemeData({
|
||||
required this.iconSize,
|
||||
required this.fontSize,
|
||||
required this.highlightBorderWidth,
|
|
@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -23,6 +23,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final bool removable;
|
||||
final bool showGenericIcon;
|
||||
final Widget? background;
|
||||
final String? banner;
|
||||
final Widget? details;
|
||||
final BorderRadius? borderRadius;
|
||||
final double padding;
|
||||
|
@ -43,6 +44,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.removable = false,
|
||||
this.showGenericIcon = true,
|
||||
this.background,
|
||||
this.banner,
|
||||
this.details,
|
||||
this.borderRadius,
|
||||
this.padding = 6.0,
|
||||
|
@ -195,6 +197,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
}
|
||||
|
||||
final borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
|
||||
final banner = widget.banner;
|
||||
Widget chip = Container(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: AvesFilterChip.minChipWidth,
|
||||
|
@ -209,51 +212,62 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
borderRadius: borderRadius,
|
||||
child: widget.background,
|
||||
),
|
||||
Tooltip(
|
||||
message: filter.getTooltip(context),
|
||||
preferBelow: false,
|
||||
child: Material(
|
||||
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: InkWell(
|
||||
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
|
||||
// so we get the long press details from the tap instead
|
||||
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
|
||||
onTap: onTap != null
|
||||
? () {
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
|
||||
setState(() => _tapped = true);
|
||||
}
|
||||
: null,
|
||||
onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
|
||||
borderRadius: borderRadius,
|
||||
child: FutureBuilder<Color>(
|
||||
future: _colorFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
_outlineColor = snapshot.data!;
|
||||
Material(
|
||||
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: InkWell(
|
||||
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
|
||||
// so we get the long press details from the tap instead
|
||||
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
|
||||
onTap: onTap != null
|
||||
? () {
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
|
||||
setState(() => _tapped = true);
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: _outlineColor,
|
||||
width: AvesFilterChip.outlineWidth,
|
||||
)),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
position: DecorationPosition.foreground,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
: null,
|
||||
onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
|
||||
borderRadius: borderRadius,
|
||||
child: FutureBuilder<Color>(
|
||||
future: _colorFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
_outlineColor = snapshot.data!;
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: _outlineColor,
|
||||
width: AvesFilterChip.outlineWidth,
|
||||
)),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
position: DecorationPosition.foreground,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (banner != null)
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Transform(
|
||||
transform: Matrix4.identity().scaled((constraints.maxHeight / 90 - .4).clamp(.45, 1.0)),
|
||||
child: Banner(
|
||||
message: banner.toUpperCase(),
|
||||
location: BannerLocation.topStart,
|
||||
color: Theme.of(context).accentColor,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/theme.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -20,11 +20,11 @@ class VideoIcon extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumbnailTheme = context.watch<ThumbnailThemeData>();
|
||||
final showDuration = thumbnailTheme.showVideoDuration;
|
||||
final gridTheme = context.watch<GridThemeData>();
|
||||
final showDuration = gridTheme.showVideoDuration;
|
||||
Widget child = OverlayIcon(
|
||||
icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
|
||||
size: thumbnailTheme.iconSize,
|
||||
size: gridTheme.iconSize,
|
||||
text: showDuration ? entry.durationText : null,
|
||||
iconScale: entry.is360 && showDuration ? .9 : 1,
|
||||
);
|
||||
|
@ -32,7 +32,7 @@ class VideoIcon extends StatelessWidget {
|
|||
child = DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade200,
|
||||
fontSize: thumbnailTheme.fontSize,
|
||||
fontSize: gridTheme.fontSize,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
@ -48,7 +48,7 @@ class AnimatedImageIcon extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.animated,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
iconScale: .8,
|
||||
);
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ class GeotiffIcon extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.geo,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ class SphericalImageIcon extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.threeSixty,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ class GpsIcon extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.location,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -97,7 +97,7 @@ class RawIcon extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return OverlayIcon(
|
||||
icon: AIcons.raw,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -112,10 +112,21 @@ class MultiPageIcon extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
IconData icon;
|
||||
String? text;
|
||||
if (entry.isMotionPhoto) {
|
||||
icon = AIcons.motionPhoto;
|
||||
} else {
|
||||
if(entry.isBurst) {
|
||||
text = '${entry.burstEntries?.length}';
|
||||
}
|
||||
icon = AIcons.multiPage;
|
||||
}
|
||||
return OverlayIcon(
|
||||
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
|
||||
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
|
||||
icon: icon,
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
iconScale: .8,
|
||||
text: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,8 +76,6 @@ class AvesLogoPainter extends CustomPainter {
|
|||
path3.relativeArcToPoint(Offset(dim * 1.917, dim * -4.63), radius: Radius.circular(dim * 2.712), rotation: 112.5, clockwise: false);
|
||||
path3.close();
|
||||
|
||||
|
||||
|
||||
canvas.drawPath(
|
||||
path0,
|
||||
Paint()
|
||||
|
|
20
lib/widgets/common/providers/selection_provider.dart
Normal file
20
lib/widgets/common/providers/selection_provider.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import 'dart:ui' as ui;
|
|||
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -79,7 +80,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
|||
builder: (extent) => SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: widget.scaledBuilder(_metadata!.item, extent),
|
||||
child: GridTheme(
|
||||
extent: extent,
|
||||
child: widget.scaledBuilder(_metadata!.item, extent),
|
||||
),
|
||||
),
|
||||
center: thumbnailCenter,
|
||||
viewportWidth: gridWidth,
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
@ -89,7 +89,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
|
|||
Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: DecoratedFilterChip(
|
||||
child: CoveredFilterChip(
|
||||
filter: filter,
|
||||
extent: extent,
|
||||
coverEntry: _isCustom ? _customEntry : _recentEntry,
|
||||
|
|
|
@ -130,7 +130,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
// when the field gets focus, we wait for the soft keyboard to appear
|
||||
// then scroll to the bottom to make sure the field is in view
|
||||
if (_nameFieldFocusNode.hasFocus) {
|
||||
await Future.delayed(Durations.softKeyboardDisplayDelay);
|
||||
await Future.delayed(Durations.softKeyboardDisplayDelay + const Duration(milliseconds: 500));
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/collection_grid.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -36,13 +38,15 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
|
|||
value: ValueNotifier(AppMode.pickInternal),
|
||||
child: MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/actions/chip_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
|
@ -12,9 +13,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
|
|||
import 'package:aves/widgets/common/basic/query_bar.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -46,37 +48,42 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget appBar = AlbumPickAppBar(
|
||||
source: source,
|
||||
moveType: widget.moveType,
|
||||
actionDelegate: AlbumChipSetActionDelegate(),
|
||||
queryNotifier: _queryNotifier,
|
||||
);
|
||||
|
||||
return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>(
|
||||
selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor),
|
||||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterGridPage<AlbumFilter>(
|
||||
settingsRouteKey: AlbumListPage.routeName,
|
||||
appBar: appBar,
|
||||
appBarHeight: AlbumPickAppBar.preferredHeight,
|
||||
filterSections: AlbumListPage.getAlbumEntries(context, source),
|
||||
sortFactor: settings.albumSortFactor,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
queryNotifier: _queryNotifier,
|
||||
applyQuery: (filters, query) {
|
||||
if (query.isEmpty) return filters;
|
||||
query = query.toUpperCase();
|
||||
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
|
||||
},
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: context.l10n.albumEmpty,
|
||||
),
|
||||
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final gridItems = AlbumListPage.getAlbumGridItems(context, source);
|
||||
return SelectionProvider<FilterGridItem<AlbumFilter>>(
|
||||
child: FilterGridPage<AlbumFilter>(
|
||||
settingsRouteKey: AlbumListPage.routeName,
|
||||
appBar: AlbumPickAppBar(
|
||||
source: source,
|
||||
moveType: widget.moveType,
|
||||
actionDelegate: AlbumChipSetActionDelegate(gridItems),
|
||||
queryNotifier: _queryNotifier,
|
||||
),
|
||||
appBarHeight: AlbumPickAppBar.preferredHeight,
|
||||
sections: AlbumListPage.groupToSections(context, source, gridItems),
|
||||
newFilters: source.getNewAlbumFilters(context),
|
||||
sortFactor: settings.albumSortFactor,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
selectable: false,
|
||||
queryNotifier: _queryNotifier,
|
||||
applyQuery: (filters, query) {
|
||||
if (query.isEmpty) return filters;
|
||||
query = query.toUpperCase();
|
||||
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
|
||||
},
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: context.l10n.albumEmpty,
|
||||
),
|
||||
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -156,7 +163,7 @@ class AlbumPickAppBar extends StatelessWidget {
|
|||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, action));
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -9,8 +8,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -38,32 +36,23 @@ class AlbumListPage extends StatelessWidget {
|
|||
animation: androidFileUtils.appNameChangeNotifier,
|
||||
builder: (context, child) => StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
|
||||
source: source,
|
||||
title: context.l10n.albumPageTitle,
|
||||
sortFactor: settings.albumSortFactor,
|
||||
groupable: true,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
chipSetActionDelegate: AlbumChipSetActionDelegate(),
|
||||
chipActionDelegate: AlbumChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) {
|
||||
final dir = VolumeRelativeDirectory.fromPath(filter.album);
|
||||
// do not allow renaming volume root
|
||||
final canRename = dir != null && dir.relativeDir.isNotEmpty;
|
||||
return [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
if (canRename) ChipAction.rename,
|
||||
ChipAction.delete,
|
||||
ChipAction.hide,
|
||||
];
|
||||
},
|
||||
filterSections: getAlbumEntries(context, source),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: context.l10n.albumEmpty,
|
||||
),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final gridItems = getAlbumGridItems(context, source);
|
||||
return FilterNavigationPage<AlbumFilter>(
|
||||
source: source,
|
||||
title: context.l10n.albumPageTitle,
|
||||
sortFactor: settings.albumSortFactor,
|
||||
groupable: true,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
actionDelegate: AlbumChipSetActionDelegate(gridItems),
|
||||
filterSections: groupToSections(context, source, gridItems),
|
||||
newFilters: source.getNewAlbumFilters(context),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: context.l10n.albumEmpty,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -72,18 +61,27 @@ class AlbumListPage extends StatelessWidget {
|
|||
|
||||
// common with album selection page to move/copy entries
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(BuildContext context, CollectionSource source) {
|
||||
static List<FilterGridItem<AlbumFilter>> getAlbumGridItems(BuildContext context, CollectionSource source) {
|
||||
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||
return _group(context, sorted);
|
||||
return FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||
}
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> _group(BuildContext context, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
|
||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> groupToSections(BuildContext context, CollectionSource source, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
|
||||
final newFilters = source.getNewAlbumFilters(context);
|
||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
|
||||
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||
final pinnedMapEntries = byPin[true] ?? [];
|
||||
final unpinnedMapEntries = byPin[false] ?? [];
|
||||
|
||||
final List<FilterGridItem<AlbumFilter>> newMapEntries = [], pinnedMapEntries = [], unpinnedMapEntries = [];
|
||||
for (final item in sortedMapEntries) {
|
||||
final filter = item.filter;
|
||||
if (newFilters.contains(filter)) {
|
||||
newMapEntries.add(item);
|
||||
} else if (pinned.contains(filter)) {
|
||||
pinnedMapEntries.add(item);
|
||||
} else {
|
||||
unpinnedMapEntries.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
var sections = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
|
||||
switch (settings.albumGroupFactor) {
|
||||
|
@ -116,8 +114,9 @@ class AlbumListPage extends StatelessWidget {
|
|||
break;
|
||||
case AlbumChipGroupFactor.none:
|
||||
return {
|
||||
if (pinnedMapEntries.isNotEmpty || unpinnedMapEntries.isNotEmpty)
|
||||
if (sortedMapEntries.isNotEmpty)
|
||||
const ChipSectionKey(): [
|
||||
...newMapEntries,
|
||||
...pinnedMapEntries,
|
||||
...unpinnedMapEntries,
|
||||
],
|
||||
|
@ -131,6 +130,13 @@ class AlbumListPage extends StatelessWidget {
|
|||
]);
|
||||
}
|
||||
|
||||
if (newMapEntries.isNotEmpty) {
|
||||
sections = Map.fromEntries([
|
||||
MapEntry(AlbumImportanceSectionKey.newAlbum(context), newMapEntries),
|
||||
...sections.entries,
|
||||
]);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,151 +1,148 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/actions/chip_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ChipActionDelegate {
|
||||
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
||||
switch (action) {
|
||||
case ChipAction.pin:
|
||||
settings.pinnedFilters = settings.pinnedFilters..add(filter);
|
||||
break;
|
||||
case ChipAction.unpin:
|
||||
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
|
||||
break;
|
||||
case ChipAction.hide:
|
||||
_hide(context, filter);
|
||||
break;
|
||||
case ChipAction.setCover:
|
||||
_showCoverSelectionDialog(context, filter);
|
||||
break;
|
||||
case ChipAction.goToAlbumPage:
|
||||
_goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage());
|
||||
break;
|
||||
case ChipAction.goToCountryPage:
|
||||
_goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage());
|
||||
break;
|
||||
case ChipAction.goToTagPage:
|
||||
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
|
||||
final Iterable<FilterGridItem<AlbumFilter>> _items;
|
||||
|
||||
Future<void> _hide(BuildContext context, CollectionFilter filter) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.hideButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
source.changeFilterVisibility(filter, false);
|
||||
}
|
||||
|
||||
void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async {
|
||||
final contentId = covers.coverContentId(filter);
|
||||
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
|
||||
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
|
||||
context: context,
|
||||
builder: (context) => CoverSelectionDialog(
|
||||
filter: filter,
|
||||
customEntry: customEntry,
|
||||
),
|
||||
);
|
||||
if (coverSelection == null) return;
|
||||
|
||||
final isCustom = coverSelection.item1;
|
||||
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
|
||||
}
|
||||
|
||||
void _goTo(
|
||||
BuildContext context,
|
||||
CollectionFilter filter,
|
||||
String routeName,
|
||||
WidgetBuilder pageBuilder,
|
||||
) {
|
||||
context.read<HighlightInfo>().set(filter);
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: routeName),
|
||||
builder: pageBuilder,
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
@override
|
||||
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
||||
super.onActionSelected(context, filter, action);
|
||||
Iterable<FilterGridItem<AlbumFilter>> get allItems => _items;
|
||||
|
||||
@override
|
||||
ChipSortFactor get sortFactor => settings.albumSortFactor;
|
||||
|
||||
@override
|
||||
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
|
||||
|
||||
@override
|
||||
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
|
||||
switch (action) {
|
||||
case ChipAction.delete:
|
||||
_showDeleteDialog(context, filter as AlbumFilter);
|
||||
case ChipSetAction.delete:
|
||||
case ChipSetAction.rename:
|
||||
return true;
|
||||
default:
|
||||
return super.isValid(filters, action);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool canApply(Set<AlbumFilter> filters, ChipSetAction action) {
|
||||
switch (action) {
|
||||
case ChipSetAction.rename:
|
||||
{
|
||||
if (filters.length != 1) return false;
|
||||
// do not allow renaming volume root
|
||||
final dir = VolumeRelativeDirectory.fromPath(filters.first.album);
|
||||
return dir != null && dir.relativeDir.isNotEmpty;
|
||||
}
|
||||
default:
|
||||
return super.canApply(filters, action);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onActionSelected(BuildContext context, Set<AlbumFilter> filters, ChipSetAction action) {
|
||||
switch (action) {
|
||||
// general
|
||||
case ChipSetAction.group:
|
||||
_showGroupDialog(context);
|
||||
break;
|
||||
case ChipAction.rename:
|
||||
_showRenameDialog(context, filter as AlbumFilter);
|
||||
case ChipSetAction.createAlbum:
|
||||
_createAlbum(context);
|
||||
break;
|
||||
// single/multiple filters
|
||||
case ChipSetAction.delete:
|
||||
_showDeleteDialog(context, filters);
|
||||
break;
|
||||
// single filter
|
||||
case ChipSetAction.rename:
|
||||
_showRenameDialog(context, filters.first);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
super.onActionSelected(context, filters, action);
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
|
||||
Future<void> _showGroupDialog(BuildContext context) async {
|
||||
final factor = await showDialog<AlbumChipGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
|
||||
initialValue: settings.albumGroupFactor,
|
||||
options: {
|
||||
AlbumChipGroupFactor.importance: context.l10n.albumGroupTier,
|
||||
AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume,
|
||||
AlbumChipGroupFactor.none: context.l10n.albumGroupNone,
|
||||
},
|
||||
title: context.l10n.albumGroupTitle,
|
||||
),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (factor != null) {
|
||||
settings.albumGroupFactor = factor;
|
||||
}
|
||||
}
|
||||
|
||||
void _createAlbum(BuildContext context) async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
final source = context.read<CollectionSource>();
|
||||
source.createAlbum(newAlbum);
|
||||
|
||||
final showAction = SnackBarAction(
|
||||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum));
|
||||
context.read<HighlightInfo>().trackItem(FilterGridItem(filter, null), highlightItem: filter);
|
||||
},
|
||||
);
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback, showAction);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context, Set<AlbumFilter> filters) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final source = context.read<CollectionSource>();
|
||||
final album = filter.album;
|
||||
final todoEntries = source.visibleEntries.where(filter.test).toSet();
|
||||
final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
|
||||
final todoCount = todoEntries.length;
|
||||
final todoAlbums = filters.map((v) => v.album).toSet();
|
||||
final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet();
|
||||
final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).toSet();
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)),
|
||||
content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -161,7 +158,10 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||
source.forgetNewAlbums(todoAlbums);
|
||||
source.cleanEmptyAlbums(emptyAlbums);
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return;
|
||||
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
|
@ -180,7 +180,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
}
|
||||
|
||||
// cleanup
|
||||
await storageService.deleteEmptyDirectories({album});
|
||||
await storageService.deleteEmptyDirectories(filledAlbums);
|
||||
},
|
||||
);
|
||||
}
|
75
lib/widgets/filter_grids/common/action_delegates/chip.dart
Normal file
75
lib/widgets/filter_grids/common/action_delegates/chip.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
181
lib/widgets/filter_grids/common/action_delegates/chip_set.dart
Normal file
181
lib/widgets/filter_grids/common/action_delegates/chip_set.dart
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
240
lib/widgets/filter_grids/common/app_bar.dart
Normal file
240
lib/widgets/filter_grids/common/app_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -16,29 +16,27 @@ import 'package:aves/utils/constants.dart';
|
|||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/overlay.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class DecoratedFilterChip extends StatelessWidget {
|
||||
final CollectionFilter filter;
|
||||
class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
||||
final T filter;
|
||||
final double extent, thumbnailExtent;
|
||||
final AvesEntry? coverEntry;
|
||||
final bool pinned, highlightable;
|
||||
final bool pinned;
|
||||
final String? banner;
|
||||
final FilterCallback? onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
||||
const DecoratedFilterChip({
|
||||
const CoveredFilterChip({
|
||||
Key? key,
|
||||
required this.filter,
|
||||
required this.extent,
|
||||
double? thumbnailExtent,
|
||||
this.coverEntry,
|
||||
this.pinned = false,
|
||||
this.highlightable = true,
|
||||
this.banner,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
}) : thumbnailExtent = thumbnailExtent ?? extent,
|
||||
super(key: key);
|
||||
|
||||
|
@ -46,7 +44,7 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Consumer<CollectionSource>(
|
||||
builder: (context, source, child) {
|
||||
switch (filter.runtimeType) {
|
||||
switch (T) {
|
||||
case AlbumFilter:
|
||||
{
|
||||
final album = (filter as AlbumFilter).album;
|
||||
|
@ -89,41 +87,24 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
extent: thumbnailExtent,
|
||||
);
|
||||
final titlePadding = min<double>(4.0, extent / 32);
|
||||
final borderRadius = BorderRadius.all(radius(extent));
|
||||
Widget child = AvesFilterChip(
|
||||
filter: filter,
|
||||
showGenericIcon: false,
|
||||
background: backgroundImage,
|
||||
details: _buildDetails(source, filter),
|
||||
borderRadius: borderRadius,
|
||||
padding: titlePadding,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
|
||||
child = Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
child,
|
||||
if (highlightable)
|
||||
ChipHighlightOverlay(
|
||||
filter: filter,
|
||||
extent: extent,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
child = SizedBox(
|
||||
return SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: child,
|
||||
child: AvesFilterChip(
|
||||
filter: filter,
|
||||
showGenericIcon: false,
|
||||
background: backgroundImage,
|
||||
banner: banner,
|
||||
details: _buildDetails(source, filter),
|
||||
borderRadius: BorderRadius.all(radius(extent)),
|
||||
padding: titlePadding,
|
||||
onTap: onTap,
|
||||
onLongPress: null,
|
||||
),
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildDetails(CollectionSource source, CollectionFilter filter) {
|
||||
Widget _buildDetails(CollectionSource source, T filter) {
|
||||
final padding = min<double>(8.0, extent / 16);
|
||||
final iconSize = min<double>(14.0, extent / 8);
|
||||
final fontSize = min<double>(14.0, extent / 6);
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
|
@ -13,7 +15,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/item_tracker.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/selector.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
|
@ -21,8 +25,9 @@ import 'package:aves/widgets/common/providers/tile_extent_controller_provider.da
|
|||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_chip_grid_decorator.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_layout.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -38,28 +43,29 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
final String? settingsRouteKey;
|
||||
final Widget appBar;
|
||||
final double appBarHeight;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
|
||||
final Set<T> newFilters;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final bool showHeaders, selectable;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final QueryTest<T>? applyQuery;
|
||||
final Widget Function() emptyBuilder;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
||||
const FilterGridPage({
|
||||
Key? key,
|
||||
this.settingsRouteKey,
|
||||
required this.appBar,
|
||||
this.appBarHeight = kToolbarHeight,
|
||||
required this.filterSections,
|
||||
required this.sections,
|
||||
required this.newFilters,
|
||||
required this.sortFactor,
|
||||
required this.showHeaders,
|
||||
required this.selectable,
|
||||
required this.queryNotifier,
|
||||
this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
}) : super(key: key);
|
||||
|
||||
static const Color detailColor = Color(0xFFE0E0E0);
|
||||
|
@ -68,24 +74,35 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: AnimatedBuilder(
|
||||
animation: covers,
|
||||
builder: (context, child) => FilterGrid<T>(
|
||||
settingsRouteKey: settingsRouteKey,
|
||||
appBar: appBar,
|
||||
appBarHeight: appBarHeight,
|
||||
filterSections: filterSections,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: queryNotifier,
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: emptyBuilder,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
body: WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<FilterGridItem<T>>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: AnimatedBuilder(
|
||||
animation: covers,
|
||||
builder: (context, child) => FilterGrid<T>(
|
||||
settingsRouteKey: settingsRouteKey,
|
||||
appBar: appBar,
|
||||
appBarHeight: appBarHeight,
|
||||
sections: sections,
|
||||
newFilters: newFilters,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
selectable: selectable,
|
||||
queryNotifier: queryNotifier,
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: emptyBuilder,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -102,28 +119,29 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
|
|||
final String? settingsRouteKey;
|
||||
final Widget appBar;
|
||||
final double appBarHeight;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
|
||||
final Set<T> newFilters;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final bool showHeaders, selectable;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final QueryTest<T>? applyQuery;
|
||||
final Widget Function() emptyBuilder;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
||||
const FilterGrid({
|
||||
Key? key,
|
||||
required this.settingsRouteKey,
|
||||
required this.appBar,
|
||||
required this.appBarHeight,
|
||||
required this.filterSections,
|
||||
required this.sections,
|
||||
required this.newFilters,
|
||||
required this.sortFactor,
|
||||
required this.showHeaders,
|
||||
required this.selectable,
|
||||
required this.queryNotifier,
|
||||
required this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -152,14 +170,15 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
|
|||
child: _FilterGridContent<T>(
|
||||
appBar: widget.appBar,
|
||||
appBarHeight: widget.appBarHeight,
|
||||
filterSections: widget.filterSections,
|
||||
sections: widget.sections,
|
||||
newFilters: widget.newFilters,
|
||||
sortFactor: widget.sortFactor,
|
||||
showHeaders: widget.showHeaders,
|
||||
selectable: widget.selectable,
|
||||
queryNotifier: widget.queryNotifier,
|
||||
applyQuery: widget.applyQuery,
|
||||
emptyBuilder: widget.emptyBuilder,
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -167,14 +186,14 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
|
|||
|
||||
class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
||||
final Widget appBar;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
|
||||
final Set<T> newFilters;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final bool showHeaders, selectable;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final Widget Function() emptyBuilder;
|
||||
final QueryTest<T>? applyQuery;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
|
||||
|
@ -182,14 +201,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
Key? key,
|
||||
required this.appBar,
|
||||
required double appBarHeight,
|
||||
required this.filterSections,
|
||||
required this.sections,
|
||||
required this.newFilters,
|
||||
required this.sortFactor,
|
||||
required this.showHeaders,
|
||||
required this.selectable,
|
||||
required this.queryNotifier,
|
||||
required this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
}) : super(key: key) {
|
||||
_appBarHeightNotifier.value = appBarHeight;
|
||||
}
|
||||
|
@ -199,15 +219,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
return ValueListenableBuilder<String>(
|
||||
valueListenable: queryNotifier,
|
||||
builder: (context, query, child) {
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
|
||||
if (applyQuery == null) {
|
||||
visibleFilterSections = filterSections;
|
||||
visibleSections = sections;
|
||||
} else {
|
||||
visibleFilterSections = {};
|
||||
filterSections.forEach((sectionKey, sectionFilters) {
|
||||
visibleSections = {};
|
||||
sections.forEach((sectionKey, sectionFilters) {
|
||||
final visibleFilters = applyQuery!(sectionFilters, query);
|
||||
if (visibleFilters.isNotEmpty) {
|
||||
visibleFilterSections[sectionKey] = visibleFilters.toList();
|
||||
visibleSections[sectionKey] = visibleFilters.toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -216,48 +236,55 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
||||
builder: (context, tileExtent, child) {
|
||||
return Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
// do not listen for animation delay change
|
||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||
return SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleFilterSections,
|
||||
showHeaders: showHeaders,
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: (gridItem) {
|
||||
final filter = gridItem.filter;
|
||||
final entry = gridItem.entry;
|
||||
return MetaData(
|
||||
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
|
||||
child: DecoratedFilterChip(
|
||||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
extent: tileExtent,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
);
|
||||
},
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: _FilterSectionedContent<T>(
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
visibleFilterSections: visibleFilterSections,
|
||||
sortFactor: sortFactor,
|
||||
emptyBuilder: emptyBuilder,
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
),
|
||||
);
|
||||
});
|
||||
return GridTheme(
|
||||
extent: tileExtent,
|
||||
child: Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
// do not listen for animation delay change
|
||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||
return SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleSections,
|
||||
showHeaders: showHeaders,
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
tileExtent: tileExtent,
|
||||
tileBuilder: (gridItem) {
|
||||
final filter = gridItem.filter;
|
||||
return MetaData(
|
||||
metaData: ScalerMetadata(gridItem),
|
||||
child: FilterChipGridDecorator<T, FilterGridItem<T>>(
|
||||
gridItem: gridItem,
|
||||
extent: tileExtent,
|
||||
child: CoveredFilterChip(
|
||||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
extent: tileExtent,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: _FilterSectionedContent<T>(
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
visibleSections: visibleSections,
|
||||
sortFactor: sortFactor,
|
||||
selectable: selectable,
|
||||
emptyBuilder: emptyBuilder,
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
|
@ -269,16 +296,18 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget {
|
||||
final Widget appBar;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool selectable;
|
||||
final Widget Function() emptyBuilder;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _FilterSectionedContent({
|
||||
required this.appBar,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.visibleFilterSections,
|
||||
required this.visibleSections,
|
||||
required this.sortFactor,
|
||||
required this.selectable,
|
||||
required this.emptyBuilder,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
@ -293,7 +322,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
|
|||
@override
|
||||
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
|
||||
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleSections => widget.visibleSections;
|
||||
|
||||
Widget Function() get emptyBuilder => widget.emptyBuilder;
|
||||
|
||||
|
@ -328,14 +357,23 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
|
|||
child: scrollView,
|
||||
);
|
||||
|
||||
return scaler;
|
||||
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
|
||||
final selector = GridSelectionGestureDetector<FilterGridItem<T>>(
|
||||
selectable: isMainMode && widget.selectable,
|
||||
items: visibleSections.values.expand((v) => v).toList(),
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
child: scaler,
|
||||
);
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
Future<void> _checkInitHighlight() async {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
final filter = highlightInfo.clear();
|
||||
if (filter is T) {
|
||||
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
|
||||
final gridItem = visibleSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
|
||||
if (gridItem != null) {
|
||||
await Future.delayed(Durations.highlightScrollInitDelay);
|
||||
highlightInfo.trackItem(gridItem, highlightItem: filter);
|
||||
|
@ -367,19 +405,18 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
|
|||
extent: extent,
|
||||
spacing: tileSpacing,
|
||||
borderWidth: AvesFilterChip.outlineWidth,
|
||||
borderRadius: DecoratedFilterChip.radius(extent),
|
||||
borderRadius: CoveredFilterChip.radius(extent),
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (item, extent) {
|
||||
final filter = item.filter;
|
||||
return DecoratedFilterChip(
|
||||
return CoveredFilterChip(
|
||||
filter: filter,
|
||||
extent: extent,
|
||||
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
highlightable: false,
|
||||
);
|
||||
},
|
||||
highlightItem: (item) => item.filter,
|
||||
|
|
|
@ -1,39 +1,24 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/app_bar.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool groupable, showHeaders;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final List<ChipAction> Function(T filter) chipActionsBuilder;
|
||||
final ChipSetActionDelegate actionDelegate;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final Set<T>? newFilters;
|
||||
final Widget Function() emptyBuilder;
|
||||
|
||||
const FilterNavigationPage({
|
||||
|
@ -43,114 +28,56 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
required this.sortFactor,
|
||||
this.groupable = false,
|
||||
this.showHeaders = false,
|
||||
required this.chipSetActionDelegate,
|
||||
required this.chipActionDelegate,
|
||||
required this.chipActionsBuilder,
|
||||
required this.actionDelegate,
|
||||
required this.filterSections,
|
||||
this.newFilters,
|
||||
required this.emptyBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
|
||||
return FilterGridPage<T>(
|
||||
key: const Key('filter-grid-page'),
|
||||
appBar: SliverAppBar(
|
||||
title: InteractiveAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: SourceStateAwareAppBarTitle(
|
||||
title: Text(title),
|
||||
return SelectionProvider<FilterGridItem<T>>(
|
||||
child: Builder(
|
||||
builder: (context) => FilterGridPage<T>(
|
||||
key: const Key('filter-grid-page'),
|
||||
appBar: FilterGridAppBar<T>(
|
||||
source: source,
|
||||
title: title,
|
||||
actionDelegate: actionDelegate,
|
||||
groupable: groupable,
|
||||
isEmpty: filterSections.isEmpty,
|
||||
),
|
||||
),
|
||||
actions: _buildActions(context),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
),
|
||||
filterSections: filterSections,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
queryNotifier: ValueNotifier(''),
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
onTap: (filter) => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(
|
||||
collection: CollectionLens(
|
||||
source: source,
|
||||
filters: [filter],
|
||||
),
|
||||
sections: filterSections,
|
||||
newFilters: newFilters ?? {},
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
selectable: true,
|
||||
queryNotifier: ValueNotifier(''),
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
onTap: (filter) => _goToCollection(context, filter),
|
||||
),
|
||||
),
|
||||
onLongPress: isMainMode ? _showMenu as OffsetFilterCallback : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _showMenu(BuildContext context, T filter, Offset? tapPosition) async {
|
||||
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
|
||||
const touchArea = Size(40, 40);
|
||||
final selectedAction = await showMenu<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect((tapPosition ?? Offset.zero) & touchArea, Offset.zero & overlay.size),
|
||||
items: chipActionsBuilder(filter)
|
||||
.map((action) => PopupMenuItem(
|
||||
value: action,
|
||||
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
if (selectedAction != null) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipActionDelegate.onActionSelected(context, filter, selectedAction));
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
return [
|
||||
CollectionSearchButton(source: source),
|
||||
PopupMenuButton<ChipSetAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: const Key('menu-sort'),
|
||||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
|
||||
),
|
||||
if (groupable)
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.stats,
|
||||
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _goToSearch(BuildContext context) {
|
||||
void _goToCollection(BuildContext context, CollectionFilter filter) {
|
||||
Navigator.push(
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(
|
||||
collection: CollectionLens(
|
||||
source: source,
|
||||
filters: [filter],
|
||||
),
|
||||
));
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
|
||||
|
@ -167,21 +94,23 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
return a.filter.compareTo(b.filter);
|
||||
}
|
||||
|
||||
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
|
||||
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
|
||||
return filters.map((filter) => FilterGridItem(
|
||||
filter,
|
||||
source.recentEntry(filter),
|
||||
));
|
||||
static List<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
|
||||
List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
|
||||
return filters
|
||||
.map((filter) => FilterGridItem(
|
||||
filter,
|
||||
source.recentEntry(filter),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Iterable<FilterGridItem<T>> allMapEntries = {};
|
||||
List<FilterGridItem<T>> allMapEntries = [];
|
||||
switch (sortFactor) {
|
||||
case ChipSortFactor.name:
|
||||
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName);
|
||||
allMapEntries = toGridItem(source, filters)..sort(compareFiltersByName);
|
||||
break;
|
||||
case ChipSortFactor.date:
|
||||
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
|
||||
allMapEntries = toGridItem(source, filters)..sort(compareFiltersByDate);
|
||||
break;
|
||||
case ChipSortFactor.count:
|
||||
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'package:aves/widgets/common/grid/header.dart';
|
|||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FilterChipSectionHeader extends StatelessWidget {
|
||||
class FilterChipSectionHeader<T> extends StatelessWidget {
|
||||
final ChipSectionKey sectionKey;
|
||||
|
||||
const FilterChipSectionHeader({
|
||||
|
@ -12,11 +12,10 @@ class FilterChipSectionHeader extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionHeader(
|
||||
return SectionHeader<T>(
|
||||
sectionKey: sectionKey,
|
||||
leading: sectionKey.leading,
|
||||
title: sectionKey.title,
|
||||
selectable: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,8 @@ class AlbumImportanceSectionKey extends ChipSectionKey {
|
|||
|
||||
AlbumImportanceSectionKey._private(BuildContext context, this.importance) : super(title: importance.getText(context));
|
||||
|
||||
factory AlbumImportanceSectionKey.newAlbum(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.newAlbum);
|
||||
|
||||
factory AlbumImportanceSectionKey.pinned(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.pinned);
|
||||
|
||||
factory AlbumImportanceSectionKey.special(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.special);
|
||||
|
@ -44,11 +46,13 @@ class AlbumImportanceSectionKey extends ChipSectionKey {
|
|||
Widget get leading => Icon(importance.getIcon());
|
||||
}
|
||||
|
||||
enum AlbumImportance { pinned, special, apps, regular }
|
||||
enum AlbumImportance { newAlbum, pinned, special, apps, regular }
|
||||
|
||||
extension ExtraAlbumImportance on AlbumImportance {
|
||||
String getText(BuildContext context) {
|
||||
switch (this) {
|
||||
case AlbumImportance.newAlbum:
|
||||
return context.l10n.albumTierNew;
|
||||
case AlbumImportance.pinned:
|
||||
return context.l10n.albumTierPinned;
|
||||
case AlbumImportance.special:
|
||||
|
@ -62,6 +66,8 @@ extension ExtraAlbumImportance on AlbumImportance {
|
|||
|
||||
IconData getIcon() {
|
||||
switch (this) {
|
||||
case AlbumImportance.newAlbum:
|
||||
return AIcons.newTier;
|
||||
case AlbumImportance.pinned:
|
||||
return AIcons.pin;
|
||||
case AlbumImportance.special:
|
||||
|
|
|
@ -41,7 +41,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
|
|||
|
||||
@override
|
||||
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
|
||||
return FilterChipSectionHeader(
|
||||
return FilterChipSectionHeader<FilterGridItem<T>>(
|
||||
sectionKey: sectionKey as ChipSectionKey,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -8,8 +7,7 @@ import 'package:aves/model/source/location.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/country_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -35,36 +33,32 @@ class CountryListPage extends StatelessWidget {
|
|||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<CountriesChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
|
||||
source: source,
|
||||
title: context.l10n.countryPageTitle,
|
||||
sortFactor: settings.countrySortFactor,
|
||||
chipSetActionDelegate: CountryChipSetActionDelegate(),
|
||||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
ChipAction.hide,
|
||||
],
|
||||
filterSections: _getCountryEntries(source),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: context.l10n.countryEmpty,
|
||||
),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final gridItems = _getGridItems(source);
|
||||
return FilterNavigationPage<LocationFilter>(
|
||||
source: source,
|
||||
title: context.l10n.countryPageTitle,
|
||||
sortFactor: settings.countrySortFactor,
|
||||
actionDelegate: CountryChipSetActionDelegate(gridItems),
|
||||
filterSections: _groupToSections(gridItems),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: context.l10n.countryEmpty,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries(CollectionSource source) {
|
||||
List<FilterGridItem<LocationFilter>> _getGridItems(CollectionSource source) {
|
||||
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
return FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
|
||||
}
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _group(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
|
||||
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _groupToSections(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
|
||||
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -8,8 +7,7 @@ import 'package:aves/model/source/tag.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/tag_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -35,36 +33,32 @@ class TagListPage extends StatelessWidget {
|
|||
builder: (context, s, child) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
|
||||
source: source,
|
||||
title: context.l10n.tagPageTitle,
|
||||
sortFactor: settings.tagSortFactor,
|
||||
chipSetActionDelegate: TagChipSetActionDelegate(),
|
||||
chipActionDelegate: ChipActionDelegate(),
|
||||
chipActionsBuilder: (filter) => [
|
||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
ChipAction.setCover,
|
||||
ChipAction.hide,
|
||||
],
|
||||
filterSections: _getTagEntries(source),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: context.l10n.tagEmpty,
|
||||
),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final gridItems = _getGridItems(source);
|
||||
return FilterNavigationPage<TagFilter>(
|
||||
source: source,
|
||||
title: context.l10n.tagPageTitle,
|
||||
sortFactor: settings.tagSortFactor,
|
||||
actionDelegate: TagChipSetActionDelegate(gridItems),
|
||||
filterSections: _groupToSections(gridItems),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: context.l10n.tagEmpty,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries(CollectionSource source) {
|
||||
List<FilterGridItem<TagFilter>> _getGridItems(CollectionSource source) {
|
||||
final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
|
||||
|
||||
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
|
||||
return _group(sorted);
|
||||
return FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
|
||||
}
|
||||
|
||||
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _group(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
|
||||
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _groupToSections(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>();
|
||||
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
|
|
|
@ -26,12 +26,13 @@ class CollectionSearchButton extends StatelessWidget {
|
|||
|
||||
void _goToSearch(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
source: source,
|
||||
parentCollection: parentCollection,
|
||||
),
|
||||
));
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
source: source,
|
||||
parentCollection: parentCollection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/path.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
|
@ -29,7 +30,7 @@ class HiddenFilterTile extends StatelessWidget {
|
|||
}
|
||||
|
||||
class HiddenFilterPage extends StatelessWidget {
|
||||
static const routeName = '/settings/hidden';
|
||||
static const routeName = '/settings/hidden_filters';
|
||||
|
||||
const HiddenFilterPage({Key? key}) : super(key: key);
|
||||
|
||||
|
@ -41,7 +42,7 @@ class HiddenFilterPage extends StatelessWidget {
|
|||
),
|
||||
body: SafeArea(
|
||||
child: Selector<Settings, Set<CollectionFilter>>(
|
||||
selector: (context, s) => settings.hiddenFilters,
|
||||
selector: (context, s) => settings.hiddenFilters.where((v) => v is! PathFilter).toSet(),
|
||||
builder: (context, hiddenFilters, child) {
|
||||
if (hiddenFilters.isEmpty) {
|
||||
return Column(
|
||||
|
@ -76,7 +77,7 @@ class HiddenFilterPage extends StatelessWidget {
|
|||
.map((filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
removable: true,
|
||||
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true),
|
||||
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility({filter}, true),
|
||||
onLongPress: null,
|
||||
))
|
||||
.toList(),
|
||||
|
|
118
lib/widgets/settings/privacy/hidden_paths.dart
Normal file
118
lib/widgets/settings/privacy/hidden_paths.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
|||
import 'package:aves/widgets/settings/common/tile_leading.dart';
|
||||
import 'package:aves/widgets/settings/privacy/access_grants.dart';
|
||||
import 'package:aves/widgets/settings/privacy/hidden_filters.dart';
|
||||
import 'package:aves/widgets/settings/privacy/hidden_paths.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -47,6 +48,7 @@ class PrivacySection extends StatelessWidget {
|
|||
title: Text(context.l10n.settingsSaveSearchHistory),
|
||||
),
|
||||
const HiddenFilterTile(),
|
||||
const HiddenPathTile(),
|
||||
const StorageAccessTile(),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -36,7 +36,7 @@ class VideoSection extends StatelessWidget {
|
|||
if (!standalonePage)
|
||||
SwitchListTile(
|
||||
value: currentShowVideos,
|
||||
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
|
||||
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility({MimeFilter.video}, v),
|
||||
title: Text(context.l10n.settingsVideoShowVideos),
|
||||
),
|
||||
const VideoActionsTile(),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -9,6 +10,7 @@ import 'package:aves/widgets/settings/viewer/entry_background.dart';
|
|||
import 'package:aves/widgets/settings/viewer/viewer_actions_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ViewerSection extends StatelessWidget {
|
||||
final ValueNotifier<String?> expandedNotifier;
|
||||
|
@ -20,11 +22,6 @@ class ViewerSection extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentShowOverlayMinimap = context.select<Settings, bool>((s) => s.showOverlayMinimap);
|
||||
final currentShowOverlayInfo = context.select<Settings, bool>((s) => s.showOverlayInfo);
|
||||
final currentShowOverlayShootingDetails = context.select<Settings, bool>((s) => s.showOverlayShootingDetails);
|
||||
final currentImageBackground = context.select<Settings, EntryBackground>((s) => s.imageBackground);
|
||||
|
||||
return AvesExpansionTile(
|
||||
leading: SettingsTileLeading(
|
||||
icon: AIcons.image,
|
||||
|
@ -35,30 +32,92 @@ class ViewerSection extends StatelessWidget {
|
|||
showHighlight: false,
|
||||
children: [
|
||||
const ViewerActionsTile(),
|
||||
SwitchListTile(
|
||||
value: currentShowOverlayMinimap,
|
||||
onChanged: (v) => settings.showOverlayMinimap = v,
|
||||
title: Text(context.l10n.settingsViewerShowMinimap),
|
||||
Selector<Settings, bool>(
|
||||
selector: (context, s) => s.showOverlayMinimap,
|
||||
builder: (context, current, child) => SwitchListTile(
|
||||
value: current,
|
||||
onChanged: (v) => settings.showOverlayMinimap = v,
|
||||
title: Text(context.l10n.settingsViewerShowMinimap),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: currentShowOverlayInfo,
|
||||
onChanged: (v) => settings.showOverlayInfo = v,
|
||||
title: Text(context.l10n.settingsViewerShowInformation),
|
||||
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
|
||||
Selector<Settings, bool>(
|
||||
selector: (context, s) => s.showOverlayInfo,
|
||||
builder: (context, current, child) => SwitchListTile(
|
||||
value: current,
|
||||
onChanged: (v) => settings.showOverlayInfo = v,
|
||||
title: Text(context.l10n.settingsViewerShowInformation),
|
||||
subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: currentShowOverlayShootingDetails,
|
||||
onChanged: currentShowOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null,
|
||||
title: Text(context.l10n.settingsViewerShowShootingDetails),
|
||||
Selector<Settings, Tuple2<bool, bool>>(
|
||||
selector: (context, s) => Tuple2(s.showOverlayInfo, s.showOverlayShootingDetails),
|
||||
builder: (context, s, child) {
|
||||
final showInfo = s.item1;
|
||||
final current = s.item2;
|
||||
return SwitchListTile(
|
||||
value: current,
|
||||
onChanged: showInfo ? (v) => settings.showOverlayShootingDetails = v : null,
|
||||
title: Text(context.l10n.settingsViewerShowShootingDetails),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.settingsImageBackground),
|
||||
trailing: EntryBackgroundSelector(
|
||||
getter: () => currentImageBackground,
|
||||
setter: (value) => settings.imageBackground = value,
|
||||
Selector<Settings, bool>(
|
||||
selector: (context, s) => s.enableOverlayBlurEffect,
|
||||
builder: (context, current, child) => SwitchListTile(
|
||||
value: current,
|
||||
onChanged: (v) => settings.enableOverlayBlurEffect = v,
|
||||
title: Text(context.l10n.settingsViewerEnableOverlayBlurEffect),
|
||||
),
|
||||
),
|
||||
const _CutoutModeSwitch(),
|
||||
Selector<Settings, EntryBackground>(
|
||||
selector: (context, s) => s.imageBackground,
|
||||
builder: (context, current, child) => ListTile(
|
||||
title: Text(context.l10n.settingsImageBackground),
|
||||
trailing: EntryBackgroundSelector(
|
||||
getter: () => current,
|
||||
setter: (value) => settings.imageBackground = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CutoutModeSwitch extends StatefulWidget {
|
||||
const _CutoutModeSwitch({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_CutoutModeSwitchState createState() => _CutoutModeSwitchState();
|
||||
}
|
||||
|
||||
class _CutoutModeSwitchState extends State<_CutoutModeSwitch> {
|
||||
late Future<bool> _canSet;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_canSet = windowService.canSetCutoutMode();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<bool>(
|
||||
future: _canSet,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data!) {
|
||||
return Selector<Settings, bool>(
|
||||
selector: (context, s) => s.viewerUseCutout,
|
||||
builder: (context, current, child) => SwitchListTile(
|
||||
value: current,
|
||||
onChanged: (v) => settings.viewerUseCutout = v,
|
||||
title: Text(context.l10n.settingsViewerUseCutout),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@ class ViewerActionEditorPage extends StatelessWidget {
|
|||
EntryAction.export,
|
||||
EntryAction.print,
|
||||
EntryAction.rotateScreen,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.flip,
|
||||
EntryAction.rotateCCW,
|
||||
EntryAction.rotateCW,
|
||||
|
|
|
@ -28,10 +28,9 @@ class StatsPage extends StatelessWidget {
|
|||
|
||||
final CollectionSource source;
|
||||
final CollectionLens? parentCollection;
|
||||
late final Set<AvesEntry> entries;
|
||||
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
|
||||
|
||||
Set<AvesEntry> get entries => parentCollection?.sortedEntries.toSet() ?? source.visibleEntries;
|
||||
|
||||
static const mimeDonutMinWidth = 124.0;
|
||||
|
||||
StatsPage({
|
||||
|
@ -39,6 +38,7 @@ class StatsPage extends StatelessWidget {
|
|||
required this.source,
|
||||
this.parentCollection,
|
||||
}) : super(key: key) {
|
||||
entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries;
|
||||
entries.forEach((entry) {
|
||||
if (entry.hasAddress) {
|
||||
final address = entry.addressDetails!;
|
||||
|
|
83
lib/widgets/viewer/embedded/embedded_data_opener.dart
Normal file
83
lib/widgets/viewer/embedded/embedded_data_opener.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
37
lib/widgets/viewer/embedded/notifications.dart
Normal file
37
lib/widgets/viewer/embedded/notifications.dart
Normal 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}';
|
||||
}
|
|
@ -23,23 +23,15 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
|||
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:aves/widgets/viewer/debug/debug_page.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/printer.dart';
|
||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
final CollectionLens? collection;
|
||||
final VoidCallback showInfo;
|
||||
|
||||
EntryActionDelegate({
|
||||
required this.collection,
|
||||
required this.showInfo,
|
||||
});
|
||||
|
||||
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
|
||||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
|
@ -52,7 +44,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
_showExportDialog(context, entry);
|
||||
break;
|
||||
case EntryAction.info:
|
||||
showInfo();
|
||||
ShowInfoNotification().dispatch(context);
|
||||
break;
|
||||
case EntryAction.rename:
|
||||
_showRenameDialog(context, entry);
|
||||
|
@ -100,6 +92,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
case EntryAction.viewSource:
|
||||
_goToSourceViewer(context, entry);
|
||||
break;
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||
break;
|
||||
case EntryAction.debug:
|
||||
_goToDebug(context, entry);
|
||||
break;
|
||||
|
@ -158,8 +153,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
if (!await entry.delete()) {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
} else {
|
||||
if (collection != null) {
|
||||
await collection!.source.removeEntries({entry.uri});
|
||||
final source = context.read<CollectionSource>();
|
||||
if (source.initialized) {
|
||||
await source.removeEntries({entry.uri});
|
||||
}
|
||||
EntryDeletedNotification(entry).dispatch(context);
|
||||
}
|
||||
|
@ -186,7 +182,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
|
||||
final selection = <AvesEntry>{};
|
||||
if (entry.isMultiPage) {
|
||||
final multiPageInfo = await metadataService.getMultiPageInfo(entry);
|
||||
final multiPageInfo = await entry.getMultiPageInfo();
|
||||
if (multiPageInfo != null) {
|
||||
if (entry.isMotionPhoto) {
|
||||
await multiPageInfo.extractMotionPhotoVideo();
|
||||
|
@ -212,8 +208,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
onDone: (processed) {
|
||||
final movedOps = processed.where((e) => e.success);
|
||||
final movedCount = movedOps.length;
|
||||
final _collection = collection;
|
||||
final showAction = _collection != null && movedCount > 0
|
||||
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
final showAction = isMainMode && movedCount > 0
|
||||
? SnackBarAction(
|
||||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/page_entry_builder.dart';
|
||||
import 'package:aves/widgets/viewer/visual/entry_page_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -12,7 +12,7 @@ class MultiEntryScroller extends StatefulWidget {
|
|||
final CollectionLens collection;
|
||||
final PageController pageController;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
|
||||
|
||||
const MultiEntryScroller({
|
||||
Key? key,
|
||||
|
@ -44,27 +44,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
|||
physics: const MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
final mainEntry = entries[index];
|
||||
|
||||
Widget? child;
|
||||
if (entry.isMultiPage) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(entry);
|
||||
if (multiPageController != null) {
|
||||
child = StreamBuilder<MultiPageInfo?>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
return ValueListenableBuilder<int?>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
child ??= _buildViewer(entry);
|
||||
var child = mainEntry.isMultiPage
|
||||
? PageEntryBuilder(
|
||||
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
|
||||
builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry),
|
||||
)
|
||||
: _buildViewer(mainEntry);
|
||||
|
||||
child = AnimatedBuilder(
|
||||
animation: pageController,
|
||||
|
@ -93,17 +80,11 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
|
|||
}
|
||||
|
||||
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
|
||||
return Selector<MediaQueryData, Size>(
|
||||
selector: (c, mq) => mq.size,
|
||||
builder: (c, mqSize, child) {
|
||||
return EntryPageView(
|
||||
key: const Key('imageview'),
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
viewportSize: mqSize,
|
||||
onDisposed: () => widget.onViewDisposed(mainEntry.uri),
|
||||
);
|
||||
},
|
||||
return EntryPageView(
|
||||
key: const Key('imageview'),
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -130,25 +111,12 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
|
|||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
Widget? child;
|
||||
if (mainEntry.isMultiPage) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
child = StreamBuilder<MultiPageInfo?>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
return ValueListenableBuilder<int?>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
child ??= _buildViewer();
|
||||
var child = mainEntry.isMultiPage
|
||||
? PageEntryBuilder(
|
||||
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
|
||||
builder: (pageEntry) => _buildViewer(pageEntry: pageEntry),
|
||||
)
|
||||
: _buildViewer();
|
||||
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: const [Axis.vertical],
|
||||
|
@ -157,15 +125,9 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
|
|||
}
|
||||
|
||||
Widget _buildViewer({AvesEntry? pageEntry}) {
|
||||
return Selector<MediaQueryData, Size>(
|
||||
selector: (c, mq) => mq.size,
|
||||
builder: (c, mqSize, child) {
|
||||
return EntryPageView(
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
viewportSize: mqSize,
|
||||
);
|
||||
},
|
||||
return EntryPageView(
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry ?? mainEntry,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class ViewerVerticalPageView extends StatefulWidget {
|
|||
final PageController horizontalPager, verticalPager;
|
||||
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
|
||||
final VoidCallback onImagePageRequested;
|
||||
final void Function(String uri) onViewDisposed;
|
||||
final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
|
||||
|
||||
const ViewerVerticalPageView({
|
||||
Key? key,
|
||||
|
@ -98,7 +98,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
)
|
||||
: const SizedBox();
|
||||
|
||||
final infoPage = NotificationListener<BackUpNotification>(
|
||||
final infoPage = NotificationListener<ShowImageNotification>(
|
||||
onNotification: (notification) {
|
||||
widget.onImagePageRequested();
|
||||
return true;
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
|||
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/visual/conductor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -23,15 +24,13 @@ class EntryViewerPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: Provider<VideoConductor>(
|
||||
create: (context) => VideoConductor(),
|
||||
dispose: (context, value) => value.dispose(),
|
||||
child: Provider<MultiPageConductor>(
|
||||
create: (context) => MultiPageConductor(),
|
||||
dispose: (context, value) => value.dispose(),
|
||||
child: EntryViewerStack(
|
||||
collection: collection,
|
||||
initialEntry: initialEntry,
|
||||
body: ViewStateConductorProvider(
|
||||
child: VideoConductorProvider(
|
||||
child: MultiPageConductorProvider(
|
||||
child: EntryViewerStack(
|
||||
collection: collection,
|
||||
initialEntry: initialEntry,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -41,3 +40,61 @@ class EntryViewerPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewStateConductorProvider extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
const ViewStateConductorProvider({
|
||||
Key? key,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider<MediaQueryData, ViewStateConductor>(
|
||||
create: (context) => ViewStateConductor(),
|
||||
update: (context, mq, value) {
|
||||
value!.viewportSize = mq.size;
|
||||
return value;
|
||||
},
|
||||
dispose: (context, value) => value.dispose(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoConductorProvider extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
const VideoConductorProvider({
|
||||
Key? key,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Provider<VideoConductor>(
|
||||
create: (context) => VideoConductor(),
|
||||
dispose: (context, value) => value.dispose(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MultiPageConductorProvider extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
const MultiPageConductorProvider({
|
||||
Key? key,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Provider<MultiPageConductor>(
|
||||
create: (context) => MultiPageConductor(),
|
||||
dispose: (context, value) => value.dispose(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
|
@ -12,28 +10,30 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
|
||||
import 'package:aves/widgets/viewer/hero.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/controller.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/bottom/common.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/bottom/video.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/top.dart';
|
||||
import 'package:aves/widgets/viewer/page_entry_builder.dart';
|
||||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/video/controller.dart';
|
||||
import 'package:aves/widgets/viewer/video_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/visual/state.dart';
|
||||
import 'package:aves/widgets/viewer/visual/conductor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class EntryViewerStack extends StatefulWidget {
|
||||
final CollectionLens? collection;
|
||||
|
@ -49,7 +49,7 @@ class EntryViewerStack extends StatefulWidget {
|
|||
_EntryViewerStackState createState() => _EntryViewerStackState();
|
||||
}
|
||||
|
||||
class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
final ValueNotifier<AvesEntry?> _entryNotifier = ValueNotifier(null);
|
||||
late int _currentHorizontalPage;
|
||||
late ValueNotifier<int> _currentVerticalPage;
|
||||
|
@ -60,9 +60,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
late Animation<double> _topOverlayScale, _bottomOverlayScale;
|
||||
late Animation<Offset> _bottomOverlayOffset;
|
||||
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
|
||||
late EntryActionDelegate _entryActionDelegate;
|
||||
late VideoActionDelegate _videoActionDelegate;
|
||||
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
|
||||
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
|
||||
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
||||
bool _isEntryTracked = true;
|
||||
|
||||
|
@ -81,8 +80,19 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!settings.viewerUseCutout) {
|
||||
windowService.setCutoutMode(false);
|
||||
}
|
||||
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
|
||||
windowService.keepScreenOn(true);
|
||||
}
|
||||
|
||||
// make sure initial entry is actually among the filtered collection entries
|
||||
final entry = entries.contains(widget.initialEntry) ? widget.initialEntry : entries.firstOrNull;
|
||||
// `initialEntry` may be a dynamic burst entry from another collection lens
|
||||
// so it is, strictly speaking, not contained in the lens used by the viewer,
|
||||
// but it can be found by content ID
|
||||
final initialEntry = widget.initialEntry;
|
||||
final entry = entries.firstWhereOrNull((v) => v.contentId == initialEntry.contentId) ?? entries.firstOrNull;
|
||||
// opening hero, with viewer as target
|
||||
_heroInfoNotifier.value = HeroInfo(collection?.id, entry);
|
||||
_entryNotifier.value = entry;
|
||||
|
@ -109,20 +119,13 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
curve: Curves.easeOutQuad,
|
||||
));
|
||||
_overlayVisible.addListener(_onOverlayVisibleChange);
|
||||
_entryActionDelegate = EntryActionDelegate(
|
||||
collection: collection,
|
||||
showInfo: () => _goToVerticalPage(infoPage),
|
||||
);
|
||||
_videoActionDelegate = VideoActionDelegate(
|
||||
collection: collection,
|
||||
);
|
||||
_initEntryControllers();
|
||||
_initEntryControllers(entry);
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addObserver(this);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _initOverlay());
|
||||
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
|
||||
windowService.keepScreenOn(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -134,6 +137,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_cleanEntryControllers(_entryNotifier.value);
|
||||
_videoActionDelegate.dispose();
|
||||
_overlayAnimationController.dispose();
|
||||
_overlayVisible.removeListener(_onOverlayVisibleChange);
|
||||
_verticalPager.removeListener(_onVerticalPageControllerChange);
|
||||
|
@ -166,6 +171,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewStateConductor = context.read<ViewStateConductor>();
|
||||
return WillPopScope(
|
||||
onWillPop: () {
|
||||
if (_currentVerticalPage.value == infoPage) {
|
||||
|
@ -183,8 +189,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
onNotification: (dynamic notification) {
|
||||
if (notification is FilterSelectedNotification) {
|
||||
_goToCollection(notification.filter);
|
||||
} else if (notification is ViewStateNotification) {
|
||||
_updateViewState(notification.uri, notification.viewState);
|
||||
} else if (notification is EntryDeletedNotification) {
|
||||
_onEntryDeleted(context, notification.entry);
|
||||
}
|
||||
|
@ -192,7 +196,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
},
|
||||
child: NotificationListener<ToggleOverlayNotification>(
|
||||
onNotification: (notification) {
|
||||
_overlayVisible.value = !_overlayVisible.value;
|
||||
_overlayVisible.value = notification.visible ?? !_overlayVisible.value;
|
||||
return true;
|
||||
},
|
||||
child: Stack(
|
||||
|
@ -205,7 +209,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
onVerticalPageChanged: _onVerticalPageChanged,
|
||||
onHorizontalPageChanged: _onHorizontalPageChanged,
|
||||
onImagePageRequested: () => _goToVerticalPage(imagePage),
|
||||
onViewDisposed: (uri) => _updateViewState(uri, null),
|
||||
onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
|
||||
),
|
||||
_buildTopOverlay(),
|
||||
_buildBottomOverlay(),
|
||||
|
@ -218,38 +222,36 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
void _updateViewState(String uri, ViewState? viewState) {
|
||||
final viewStateNotifier = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri)?.item2;
|
||||
viewStateNotifier?.value = viewState ?? ViewState.zero;
|
||||
}
|
||||
|
||||
Widget _buildTopOverlay() {
|
||||
Widget child = ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: _entryNotifier,
|
||||
builder: (context, mainEntry, child) {
|
||||
if (mainEntry == null) return const SizedBox.shrink();
|
||||
|
||||
return ViewerTopOverlay(
|
||||
mainEntry: mainEntry,
|
||||
scale: _topOverlayScale,
|
||||
canToggleFavourite: hasCollection,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
onActionSelected: (action) {
|
||||
var targetEntry = mainEntry;
|
||||
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
|
||||
if (pageEntry != null) {
|
||||
targetEntry = pageEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
_entryActionDelegate.onActionSelected(context, targetEntry, action);
|
||||
Widget _buildContent({AvesEntry? pageEntry}) {
|
||||
return EmbeddedDataOpener(
|
||||
entry: mainEntry,
|
||||
child: ViewerTopOverlay(
|
||||
mainEntry: mainEntry,
|
||||
scale: _topOverlayScale,
|
||||
canToggleFavourite: hasCollection,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NotificationListener<ShowInfoNotification>(
|
||||
onNotification: (notification) {
|
||||
_goToVerticalPage(infoPage);
|
||||
return true;
|
||||
},
|
||||
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
|
||||
child: mainEntry.isMultiPage
|
||||
? PageEntryBuilder(
|
||||
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
|
||||
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
|
||||
)
|
||||
: _buildContent(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -284,14 +286,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
valueListenable: _entryNotifier,
|
||||
builder: (context, mainEntry, child) {
|
||||
if (mainEntry == null) return const SizedBox.shrink();
|
||||
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
|
||||
|
||||
Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) {
|
||||
Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) {
|
||||
final targetEntry = pageEntry ?? mainEntry;
|
||||
Widget? child;
|
||||
// a 360 video is both a video and a panorama but only the video controls are displayed
|
||||
if (pageEntry.isVideo) {
|
||||
return Selector<VideoConductor, AvesVideoController?>(
|
||||
selector: (context, vc) => vc.getController(pageEntry),
|
||||
if (targetEntry.isVideo) {
|
||||
child = Selector<VideoConductor, AvesVideoController?>(
|
||||
selector: (context, vc) => vc.getController(targetEntry),
|
||||
builder: (context, videoController, child) => VideoControlOverlay(
|
||||
entry: pageEntry,
|
||||
entry: targetEntry,
|
||||
controller: videoController,
|
||||
scale: _bottomOverlayScale,
|
||||
onActionSelected: (action) {
|
||||
|
@ -301,40 +306,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
},
|
||||
),
|
||||
);
|
||||
} else if (pageEntry.is360) {
|
||||
return PanoramaOverlay(
|
||||
entry: pageEntry,
|
||||
} else if (targetEntry.is360) {
|
||||
child = PanoramaOverlay(
|
||||
entry: targetEntry,
|
||||
scale: _bottomOverlayScale,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return child != null
|
||||
? ExtraBottomOverlay(
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
child: child,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null;
|
||||
final extraBottomOverlay = multiPageController != null
|
||||
? StreamBuilder<MultiPageInfo?>(
|
||||
stream: multiPageController.infoStream,
|
||||
builder: (context, snapshot) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
if (multiPageInfo == null) return const SizedBox.shrink();
|
||||
return ValueListenableBuilder<int?>(
|
||||
valueListenable: multiPageController.pageNotifier,
|
||||
builder: (context, page, child) {
|
||||
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
|
||||
return _buildExtraBottomOverlay(pageEntry) ?? const SizedBox();
|
||||
},
|
||||
);
|
||||
})
|
||||
: _buildExtraBottomOverlay(mainEntry);
|
||||
final extraBottomOverlay = mainEntry.isMultiPage
|
||||
? PageEntryBuilder(
|
||||
multiPageController: multiPageController,
|
||||
builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(),
|
||||
)
|
||||
: _buildExtraBottomOverlay();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (extraBottomOverlay != null)
|
||||
ExtraBottomOverlay(
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
child: extraBottomOverlay,
|
||||
),
|
||||
if (extraBottomOverlay != null) extraBottomOverlay,
|
||||
SlideTransition(
|
||||
position: _bottomOverlayOffset,
|
||||
child: ViewerBottomOverlay(
|
||||
|
@ -421,7 +417,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
void _onVerticalPageChanged(int page) {
|
||||
_currentVerticalPage.value = page;
|
||||
if (page == transitionPage) {
|
||||
_entryActionDelegate.dismissFeedback(context);
|
||||
dismissFeedback(context);
|
||||
_popVisual();
|
||||
} else if (page == infoPage) {
|
||||
// prevent hero when viewer is offscreen
|
||||
|
@ -463,10 +459,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
|
||||
final newEntry = _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
|
||||
if (_entryNotifier.value == newEntry) return;
|
||||
_cleanEntryControllers(_entryNotifier.value);
|
||||
_entryNotifier.value = newEntry;
|
||||
_isEntryTracked = false;
|
||||
await _pauseVideoControllers();
|
||||
await _initEntryControllers();
|
||||
await _initEntryControllers(newEntry);
|
||||
}
|
||||
|
||||
void _popVisual() {
|
||||
|
@ -507,11 +504,15 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
}
|
||||
|
||||
void _onLeave() {
|
||||
_showSystemUI();
|
||||
windowService.requestOrientation();
|
||||
if (!settings.viewerUseCutout) {
|
||||
windowService.setCutoutMode(true);
|
||||
}
|
||||
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
|
||||
windowService.keepScreenOn(false);
|
||||
}
|
||||
|
||||
_showSystemUI();
|
||||
windowService.requestOrientation();
|
||||
}
|
||||
|
||||
// system UI
|
||||
|
@ -558,11 +559,9 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
|
||||
// state controllers/monitors
|
||||
|
||||
Future<void> _initEntryControllers() async {
|
||||
final entry = _entryNotifier.value;
|
||||
Future<void> _initEntryControllers(AvesEntry? entry) async {
|
||||
if (entry == null) return;
|
||||
|
||||
_initViewStateController(entry);
|
||||
if (entry.isVideo) {
|
||||
await _initVideoController(entry);
|
||||
}
|
||||
|
@ -571,17 +570,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
void _initViewStateController(AvesEntry entry) {
|
||||
final uri = entry.uri;
|
||||
var controller = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri);
|
||||
if (controller != null) {
|
||||
_viewStateNotifiers.remove(controller);
|
||||
} else {
|
||||
controller = Tuple2(uri, ValueNotifier<ViewState>(ViewState.zero));
|
||||
}
|
||||
_viewStateNotifiers.insert(0, controller);
|
||||
while (_viewStateNotifiers.length > 3) {
|
||||
_viewStateNotifiers.removeLast().item2.dispose();
|
||||
void _cleanEntryControllers(AvesEntry? entry) {
|
||||
if (entry == null) return;
|
||||
|
||||
if (entry.isMultiPage) {
|
||||
_cleanMultiPageController(entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -626,11 +619,22 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
_multiPageControllerPageListeners[multiPageController] = _onPageChange;
|
||||
multiPageController.pageNotifier.addListener(_onPageChange);
|
||||
await _onPageChange();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanMultiPageController(AvesEntry entry) async {
|
||||
final multiPageController = _multiPageControllerPageListeners.keys.firstWhereOrNull((v) => v.entry == entry);
|
||||
if (multiPageController != null) {
|
||||
final _onPageChange = _multiPageControllerPageListeners.remove(multiPageController);
|
||||
if (_onPageChange != null) {
|
||||
multiPageController.pageNotifier.removeListener(_onPageChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playVideo(AvesVideoController videoController, bool Function() isCurrent) async {
|
||||
// video decoding may fail or have initial artifacts when the player initializes
|
||||
// during this widget initialization (because of the page transition and hero animation?)
|
||||
|
|
|
@ -3,14 +3,15 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/page_entry_builder.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -34,9 +35,7 @@ class _InfoPageState extends State<InfoPage> {
|
|||
final ScrollController _scrollController = ScrollController();
|
||||
bool _scrollStartFromTop = false;
|
||||
|
||||
CollectionLens? get collection => widget.collection;
|
||||
|
||||
AvesEntry? get entry => widget.entryNotifier.value;
|
||||
static const splitScreenWidthThreshold = 600;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -47,31 +46,39 @@ class _InfoPageState extends State<InfoPage> {
|
|||
bottom: false,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleTopScroll,
|
||||
child: NotificationListener<OpenTempEntryNotification>(
|
||||
onNotification: (notification) {
|
||||
_openTempEntry(notification.entry);
|
||||
return true;
|
||||
},
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width,
|
||||
builder: (c, mqWidth, child) {
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
return entry != null
|
||||
? _InfoPageContent(
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: _scrollController,
|
||||
split: mqWidth > 600,
|
||||
goToViewer: _goToViewer,
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width,
|
||||
builder: (c, mqWidth, child) {
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.entryNotifier,
|
||||
builder: (context, mainEntry, child) {
|
||||
if (mainEntry != null) {
|
||||
Widget _buildContent({AvesEntry? pageEntry}) {
|
||||
final targetEntry = pageEntry ?? mainEntry;
|
||||
return EmbeddedDataOpener(
|
||||
entry: targetEntry,
|
||||
child: _InfoPageContent(
|
||||
collection: widget.collection,
|
||||
entry: targetEntry,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: _scrollController,
|
||||
split: mqWidth > splitScreenWidthThreshold,
|
||||
goToViewer: _goToViewer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return mainEntry.isBurst
|
||||
? PageEntryBuilder(
|
||||
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
|
||||
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
: _buildContent();
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -102,25 +109,13 @@ class _InfoPageState extends State<InfoPage> {
|
|||
}
|
||||
|
||||
void _goToViewer() {
|
||||
BackUpNotification().dispatch(context);
|
||||
ShowImageNotification().dispatch(context);
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: Durations.viewerVerticalPageScrollAnimation,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _openTempEntry(AvesEntry tempEntry) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||
initialEntry: tempEntry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoPageContent extends StatefulWidget {
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -112,11 +110,8 @@ class InfoSearchDelegate extends SearchDelegate {
|
|||
icon: AIcons.info,
|
||||
text: context.l10n.viewerInfoSearchEmpty,
|
||||
)
|
||||
: NotificationListener<OpenTempEntryNotification>(
|
||||
onNotification: (notification) {
|
||||
_openTempEntry(context, notification.entry);
|
||||
return true;
|
||||
},
|
||||
: EmbeddedDataOpener(
|
||||
entry: entry,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) => tiles[index],
|
||||
|
@ -125,16 +120,4 @@ class InfoSearchDelegate extends SearchDelegate {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openTempEntry(BuildContext context, AvesEntry tempEntry) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||
initialEntry: tempEntry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue