diff --git a/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.kt b/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.kt index 6afd50d..8eacd07 100644 --- a/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.kt +++ b/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.kt @@ -7,11 +7,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import io.github.vinceglb.picker.core.Picker import io.github.vinceglb.picker.core.PickerSelectionMode +import io.github.vinceglb.picker.core.PickerSelectionType +import io.github.vinceglb.picker.core.PlatformDirectory import io.github.vinceglb.picker.core.PlatformFile import kotlinx.coroutines.launch @Composable -public fun rememberPickerLauncher( +public fun rememberFilePickerLauncher( + type: PickerSelectionType = PickerSelectionType.File(), mode: PickerSelectionMode, title: String? = null, initialDirectory: String? = null, @@ -24,6 +27,7 @@ public fun rememberPickerLauncher( val coroutineScope = rememberCoroutineScope() // Updated state + val currentType by rememberUpdatedState(type) val currentMode by rememberUpdatedState(mode) val currentTitle by rememberUpdatedState(title) val currentInitialDirectory by rememberUpdatedState(initialDirectory) @@ -36,7 +40,8 @@ public fun rememberPickerLauncher( val returnedLauncher = remember { PickerResultLauncher { coroutineScope.launch { - val result = picker.pick( + val result = picker.pickFile( + type = currentType, mode = currentMode, title = currentTitle, initialDirectory = currentInitialDirectory, @@ -50,7 +55,59 @@ public fun rememberPickerLauncher( } @Composable -public fun rememberSaverLauncher( +public fun rememberFilePickerLauncher( + type: PickerSelectionType = PickerSelectionType.File(), + title: String? = null, + initialDirectory: String? = null, + onResult: (PlatformFile?) -> Unit, +): PickerResultLauncher { + return rememberFilePickerLauncher( + type = type, + mode = PickerSelectionMode.Single, + title = title, + initialDirectory = initialDirectory, + onResult = onResult, + ) +} + +@Composable +public fun rememberDirectoryPickerLauncher( + title: String? = null, + initialDirectory: String? = null, + onResult: (PlatformDirectory?) -> Unit, +): PickerResultLauncher { + // Init picker + InitPicker() + + // Coroutine + val coroutineScope = rememberCoroutineScope() + + // Updated state + val currentTitle by rememberUpdatedState(title) + val currentInitialDirectory by rememberUpdatedState(initialDirectory) + val currentOnResult by rememberUpdatedState(onResult) + + // Picker + val picker = remember { Picker } + + // Picker launcher + val returnedLauncher = remember { + PickerResultLauncher { + coroutineScope.launch { + val result = picker.pickDirectory( + title = currentTitle, + initialDirectory = currentInitialDirectory, + ) + currentOnResult(result) + } + } + } + + return returnedLauncher +} + +@Composable +public fun rememberFileSaverLauncher( onResult: (PlatformFile?) -> Unit ): SaverResultLauncher { // Init picker @@ -69,7 +126,7 @@ public fun rememberSaverLauncher( val returnedLauncher = remember { SaverResultLauncher { bytes, baseName, extension, initialDirectory -> coroutineScope.launch { - val result = picker.save( + val result = picker.saveFile( bytes = bytes, baseName = baseName, extension = extension, diff --git a/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/Picker.android.kt b/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/Picker.android.kt index 81d9958..0ad2392 100644 --- a/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/Picker.android.kt +++ b/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/Picker.android.kt @@ -5,8 +5,12 @@ import android.net.Uri import android.webkit.MimeTypeMap import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import io.github.vinceglb.picker.core.PickerSelectionMode.SelectionResult +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageAndVideo +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.VideoOnly import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.lang.ref.WeakReference @@ -16,18 +20,18 @@ import kotlin.coroutines.suspendCoroutine public actual object Picker { private var registry: ActivityResultRegistry? = null - internal var context: WeakReference = WeakReference(null) - private set + private var context: WeakReference = WeakReference(null) public fun init(activity: ComponentActivity) { context = WeakReference(activity.applicationContext) registry = activity.activityResultRegistry } - public actual suspend fun pick( + public actual suspend fun pickFile( + type: PickerSelectionType, mode: PickerSelectionMode, title: String?, - initialDirectory: String?, + initialDirectory: String? ): Out? = withContext(Dispatchers.IO) { // Throw exception if registry is not initialized val registry = registry ?: throw PickerNotInitializedException() @@ -35,47 +39,103 @@ public actual object Picker { // It doesn't really matter what the key is, just that it is unique val key = UUID.randomUUID().toString() - // Open native file picker - val selection = suspendCoroutine { continuation -> - when (mode) { - is PickerSelectionMode.SingleFile -> { - val contract = ActivityResultContracts.OpenDocument() - val launcher = registry.register(key, contract) { uri -> - continuation.resume(SelectionResult( - files = uri?.let { listOf(it) } - )) + // Get context + val context = Picker.context.get() + ?: throw PickerNotInitializedException() + + val result: PlatformFiles? = suspendCoroutine { continuation -> + when (type) { + PickerSelectionType.Image, + PickerSelectionType.Video, + PickerSelectionType.ImageAndVideo -> { + when (mode) { + is PickerSelectionMode.Single -> { + val contract = PickVisualMedia() + val launcher = registry.register(key, contract) { uri -> + val result = uri?.let { listOf(PlatformFile(it, context)) } + continuation.resume(result) + } + + val request = when (type) { + PickerSelectionType.Image -> PickVisualMediaRequest(ImageOnly) + PickerSelectionType.Video -> PickVisualMediaRequest(VideoOnly) + PickerSelectionType.ImageAndVideo -> PickVisualMediaRequest(ImageAndVideo) + else -> throw IllegalArgumentException("Unsupported type: $type") + } + + launcher.launch(request) + } + + is PickerSelectionMode.Multiple -> { + val contract = ActivityResultContracts.PickMultipleVisualMedia() + val launcher = registry.register(key, contract) { uri -> + val result = uri.map { PlatformFile(it, context) } + continuation.resume(result) + } + + val request = when (type) { + PickerSelectionType.Image -> PickVisualMediaRequest(ImageOnly) + PickerSelectionType.Video -> PickVisualMediaRequest(VideoOnly) + PickerSelectionType.ImageAndVideo -> PickVisualMediaRequest(ImageAndVideo) + else -> throw IllegalArgumentException("Unsupported type: $type") + } + + launcher.launch(request) + } } - launcher.launch(getMimeTypes(mode.extensions)) } - is PickerSelectionMode.MultipleFiles -> { - val contract = ActivityResultContracts.OpenMultipleDocuments() - val launcher = registry.register(key, contract) { uris -> - continuation.resume(SelectionResult(files = uris)) + is PickerSelectionType.File -> { + when (mode) { + is PickerSelectionMode.Single -> { + val contract = ActivityResultContracts.OpenDocument() + val launcher = registry.register(key, contract) { uri -> + val result = uri?.let { listOf(PlatformFile(it, context)) } + continuation.resume(result) + } + launcher.launch(getMimeTypes(type.extensions)) + } + + is PickerSelectionMode.Multiple -> { + val contract = ActivityResultContracts.OpenMultipleDocuments() + val launcher = registry.register(key, contract) { uris -> + val result = uris.map { PlatformFile(it, context) } + continuation.resume(result) + } + launcher.launch(getMimeTypes(type.extensions)) + } } - launcher.launch(getMimeTypes(mode.extensions)) } + } + } - is PickerSelectionMode.Directory -> { - val contract = ActivityResultContracts.OpenDocumentTree() - val launcher = registry.register(key, contract) { uri -> - continuation.resume(SelectionResult( - files = uri?.let { listOf(it) } - )) - } - val initialUri = initialDirectory?.let { Uri.parse(it) } - launcher.launch(initialUri) - } + mode.parseResult(result) + } + + public actual suspend fun pickDirectory( + title: String?, + initialDirectory: String? + ): PlatformDirectory? = withContext(Dispatchers.IO) { + // Throw exception if registry is not initialized + val registry = registry ?: throw PickerNotInitializedException() + + // It doesn't really matter what the key is, just that it is unique + val key = UUID.randomUUID().toString() - else -> throw IllegalArgumentException("Unsupported mode: $mode") + suspendCoroutine { continuation -> + val contract = ActivityResultContracts.OpenDocumentTree() + val launcher = registry.register(key, contract) { uri -> + val platformDirectory = uri?.let { PlatformDirectory(it) } + continuation.resume(platformDirectory) } + val initialUri = initialDirectory?.let { Uri.parse(it) } + launcher.launch(initialUri) } - - // Return result - return@withContext mode.result(selection) } - public actual suspend fun save( + public actual fun isDirectoryPickerSupported(): Boolean = true + + public actual suspend fun saveFile( bytes: ByteArray, baseName: String, extension: String, @@ -114,7 +174,7 @@ public actual object Picker { } } - public fun getMimeTypes(fileExtensions: List?): Array { + private fun getMimeTypes(fileExtensions: List?): Array { val mimeTypeMap = MimeTypeMap.getSingleton() return fileExtensions ?.takeIf { it.isNotEmpty() } @@ -123,7 +183,7 @@ public actual object Picker { ?: arrayOf("*/*") } - public fun getMimeType(fileExtension: String): String { + private fun getMimeType(fileExtension: String): String { val mimeTypeMap = MimeTypeMap.getSingleton() return mimeTypeMap.getMimeTypeFromExtension(fileExtension) ?: "*/*" } diff --git a/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.android.kt b/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.android.kt deleted file mode 100644 index 0b9fed4..0000000 --- a/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.android.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.vinceglb.picker.core - -import android.net.Uri - -public actual sealed class PickerSelectionMode { - internal actual class SelectionResult( - val files: List? - ) - - internal actual abstract fun result(selection: SelectionResult): Out? - - public actual class SingleFile actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - actual override fun result(selection: SelectionResult): PlatformFile? { - val context = Picker.context.get() - ?: throw PickerNotInitializedException() - - return selection.files - ?.firstOrNull() - ?.let { PlatformFile(it, context) } - } - } - - public actual class MultipleFiles actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - override fun result(selection: SelectionResult): PlatformFiles? { - val context = Picker.context.get() - ?: throw PickerNotInitializedException() - - return selection.files - ?.takeIf { it.isNotEmpty() } - ?.map { PlatformFile(it, context) } - } - } - - public actual data object Directory : PickerSelectionMode() { - public actual val isSupported: Boolean = true - - override fun result(selection: SelectionResult): PlatformDirectory? { - return selection.files - ?.firstOrNull() - ?.let { PlatformDirectory(it) } - } - } -} diff --git a/picker-core/src/appleMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.apple.kt b/picker-core/src/appleMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.apple.kt deleted file mode 100644 index 152e673..0000000 --- a/picker-core/src/appleMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.apple.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.vinceglb.picker.core - -import platform.Foundation.NSURL - -public actual sealed class PickerSelectionMode { - internal actual class SelectionResult( - val nsUrls: List - ) - - internal actual abstract fun result(selection: SelectionResult): Out? - - public actual class SingleFile actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - actual override fun result(selection: SelectionResult): PlatformFile? { - return selection.nsUrls - .firstOrNull() - ?.let { PlatformFile(it) } - } - } - - public actual class MultipleFiles actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - override fun result(selection: SelectionResult): PlatformFiles? { - return selection.nsUrls - .takeIf { it.isNotEmpty() } - ?.map { PlatformFile(it) } - } - } - - public actual data object Directory : PickerSelectionMode() { - public actual val isSupported: Boolean = true - - override fun result(selection: SelectionResult): PlatformDirectory? { - return selection.nsUrls - .firstOrNull() - ?.let { PlatformDirectory(it) } - } - } -} diff --git a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Picker.kt b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Picker.kt index ae21103..913f887 100644 --- a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Picker.kt +++ b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Picker.kt @@ -1,35 +1,37 @@ package io.github.vinceglb.picker.core public expect object Picker { - /** - * This function is used to pick a file or multiple files based on the mode provided. - * It is a suspend function and should be called from a coroutine or a suspend function. - * - * @param mode The mode of file picking. It could be single file, multiple files, or directory. - * @param title The title for the file picker dialog. It is optional and defaults to null. - * @param initialDirectory The initial directory that the file picker should open. It is optional and defaults to null. - * @return The picked file(s) or directory as defined by the mode. - */ - public suspend fun pick( + public suspend fun pickFile( + type: PickerSelectionType = PickerSelectionType.File(), mode: PickerSelectionMode, title: String? = null, initialDirectory: String? = null, ): Out? - /** - * This function is used to save a file with the provided byte data, base name, and extension. - * It is a suspend function and should be called from a coroutine or a suspend function. - * - * @param bytes The byte data to be written to the file. - * @param baseName The base name of the file without extension. It defaults to "file". - * @param extension The extension of the file. It should not include the dot. - * @param initialDirectory The initial directory that the file save dialog should open. It is optional and defaults to null. - * @return The saved file as a PlatformFile object. - */ - public suspend fun save( + public suspend fun pickDirectory( + title: String? = null, + initialDirectory: String? = null, + ): PlatformDirectory? + + public fun isDirectoryPickerSupported(): Boolean + + public suspend fun saveFile( bytes: ByteArray, baseName: String = "file", extension: String, initialDirectory: String? = null, ): PlatformFile? } + +public suspend fun Picker.pickFile( + type: PickerSelectionType = PickerSelectionType.File(), + title: String? = null, + initialDirectory: String? = null, +): PlatformFile? { + return pickFile( + type = type, + mode = PickerSelectionMode.Single, + title = title, + initialDirectory = initialDirectory, + ) +} diff --git a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.kt b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.kt index a233d10..72552f1 100644 --- a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.kt +++ b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.kt @@ -1,22 +1,17 @@ package io.github.vinceglb.picker.core -public expect sealed class PickerSelectionMode { - internal class SelectionResult +public sealed class PickerSelectionMode { + public abstract fun parseResult(value: PlatformFiles?): Out? - internal abstract fun result(selection: SelectionResult): Out? + public data object Single : PickerSelectionMode() { + override fun parseResult(value: PlatformFiles?): PlatformFile? { + return value?.firstOrNull() + } + } - public class SingleFile( - extensions: List? = null - ) : PickerSelectionMode { - override fun result(selection: SelectionResult): PlatformFile? - } - - public class MultipleFiles( - extensions: List? = null - ) : PickerSelectionMode - - @Suppress("ConvertObjectToDataObject") - public object Directory : PickerSelectionMode { - public val isSupported: Boolean - } + public data object Multiple : PickerSelectionMode() { + override fun parseResult(value: PlatformFiles?): PlatformFiles? { + return value?.takeIf { it.isNotEmpty() } + } + } } diff --git a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionType.kt b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionType.kt new file mode 100644 index 0000000..9690d12 --- /dev/null +++ b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionType.kt @@ -0,0 +1,10 @@ +package io.github.vinceglb.picker.core + +public sealed class PickerSelectionType { + public data object Image : PickerSelectionType() + public data object Video : PickerSelectionType() + public data object ImageAndVideo : PickerSelectionType() + public data class File( + val extensions: List? = null + ) : PickerSelectionType() +} \ No newline at end of file diff --git a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Utils.kt b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Utils.kt new file mode 100644 index 0000000..644e515 --- /dev/null +++ b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/Utils.kt @@ -0,0 +1,4 @@ +package io.github.vinceglb.picker.core + +internal val imageExtensions = listOf("png", "jpg", "jpeg", "gif", "bmp") +internal val videoExtensions = listOf("mp4", "mov", "avi", "mkv", "webm") diff --git a/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/Picker.ios.kt b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/Picker.ios.kt index 52cc0ea..d72b7a2 100644 --- a/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/Picker.ios.kt +++ b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/Picker.ios.kt @@ -1,11 +1,17 @@ package io.github.vinceglb.picker.core -import io.github.vinceglb.picker.core.util.PickerDelegate +import io.github.vinceglb.picker.core.util.DocumentPickerDelegate +import io.github.vinceglb.picker.core.util.PhPickerDelegate import platform.Foundation.NSFileManager import platform.Foundation.NSURL import platform.Foundation.fileURLWithPathComponents import platform.Foundation.pathComponents import platform.Foundation.temporaryDirectory +import platform.Photos.PHPhotoLibrary.Companion.sharedPhotoLibrary +import platform.PhotosUI.PHPickerConfiguration +import platform.PhotosUI.PHPickerFilter +import platform.PhotosUI.PHPickerResult +import platform.PhotosUI.PHPickerViewController import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UISceneActivationStateForegroundActive @@ -14,61 +20,59 @@ import platform.UIKit.UIWindowScene import platform.UniformTypeIdentifiers.UTType import platform.UniformTypeIdentifiers.UTTypeContent import platform.UniformTypeIdentifiers.UTTypeFolder +import platform.UniformTypeIdentifiers.UTTypeImage +import platform.UniformTypeIdentifiers.UTTypeVideo import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine public actual object Picker { // Create a reference to the picker delegate to prevent it from being garbage collected - private lateinit var pickerDelegate: PickerDelegate + private lateinit var documentPickerDelegate: DocumentPickerDelegate + private lateinit var phPickerDelegate: PhPickerDelegate - public actual suspend fun pick( + public actual suspend fun pickFile( + type: PickerSelectionType, mode: PickerSelectionMode, title: String?, initialDirectory: String? - ): Out? = suspendCoroutine { continuation -> - // Create a picker delegate - pickerDelegate = PickerDelegate( - onFilesPicked = { urls -> - val selection = PickerSelectionMode.SelectionResult(urls) - continuation.resume(mode.result(selection)) + ): Out? = when (type) { + // Use PHPickerViewController for images and videos + is PickerSelectionType.Image, + is PickerSelectionType.Video -> callPhPicker( + isMultipleMode = mode is PickerSelectionMode.Multiple, + type = type + )?.map { PlatformFile(it) }?.let { mode.parseResult(it) } + + // Use UIDocumentPickerViewController for other types + else -> callPicker( + mode = when (mode) { + is PickerSelectionMode.Single -> Mode.Single + is PickerSelectionMode.Multiple -> Mode.Multiple }, - onPickerCancelled = { - continuation.resume(null) - } - ) - - // Create a picker controller - val pickerController = UIDocumentPickerViewController( - forOpeningContentTypes = mode.contentTypes - ) - - // Set the initial directory - initialDirectory?.let { pickerController.directoryURL = NSURL.fileURLWithPath(it) } - - // Setup the picker mode - if (mode is PickerSelectionMode.MultipleFiles) { - pickerController.allowsMultipleSelection = true - } + contentTypes = type.contentTypes, + initialDirectory = initialDirectory + )?.map { PlatformFile(it) }?.let { mode.parseResult(it) } + } - // Assign the delegate to the picker controller - pickerController.delegate = pickerDelegate + public actual suspend fun pickDirectory( + title: String?, + initialDirectory: String? + ): PlatformDirectory? = callPicker( + mode = Mode.Directory, + contentTypes = listOf(UTTypeFolder), + initialDirectory = initialDirectory + )?.firstOrNull()?.let { PlatformDirectory(it) } - // Present the picker controller - UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( - pickerController, - animated = true, - completion = null - ) - } + public actual fun isDirectoryPickerSupported(): Boolean = true - public actual suspend fun save( + public actual suspend fun saveFile( bytes: ByteArray, baseName: String, extension: String, initialDirectory: String?, ): PlatformFile? = suspendCoroutine { continuation -> // Create a picker delegate - pickerDelegate = PickerDelegate( + documentPickerDelegate = DocumentPickerDelegate( onFilesPicked = { urls -> val file = urls.firstOrNull()?.let { PlatformFile(it) } continuation.resume(file) @@ -103,7 +107,7 @@ public actual object Picker { initialDirectory?.let { pickerController.directoryURL = NSURL.fileURLWithPath(it) } // Assign the delegate to the picker controller - pickerController.delegate = pickerDelegate + pickerController.delegate = documentPickerDelegate // Present the picker controller UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( @@ -113,6 +117,93 @@ public actual object Picker { ) } + private suspend fun callPicker( + mode: Mode, + contentTypes: List, + initialDirectory: String?, + ): List? = suspendCoroutine { continuation -> + // Create a picker delegate + documentPickerDelegate = DocumentPickerDelegate( + onFilesPicked = { urls -> continuation.resume(urls) }, + onPickerCancelled = { continuation.resume(null) } + ) + + // Create a picker controller + val pickerController = UIDocumentPickerViewController(forOpeningContentTypes = contentTypes) + + // Set the initial directory + initialDirectory?.let { pickerController.directoryURL = NSURL.fileURLWithPath(it) } + + // Setup the picker mode + pickerController.allowsMultipleSelection = mode == Mode.Multiple + + // Assign the delegate to the picker controller + pickerController.delegate = documentPickerDelegate + + // Present the picker controller + UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( + pickerController, + animated = true, + completion = null + ) + } + + private suspend fun callPhPicker( + isMultipleMode: Boolean, + type: PickerSelectionType, + ): List? { + val pickerResults: List = suspendCoroutine { continuation -> + // Create a picker delegate + phPickerDelegate = PhPickerDelegate( + onFilesPicked = continuation::resume + ) + + // Define configuration + val configuration = PHPickerConfiguration(sharedPhotoLibrary()) + + // Number of medias to select + configuration.selectionLimit = if (isMultipleMode) 0 else 1 + + // Filter configuration + configuration.filter = when (type) { + is PickerSelectionType.Image -> PHPickerFilter.imagesFilter + is PickerSelectionType.Video -> PHPickerFilter.videosFilter + is PickerSelectionType.ImageAndVideo -> PHPickerFilter.anyFilterMatchingSubfilters( + listOf( + PHPickerFilter.imagesFilter, + PHPickerFilter.videosFilter + ) + ) + + else -> throw IllegalArgumentException("Unsupported type: $type") + } + + // Create a picker controller + val controller = PHPickerViewController(configuration = configuration) + controller.delegate = phPickerDelegate + + // Present the picker controller + UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( + controller, + animated = true, + completion = null + ) + } + + return pickerResults.mapNotNull { result -> + suspendCoroutine { continuation -> + result.itemProvider.loadFileRepresentationForTypeIdentifier( + typeIdentifier = when (type) { + is PickerSelectionType.Image -> UTTypeImage.identifier + is PickerSelectionType.Video -> UTTypeVideo.identifier + is PickerSelectionType.ImageAndVideo -> UTTypeContent.identifier + else -> throw IllegalArgumentException("Unsupported type: $type") + } + ) { url, _ -> continuation.resume(url) } + } + }.takeIf { it.isNotEmpty() } + } + // How to get Root view controller in Swift // https://sarunw.com/posts/how-to-get-root-view-controller/ private val UIApplication.firstKeyWindow: UIWindow? @@ -121,21 +212,22 @@ public actual object Picker { .firstOrNull { it.activationState == UISceneActivationStateForegroundActive } ?.keyWindow - private val PickerSelectionMode<*>.contentTypes: List + private val PickerSelectionType.contentTypes: List get() = when (this) { - is PickerSelectionMode.Directory -> listOf(UTTypeFolder) - - is PickerSelectionMode.SingleFile -> this.extensions - ?.mapNotNull { UTType.typeWithFilenameExtension(it) } - .ifNullOrEmpty { listOf(UTTypeContent) } - - is PickerSelectionMode.MultipleFiles -> this.extensions + is PickerSelectionType.Image -> listOf(UTTypeImage) + is PickerSelectionType.Video -> listOf(UTTypeVideo) + is PickerSelectionType.ImageAndVideo -> listOf(UTTypeImage, UTTypeVideo) + is PickerSelectionType.File -> extensions ?.mapNotNull { UTType.typeWithFilenameExtension(it) } .ifNullOrEmpty { listOf(UTTypeContent) } - - else -> throw IllegalArgumentException("Unsupported mode: $this") } private fun List?.ifNullOrEmpty(block: () -> List): List = if (this.isNullOrEmpty()) block() else this + + private enum class Mode { + Single, + Multiple, + Directory + } } diff --git a/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/DocumentPickerDelegate.kt b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/DocumentPickerDelegate.kt new file mode 100644 index 0000000..0854eb3 --- /dev/null +++ b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/DocumentPickerDelegate.kt @@ -0,0 +1,31 @@ +package io.github.vinceglb.picker.core.util + +import platform.Foundation.NSURL +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.darwin.NSObject + +internal class DocumentPickerDelegate( + private val onFilesPicked: (List) -> Unit, + private val onPickerCancelled: () -> Unit +) : NSObject(), + UIDocumentPickerDelegateProtocol { + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentAtURL: NSURL + ) { + onFilesPicked(listOf(didPickDocumentAtURL)) + } + + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*> + ) { + val res = didPickDocumentsAtURLs.mapNotNull { it as? NSURL } + onFilesPicked(res) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + onPickerCancelled() + } +} diff --git a/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/PhPickerDelegate.kt b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/PhPickerDelegate.kt new file mode 100644 index 0000000..7b50420 --- /dev/null +++ b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/PhPickerDelegate.kt @@ -0,0 +1,20 @@ +package io.github.vinceglb.picker.core.util + +import platform.PhotosUI.PHPickerResult +import platform.PhotosUI.PHPickerViewController +import platform.PhotosUI.PHPickerViewControllerDelegateProtocol +import platform.darwin.NSObject + +internal class PhPickerDelegate( + private val onFilesPicked: (List) -> Unit +) : NSObject(), + PHPickerViewControllerDelegateProtocol { + override fun picker(picker: PHPickerViewController, didFinishPicking: List<*>) { + // Dismiss the picker + picker.dismissViewControllerAnimated(true, null) + + // Map the results to PHPickerResult + val res = didFinishPicking.mapNotNull { it as? PHPickerResult } + onFilesPicked(res) + } +} diff --git a/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/PickerDelegate.kt b/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/PickerDelegate.kt deleted file mode 100644 index 0a9ac97..0000000 --- a/picker-core/src/iosMain/kotlin/io/github/vinceglb/picker/core/util/PickerDelegate.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.vinceglb.picker.core.util - -import platform.Foundation.NSURL -import platform.UIKit.UIDocumentPickerDelegateProtocol -import platform.UIKit.UIDocumentPickerViewController -import platform.darwin.NSObject - -internal class PickerDelegate( - private val onFilesPicked: (List) -> Unit, - private val onPickerCancelled: () -> Unit -) : NSObject(), - UIDocumentPickerDelegateProtocol { - override fun documentPicker( - controller: UIDocumentPickerViewController, - didPickDocumentsAtURLs: List<*> - ) { - println("documentPicker called ${didPickDocumentsAtURLs.size} files picked") - val res = didPickDocumentsAtURLs.mapNotNull { it as? NSURL } - onFilesPicked(res) - } - - override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { - println("Picker was cancelled") - onPickerCancelled() - } -} diff --git a/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/Picker.js.kt b/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/Picker.js.kt index 2f6340d..8de0324 100644 --- a/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/Picker.js.kt +++ b/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/Picker.js.kt @@ -13,7 +13,8 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine public actual object Picker { - public actual suspend fun pick( + public actual suspend fun pickFile( + type: PickerSelectionType, mode: PickerSelectionMode, title: String?, initialDirectory: String? @@ -23,7 +24,22 @@ public actual object Picker { val input = document.createElement("input") as HTMLInputElement // Configure the input element - input.configure(mode) + input.apply { + this.type = "file" + + // Set the allowed file types + when (type) { + is PickerSelectionType.Image -> accept = "image/*" + is PickerSelectionType.Video -> accept = "video/*" + is PickerSelectionType.ImageAndVideo -> accept = "image/*,video/*" + is PickerSelectionType.File -> type.extensions?.let { + accept = type.extensions.joinToString(",") { ".$it" } + } + } + + // Set the multiple attribute + multiple = mode is PickerSelectionMode.Multiple + } // Setup the change listener input.onchange = { event -> @@ -35,8 +51,8 @@ public actual object Picker { ?.asList() // Return the result - val selection = PickerSelectionMode.SelectionResult(files) - continuation.resume(mode.result(selection)) + val result = files?.map { PlatformFile(it) } + continuation.resume(mode.parseResult(result)) } catch (e: Throwable) { continuation.resumeWithException(e) } @@ -51,7 +67,16 @@ public actual object Picker { } } - public actual suspend fun save( + public actual suspend fun pickDirectory( + title: String?, + initialDirectory: String? + ): PlatformDirectory? = withContext(Dispatchers.Default) { + throw NotImplementedError("Directory selection is not supported on the web") + } + + public actual fun isDirectoryPickerSupported(): Boolean = false + + public actual suspend fun saveFile( bytes: ByteArray, baseName: String, extension: String, @@ -74,40 +99,4 @@ public actual object Picker { // Return the file PlatformFile(file) } - - private fun HTMLInputElement.configure( - mode: PickerSelectionMode<*>, - ): HTMLInputElement { - type = "file" - - when (mode) { - is PickerSelectionMode.SingleFile -> { - // Set the allowed file types - mode.extensions?.let { - accept = mode.extensions.joinToString(",") { ".$it" } - } - - // Allow only one file - multiple = false - } - - is PickerSelectionMode.MultipleFiles -> { - // Set the allowed file types - mode.extensions?.let { - accept = mode.extensions.joinToString(",") { ".$it" } - } - - // Allow multiple files - multiple = true - } - - PickerSelectionMode.Directory -> - throw NotImplementedError("Directory selection is not supported on the web") - - else -> - throw IllegalArgumentException("Unsupported mode: $mode") - } - - return this - } } diff --git a/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.js.kt b/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.js.kt deleted file mode 100644 index cc1318f..0000000 --- a/picker-core/src/jsMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.js.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.vinceglb.picker.core - -import org.w3c.files.File - -public actual sealed class PickerSelectionMode { - internal actual class SelectionResult( - val files: List? - ) - - internal actual abstract fun result(selection: SelectionResult): Out? - - public actual class SingleFile actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - actual override fun result(selection: SelectionResult): PlatformFile? { - return selection.files - ?.firstOrNull() - ?.let { PlatformFile(it) } - } - } - - public actual class MultipleFiles actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - override fun result(selection: SelectionResult): PlatformFiles? { - return selection.files - ?.takeIf { it.isNotEmpty() } - ?.map { PlatformFile(it) } - } - } - - public actual data object Directory : PickerSelectionMode() { - public actual val isSupported: Boolean = false - - override fun result(selection: SelectionResult): PlatformDirectory? { - throw NotImplementedError("Directory selection is not supported on the web") - } - } -} diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/Picker.jvm.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/Picker.jvm.kt index 7d45d17..a857fe3 100644 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/Picker.jvm.kt +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/Picker.jvm.kt @@ -2,44 +2,66 @@ package io.github.vinceglb.picker.core import io.github.vinceglb.picker.core.platform.PlatformFilePicker import io.github.vinceglb.picker.core.platform.awt.AwtFileSaver +import io.github.vinceglb.picker.core.platform.util.Platform +import io.github.vinceglb.picker.core.platform.util.PlatformUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext public actual object Picker { - public actual suspend fun pick( + public actual suspend fun pickFile( + type: PickerSelectionType, mode: PickerSelectionMode, title: String?, initialDirectory: String? ): Out? = withContext(Dispatchers.IO) { - val picker = PlatformFilePicker.current + // Filter by extension + val extensions = when (type) { + PickerSelectionType.Image -> imageExtensions + PickerSelectionType.Video -> videoExtensions + PickerSelectionType.ImageAndVideo -> imageExtensions + videoExtensions + is PickerSelectionType.File -> type.extensions + } // Open native file picker - val selection = when (mode) { - is PickerSelectionMode.SingleFile -> picker.pickFile( + val result = when (mode) { + PickerSelectionMode.Single -> PlatformFilePicker.current.pickFile( title = title, initialDirectory = initialDirectory, - fileExtensions = mode.extensions - )?.let { listOf(it) } + fileExtensions = extensions + )?.let { listOf(PlatformFile(it)) } - is PickerSelectionMode.MultipleFiles -> picker.pickFiles( + PickerSelectionMode.Multiple -> PlatformFilePicker.current.pickFiles( title = title, initialDirectory = initialDirectory, - fileExtensions = mode.extensions - ) + fileExtensions = extensions + )?.map { PlatformFile(it) } + } - is PickerSelectionMode.Directory -> picker.pickDirectory( - title = title, - initialDirectory = initialDirectory - )?.let { listOf(it) } + // Return result + mode.parseResult(result) + } - else -> throw IllegalArgumentException("Unsupported mode: $mode") - }.let { PickerSelectionMode.SelectionResult(it) } + public actual suspend fun pickDirectory( + title: String?, + initialDirectory: String? + ): PlatformDirectory? = withContext(Dispatchers.IO) { + // Open native file picker + val file = PlatformFilePicker.current.pickDirectory( + title = title, + initialDirectory = initialDirectory + ) // Return result - return@withContext mode.result(selection) + file?.let { PlatformDirectory(it) } + } + + public actual fun isDirectoryPickerSupported(): Boolean = when (PlatformUtil.current) { + Platform.MacOS -> true + Platform.Windows -> true + Platform.Linux -> false } - public actual suspend fun save( + public actual suspend fun saveFile( bytes: ByteArray, baseName: String, extension: String, diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.jvm.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.jvm.kt deleted file mode 100644 index 2d198f7..0000000 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.jvm.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.vinceglb.picker.core - -import io.github.vinceglb.picker.core.platform.util.Platform -import io.github.vinceglb.picker.core.platform.util.PlatformUtil -import java.io.File - -public actual sealed class PickerSelectionMode { - internal actual class SelectionResult( - val files: List? - ) - - internal actual abstract fun result(selection: SelectionResult): Out? - - public actual class SingleFile actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - actual override fun result(selection: SelectionResult): PlatformFile? { - return selection.files - ?.firstOrNull() - ?.let { PlatformFile(it) } - } - } - - public actual class MultipleFiles actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - override fun result(selection: SelectionResult): PlatformFiles? { - return selection.files - ?.takeIf { it.isNotEmpty() } - ?.map { PlatformFile(it) } - } - } - - public actual data object Directory : PickerSelectionMode() { - public actual val isSupported: Boolean = when (PlatformUtil.current) { - Platform.MacOS -> true - Platform.Windows -> true - Platform.Linux -> false - } - - override fun result(selection: SelectionResult): PlatformDirectory? { - return selection.files - ?.firstOrNull() - ?.let { PlatformDirectory(it) } - } - } -} diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt index e665d79..acd0da6 100644 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt @@ -1,6 +1,5 @@ package io.github.vinceglb.picker.core.platform.awt -import io.github.vinceglb.picker.core.PickerSelectionMode import io.github.vinceglb.picker.core.platform.PlatformFilePicker import kotlinx.coroutines.suspendCancellableCoroutine import java.awt.FileDialog @@ -15,54 +14,48 @@ internal class AwtFilePicker : PlatformFilePicker { fileExtensions: List?, title: String? ): File? = callAwtPicker( - mode = PickerSelectionMode.SingleFile(fileExtensions), title = title, + isMultipleMode = false, + fileExtensions = fileExtensions, initialDirectory = initialDirectory - )?.file + )?.firstOrNull() override suspend fun pickFiles( initialDirectory: String?, fileExtensions: List?, title: String? ): List? = callAwtPicker( - mode = PickerSelectionMode.MultipleFiles(fileExtensions), title = title, + isMultipleMode = true, + fileExtensions = fileExtensions, initialDirectory = initialDirectory - )?.map { it.file } + ) override fun pickDirectory(initialDirectory: String?, title: String?): File? { throw UnsupportedOperationException("Directory picker is not supported on Linux yet.") } - private suspend fun callAwtPicker( - mode: PickerSelectionMode, + private suspend fun callAwtPicker( title: String?, + isMultipleMode: Boolean, initialDirectory: String?, - ): T? = suspendCancellableCoroutine { continuation -> + fileExtensions: List?, + ): List? = suspendCancellableCoroutine { continuation -> val parent: Frame? = null val dialog = object : FileDialog(parent, title, LOAD) { override fun setVisible(value: Boolean) { super.setVisible(value) - val files: List? = files?.toList() - val selection = PickerSelectionMode.SelectionResult(files) - continuation.resume(mode.result(selection)) + val result = files?.toList() + continuation.resume(result) } } // Set multiple mode - dialog.isMultipleMode = mode is PickerSelectionMode.MultipleFiles + dialog.isMultipleMode = isMultipleMode // Set mime types - dialog.filenameFilter = FilenameFilter { dir, name -> - when (mode) { - is PickerSelectionMode.SingleFile -> mode.extensions?.any { name.endsWith(it) } - ?: true - - is PickerSelectionMode.MultipleFiles -> mode.extensions?.any { name.endsWith(it) } - ?: true - - else -> throw IllegalArgumentException("Unsupported mode: $mode") - } + dialog.filenameFilter = FilenameFilter { _, name -> + fileExtensions?.any { name.endsWith(it) } ?: true } // Set initial directory diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/JnaFileChooser.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/JnaFileChooser.kt index c438333..146bf91 100644 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/JnaFileChooser.kt +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/JnaFileChooser.kt @@ -284,9 +284,9 @@ internal class JnaFileChooser } /** - * set a save button name + * set a saveFile button name * - * @param save button text + * @param saveFile button text */ fun setSaveButtonText(buttonText: String) { this.saveButtonText = buttonText diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/WindowsFileChooser.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/WindowsFileChooser.kt index 53298c3..6452fd6 100644 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/WindowsFileChooser.kt +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/windows/api/WindowsFileChooser.kt @@ -180,7 +180,7 @@ internal class WindowsFileChooser { * shows the dialog * * @param parent the parent window - * @param open whether to show the open dialog, if false save dialog is shown + * @param open whether to show the open dialog, if false saveFile dialog is shown * * @return true if the user clicked ok, false otherwise */ diff --git a/picker-core/src/macosMain/kotlin/io/github/vinceglb/picker/core/Picker.macos.kt b/picker-core/src/macosMain/kotlin/io/github/vinceglb/picker/core/Picker.macos.kt index 821093d..151fef3 100644 --- a/picker-core/src/macosMain/kotlin/io/github/vinceglb/picker/core/Picker.macos.kt +++ b/picker-core/src/macosMain/kotlin/io/github/vinceglb/picker/core/Picker.macos.kt @@ -7,32 +7,39 @@ import platform.AppKit.allowedFileTypes import platform.Foundation.NSURL public actual object Picker { - public actual suspend fun pick( + public actual suspend fun pickFile( + type: PickerSelectionType, mode: PickerSelectionMode, + title: String?, + initialDirectory: String?, + ): Out? = callPicker( + mode = when (mode) { + is PickerSelectionMode.Single -> Mode.Single + is PickerSelectionMode.Multiple -> Mode.Multiple + }, + title = title, + initialDirectory = initialDirectory, + fileExtensions = when (type) { + PickerSelectionType.Image -> imageExtensions + PickerSelectionType.Video -> videoExtensions + PickerSelectionType.ImageAndVideo -> imageExtensions + videoExtensions + is PickerSelectionType.File -> type.extensions + }, + )?.map { PlatformFile(it) }?.let { mode.parseResult(it) } + + public actual suspend fun pickDirectory( title: String?, initialDirectory: String? - ): Out? { - // Create an NSOpenPanel - val nsOpenPanel = NSOpenPanel() - - // Configure the NSOpenPanel - nsOpenPanel.configure(mode, title, initialDirectory) - - // Run the NSOpenPanel - val result = nsOpenPanel.runModal() - - // If the user cancelled the operation, return null - if (result != NSModalResponseOK) { - return null - } + ): PlatformDirectory? = callPicker( + mode = Mode.Directory, + title = title, + initialDirectory = initialDirectory, + fileExtensions = null + )?.firstOrNull()?.let { PlatformDirectory(it) } - // Return the result - val urls = nsOpenPanel.URLs.mapNotNull { it as? NSURL } - val selection = PickerSelectionMode.SelectionResult(urls) - return mode.result(selection) - } + public actual fun isDirectoryPickerSupported(): Boolean = true - public actual suspend fun save( + public actual suspend fun saveFile( bytes: ByteArray, baseName: String, extension: String, @@ -71,9 +78,34 @@ public actual object Picker { return platformFile } + private fun callPicker( + mode: Mode, + title: String?, + initialDirectory: String?, + fileExtensions: List?, + ): List? { + // Create an NSOpenPanel + val nsOpenPanel = NSOpenPanel() + + // Configure the NSOpenPanel + nsOpenPanel.configure(mode, title, fileExtensions, initialDirectory) + + // Run the NSOpenPanel + val result = nsOpenPanel.runModal() + + // If the user cancelled the operation, return null + if (result != NSModalResponseOK) { + return null + } + + // Return the result + return nsOpenPanel.URLs.mapNotNull { it as? NSURL } + } + private fun NSOpenPanel.configure( - mode: PickerSelectionMode<*>, + mode: Mode, title: String?, + extensions: List?, initialDirectory: String?, ): NSOpenPanel { // Set the title @@ -82,35 +114,36 @@ public actual object Picker { // Set the initial directory initialDirectory?.let { directoryURL = NSURL.fileURLWithPath(it) } + // Set the allowed file types + extensions?.let { allowedFileTypes = extensions } + // Setup the picker mode and files extensions when (mode) { - is PickerSelectionMode.SingleFile -> { + Mode.Single -> { canChooseFiles = true canChooseDirectories = false allowsMultipleSelection = false - - // Set the allowed file types - mode.extensions?.let { allowedFileTypes = mode.extensions } } - is PickerSelectionMode.MultipleFiles -> { + Mode.Multiple -> { canChooseFiles = true canChooseDirectories = false allowsMultipleSelection = true - - // Set the allowed file types - mode.extensions?.let { allowedFileTypes = mode.extensions } } - is PickerSelectionMode.Directory -> { + Mode.Directory -> { canChooseFiles = false canChooseDirectories = true allowsMultipleSelection = false } - - else -> throw IllegalArgumentException("Unsupported mode: $mode") } return this } + + private enum class Mode { + Single, + Multiple, + Directory + } } diff --git a/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/Picker.wasmJs.kt b/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/Picker.wasmJs.kt index 6e2bb36..a60e6f5 100644 --- a/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/Picker.wasmJs.kt +++ b/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/Picker.wasmJs.kt @@ -15,7 +15,8 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine public actual object Picker { - public actual suspend fun pick( + public actual suspend fun pickFile( + type: PickerSelectionType, mode: PickerSelectionMode, title: String?, initialDirectory: String? @@ -25,7 +26,22 @@ public actual object Picker { val input = document.createElement("input") as HTMLInputElement // Configure the input element - input.configure(mode) + input.apply { + this.type = "file" + + // Set the allowed file types + when (type) { + is PickerSelectionType.Image -> accept = "image/*" + is PickerSelectionType.Video -> accept = "video/*" + is PickerSelectionType.ImageAndVideo -> accept = "image/*,video/*" + is PickerSelectionType.File -> type.extensions?.let { + accept = type.extensions.joinToString(",") { ".$it" } + } + } + + // Set the multiple attribute + multiple = mode is PickerSelectionMode.Multiple + } // Setup the change listener input.onchange = { event -> @@ -37,8 +53,8 @@ public actual object Picker { ?.asList() // Return the result - val selection = PickerSelectionMode.SelectionResult(files) - continuation.resume(mode.result(selection)) + val result = files?.map { PlatformFile(it) } + continuation.resume(mode.parseResult(result)) } catch (e: Throwable) { continuation.resumeWithException(e) } @@ -53,7 +69,16 @@ public actual object Picker { } } - public actual suspend fun save( + public actual suspend fun pickDirectory( + title: String?, + initialDirectory: String? + ): PlatformDirectory? = withContext(Dispatchers.Default) { + throw NotImplementedError("Directory selection is not supported on the web") + } + + public actual fun isDirectoryPickerSupported(): Boolean = false + + public actual suspend fun saveFile( bytes: ByteArray, baseName: String, extension: String, @@ -86,40 +111,4 @@ public actual object Picker { // Return the file PlatformFile(file) } - - private fun HTMLInputElement.configure( - mode: PickerSelectionMode<*>, - ): HTMLInputElement { - type = "file" - - when (mode) { - is PickerSelectionMode.SingleFile -> { - // Set the allowed file types - mode.extensions?.let { - accept = mode.extensions.joinToString(",") { ".$it" } - } - - // Allow only one file - multiple = false - } - - is PickerSelectionMode.MultipleFiles -> { - // Set the allowed file types - mode.extensions?.let { - accept = mode.extensions.joinToString(",") { ".$it" } - } - - // Allow multiple files - multiple = true - } - - PickerSelectionMode.Directory -> - throw NotImplementedError("Directory selection is not supported on the web") - - else -> - throw IllegalArgumentException("Unsupported mode: $mode") - } - - return this - } } diff --git a/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.wasmJs.kt b/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.wasmJs.kt deleted file mode 100644 index c914438..0000000 --- a/picker-core/src/wasmJsMain/kotlin/io/github/vinceglb/picker/core/PickerSelectionMode.wasmJs.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.vinceglb.picker.core - -import org.w3c.files.File - -public actual sealed class PickerSelectionMode { - internal actual class SelectionResult( - val files: List? - ) - - internal actual abstract fun result(selection: SelectionResult): Out? - - public actual class SingleFile actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - actual override fun result(selection: SelectionResult): PlatformFile? { - return selection.files - ?.firstOrNull() - ?.let { PlatformFile(it) } - } - } - - public actual class MultipleFiles actual constructor( - public val extensions: List? - ) : PickerSelectionMode() { - override fun result(selection: SelectionResult): PlatformFiles? { - return selection.files - ?.takeIf { it.isNotEmpty() } - ?.map { PlatformFile(it) } - } - } - - public actual data object Directory : PickerSelectionMode() { - public actual val isSupported: Boolean = false - - override fun result(selection: SelectionResult): PlatformDirectory? { - throw NotImplementedError("Directory selection is not supported on the web") - } - } -} diff --git a/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt b/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt index 7044050..e094717 100644 --- a/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt +++ b/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt @@ -19,9 +19,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import io.github.vinceglb.picker.compose.rememberPickerLauncher -import io.github.vinceglb.picker.compose.rememberSaverLauncher +import io.github.vinceglb.picker.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.picker.compose.rememberFilePickerLauncher +import io.github.vinceglb.picker.compose.rememberFileSaverLauncher +import io.github.vinceglb.picker.core.Picker import io.github.vinceglb.picker.core.PickerSelectionMode +import io.github.vinceglb.picker.core.PickerSelectionType import io.github.vinceglb.picker.core.PlatformDirectory import io.github.vinceglb.picker.core.PlatformFile import io.github.vinceglb.picker.core.baseName @@ -42,31 +45,46 @@ private fun SampleApp() { var files: Set by remember { mutableStateOf(emptySet()) } var directory: PlatformDirectory? by remember { mutableStateOf(null) } - val singleFilePicker = rememberPickerLauncher( - mode = PickerSelectionMode.SingleFile(extensions = listOf("png", "jpg", "jpeg")), + val singleFilePicker = rememberFilePickerLauncher( + type = PickerSelectionType.Image, title = "Single file picker", initialDirectory = directory?.path, onResult = { file -> file?.let { files += it } } ) - val multipleFilesPicker = rememberPickerLauncher( - mode = PickerSelectionMode.MultipleFiles(extensions = listOf("png", "jpg", "jpeg")), + val multipleFilesPicker = rememberFilePickerLauncher( + type = PickerSelectionType.Image, + mode = PickerSelectionMode.Multiple, title = "Multiple files picker", initialDirectory = directory?.path, onResult = { file -> file?.let { files += it } } ) - val directoryPicker = rememberPickerLauncher( - mode = PickerSelectionMode.Directory, - title = "Directory picker", + val filePicker = rememberFilePickerLauncher( + type = PickerSelectionType.File(listOf("png")), + title = "Single file picker, only png", initialDirectory = directory?.path, - onResult = { dir -> directory = dir } + onResult = { file -> file?.let { files += it } } ) - val saver = rememberSaverLauncher( + val filesPicker = rememberFilePickerLauncher( + type = PickerSelectionType.File(listOf("png")), + mode = PickerSelectionMode.Multiple, + title = "Multiple files picker, only png", + initialDirectory = directory?.path, onResult = { file -> file?.let { files += it } } ) + val directoryPicker = rememberDirectoryPickerLauncher( + title = "Directory picker", + initialDirectory = directory?.path, + onResult = { dir -> directory = dir } + ) + + val saver = rememberFileSaverLauncher { file -> + file?.let { files += it } + } + val scope = rememberCoroutineScope() fun saveFile(file: PlatformFile) { scope.launch { @@ -94,14 +112,22 @@ private fun SampleApp() { Text("Multiple files picker") } + Button(onClick = { filePicker.launch() }) { + Text("Single file picker, only png") + } + + Button(onClick = { filesPicker.launch() }){ + Text("Multiple files picker, only png") + } + Button( onClick = { directoryPicker.launch() }, - enabled = PickerSelectionMode.Directory.isSupported + enabled = Picker.isDirectoryPickerSupported() ) { Text("Directory picker") } - if (PickerSelectionMode.Directory.isSupported) { + if (Picker.isDirectoryPickerSupported()) { Text("Selected directory: ${directory?.path ?: "None"}") } else { Text("Directory picker is not supported") diff --git a/samples/sample-core/appleApps/iOSApp/ContentView.swift b/samples/sample-core/appleApps/iOSApp/ContentView.swift index 861d436..8a358f7 100644 --- a/samples/sample-core/appleApps/iOSApp/ContentView.swift +++ b/samples/sample-core/appleApps/iOSApp/ContentView.swift @@ -32,6 +32,14 @@ struct ContentView: View { viewModel.pickImages() } + Button("Single file picker, only png") { + viewModel.pickFile() + } + + Button("Multiple file picker, only png") { + viewModel.pickFiles() + } + Button("Directory picker") { viewModel.pickDirectory() } diff --git a/samples/sample-core/appleApps/macOSApp/ContentView.swift b/samples/sample-core/appleApps/macOSApp/ContentView.swift index e5f3803..dd843af 100644 --- a/samples/sample-core/appleApps/macOSApp/ContentView.swift +++ b/samples/sample-core/appleApps/macOSApp/ContentView.swift @@ -32,6 +32,14 @@ struct ContentView: View { viewModel.pickImages() } + Button("Single file picker, only png") { + viewModel.pickFile() + } + + Button("Multiple file picker, only png") { + viewModel.pickFiles() + } + Button("Directory picker") { viewModel.pickDirectory() } diff --git a/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/App.kt b/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/App.kt index 6bd26f8..b1bb44a 100644 --- a/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/App.kt +++ b/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/App.kt @@ -19,7 +19,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import io.github.vinceglb.picker.core.PickerSelectionMode +import io.github.vinceglb.picker.core.Picker import io.github.vinceglb.sample.core.MainViewModel import org.koin.compose.KoinApplication import org.koin.compose.koinInject @@ -57,9 +57,17 @@ private fun SampleApp(viewModel: MainViewModel = koinInject()) { Text("Multiple image picker") } + Button(onClick = viewModel::pickFile) { + Text("Single file picker, only png") + } + + Button(onClick = viewModel::pickFiles) { + Text("Multiple files picker, only png") + } + Button( onClick = viewModel::pickDirectory, - enabled = PickerSelectionMode.Directory.isSupported + enabled = Picker.isDirectoryPickerSupported(), ) { Text("Directory picker") } @@ -68,7 +76,7 @@ private fun SampleApp(viewModel: MainViewModel = koinInject()) { CircularProgressIndicator() } - if (PickerSelectionMode.Directory.isSupported) { + if (Picker.isDirectoryPickerSupported()) { Text("Selected directory: ${uiState.directory?.path ?: "None"}") } else { Text("Directory picker is not supported") diff --git a/samples/sample-core/shared/src/commonMain/kotlin/io/github/vinceglb/sample/core/MainViewModel.kt b/samples/sample-core/shared/src/commonMain/kotlin/io/github/vinceglb/sample/core/MainViewModel.kt index e129764..3bccbd7 100644 --- a/samples/sample-core/shared/src/commonMain/kotlin/io/github/vinceglb/sample/core/MainViewModel.kt +++ b/samples/sample-core/shared/src/commonMain/kotlin/io/github/vinceglb/sample/core/MainViewModel.kt @@ -2,12 +2,14 @@ package io.github.vinceglb.sample.core import com.rickclephas.kmm.viewmodel.KMMViewModel import com.rickclephas.kmm.viewmodel.coroutineScope -import io.github.vinceglb.picker.core.Picker import io.github.vinceglb.picker.core.PickerSelectionMode +import io.github.vinceglb.picker.core.PickerSelectionType +import io.github.vinceglb.picker.core.Picker import io.github.vinceglb.picker.core.PlatformDirectory import io.github.vinceglb.picker.core.PlatformFile -import io.github.vinceglb.picker.core.extension import io.github.vinceglb.picker.core.baseName +import io.github.vinceglb.picker.core.extension +import io.github.vinceglb.picker.core.pickFile import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -18,12 +20,9 @@ class MainViewModel : KMMViewModel() { val uiState: StateFlow = _uiState fun pickImage() = executeWithLoading { - // Single file mode - val mode = PickerSelectionMode.SingleFile(extensions = listOf("jpg", "jpeg", "png")) - // Pick a file - val file = Picker.pick( - mode = mode, + val file = Picker.pickFile( + type = PickerSelectionType.Image, title = "Custom title here", initialDirectory = downloadDirectoryPath() ) @@ -36,11 +35,11 @@ class MainViewModel : KMMViewModel() { } fun pickImages() = executeWithLoading { - // Multiple files mode - val mode = PickerSelectionMode.MultipleFiles(extensions = listOf("jpg", "jpeg", "png")) - // Pick files - val files = Picker.pick(mode = mode) + val files = Picker.pickFile( + type = PickerSelectionType.Image, + mode = PickerSelectionMode.Multiple + ) // Add files to the state if (files != null) { @@ -50,12 +49,36 @@ class MainViewModel : KMMViewModel() { } } - fun pickDirectory() = executeWithLoading { - // Directory mode - val mode = PickerSelectionMode.Directory + fun pickFile() = executeWithLoading { + // Pick a file + val file = Picker.pickFile( + type = PickerSelectionType.File(extensions = listOf("png")), + ) + + // Add file to the state + if (file != null) { + val newFiles = _uiState.value.files + file + _uiState.update { it.copy(files = newFiles) } + } + } + + fun pickFiles() = executeWithLoading { + // Pick files + val files = Picker.pickFile( + type = PickerSelectionType.File(extensions = listOf("png")), + mode = PickerSelectionMode.Multiple + ) + + // Add files to the state + if (files != null) { + val newFiles = _uiState.value.files + files + _uiState.update { it.copy(files = newFiles) } + } + } + fun pickDirectory() = executeWithLoading { // Pick a directory - val directory = Picker.pick(mode) + val directory = Picker.pickDirectory() // Update the state if (directory != null) { @@ -65,7 +88,7 @@ class MainViewModel : KMMViewModel() { fun saveFile(file: PlatformFile) = executeWithLoading { // Save a file - val newFile = Picker.save( + val newFile = Picker.saveFile( bytes = file.readBytes(), baseName = file.baseName, extension = file.extension @@ -92,6 +115,7 @@ data class MainUiState( val directory: PlatformDirectory? = null, val loading: Boolean = false ) { + // Used by SwiftUI code constructor() : this(emptySet(), null, false) }