diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9b5c7e..81ff480 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.3.2" +agp = "8.4.0" android-activity-ktx = "1.9.0" android-compose = "1.9.0" coilCompose = "3.0.0-alpha06" diff --git a/picker-compose/build.gradle.kts b/picker-compose/build.gradle.kts index 6399391..cca0de7 100644 --- a/picker-compose/build.gradle.kts +++ b/picker-compose/build.gradle.kts @@ -57,6 +57,9 @@ kotlin { // Compose implementation(compose.runtime) + // Coroutines + implementation(libs.kotlinx.coroutines.core) + // Picker Core api(projects.pickerCore) } @@ -67,10 +70,6 @@ kotlin { val nonAndroidMain by creating { dependsOn(commonMain.get()) - - dependencies { - implementation(libs.kotlinx.coroutines.core) - } } nativeMain.get().dependsOn(nonAndroidMain) diff --git a/picker-compose/src/androidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.android.kt b/picker-compose/src/androidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.android.kt index 42fe978..26864b9 100644 --- a/picker-compose/src/androidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.android.kt +++ b/picker-compose/src/androidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.android.kt @@ -1,96 +1,15 @@ package io.github.vinceglb.picker.compose -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree -import androidx.activity.result.contract.ActivityResultContracts.OpenMultipleDocuments +import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import io.github.vinceglb.picker.core.Picker -import io.github.vinceglb.picker.core.PickerSelectionMode -import io.github.vinceglb.picker.core.PickerSelectionMode.Directory -import io.github.vinceglb.picker.core.PickerSelectionMode.MultipleFiles -import io.github.vinceglb.picker.core.PickerSelectionMode.SingleFile -import io.github.vinceglb.picker.core.PlatformDirectory -import io.github.vinceglb.picker.core.PlatformFile @Composable -public actual fun rememberPickerLauncher( - mode: PickerSelectionMode, - title: String?, - initialDirectory: String?, - onResult: (Out?) -> Unit, -): PickerResultLauncher { - // Get context - val context = LocalContext.current - - // Keep track of the current mode, initialDirectory and onResult listener - val currentMode by rememberUpdatedState(mode) - val currentInitialDirectory by rememberUpdatedState(initialDirectory) - val currentOnResult by rememberUpdatedState(onResult) - - // Create Picker launcher based on mode - val launcher = when (val currentModeValue = currentMode) { - is SingleFile -> { - // Create Android launcher - @Suppress("UNCHECKED_CAST") - val launcher = rememberLauncherForActivityResult(OpenDocument()) { uri -> - val platformFile = uri?.let { PlatformFile(it, context) } - currentOnResult(platformFile as Out?) - } - - remember { - // Get mime types - val mimeTypes = Picker.getMimeType(currentModeValue.extensions) - - // Return Picker launcher - PickerResultLauncher { launcher.launch(mimeTypes) } - } - } - - is MultipleFiles -> { - // Create Android launcher - @Suppress("UNCHECKED_CAST") - val launcher = rememberLauncherForActivityResult(OpenMultipleDocuments()) { uris -> - val platformFiles = uris - .takeIf { it.isNotEmpty() } - ?.map { uri -> PlatformFile(uri, context) } - - currentOnResult(platformFiles as Out?) - } - - remember { - // Get mime types - val mimeTypes = Picker.getMimeType(currentModeValue.extensions) - - // Return Picker launcher - PickerResultLauncher { launcher.launch(mimeTypes) } - } - } - - is Directory -> { - // Create Android launcher - @Suppress("UNCHECKED_CAST") - val launcher = rememberLauncherForActivityResult(OpenDocumentTree()) { uri -> - val platformDirectory = uri?.let { PlatformDirectory(it) } - currentOnResult(platformDirectory as Out?) - } - - remember { - // Convert initialDirectory to Uri - val initialPath = currentInitialDirectory?.let { Uri.parse(it) } - - // Return Picker launcher - PickerResultLauncher { launcher.launch(initialPath) } - } - } - - else -> throw IllegalArgumentException("Unsupported mode: $currentModeValue") +internal actual fun InitPicker() { + val componentActivity = LocalContext.current as ComponentActivity + LaunchedEffect(Unit) { + Picker.init(componentActivity) } - - return launcher } 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 4701b61..6afd50d 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 @@ -1,12 +1,87 @@ package io.github.vinceglb.picker.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.PlatformFile +import kotlinx.coroutines.launch @Composable -public expect fun rememberPickerLauncher( +public fun rememberPickerLauncher( mode: PickerSelectionMode, title: String? = null, initialDirectory: String? = null, onResult: (Out?) -> Unit, -): PickerResultLauncher +): PickerResultLauncher { + // Init picker + InitPicker() + + // Coroutine + val coroutineScope = rememberCoroutineScope() + + // Updated state + val currentMode by rememberUpdatedState(mode) + 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.pick( + mode = currentMode, + title = currentTitle, + initialDirectory = currentInitialDirectory, + ) + currentOnResult(result) + } + } + } + + return returnedLauncher +} + +@Composable +public fun rememberSaverLauncher( + onResult: (PlatformFile?) -> Unit +): SaverResultLauncher { + // Init picker + InitPicker() + + // Coroutine + val coroutineScope = rememberCoroutineScope() + + // Updated state + val currentOnResult by rememberUpdatedState(onResult) + + // Picker + val picker = remember { Picker } + + // Picker launcher + val returnedLauncher = remember { + SaverResultLauncher { bytes, baseName, extension, initialDirectory -> + coroutineScope.launch { + val result = picker.save( + bytes = bytes, + baseName = baseName, + extension = extension, + initialDirectory = initialDirectory, + ) + currentOnResult(result) + } + } + } + + return returnedLauncher +} + +@Composable +internal expect fun InitPicker() diff --git a/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerResultLauncher.kt b/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerResultLauncher.kt index 4d14346..c305195 100644 --- a/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerResultLauncher.kt +++ b/picker-compose/src/commonMain/kotlin/io/github/vinceglb/picker/compose/PickerResultLauncher.kt @@ -7,3 +7,21 @@ public class PickerResultLauncher( onLaunch() } } + +public class SaverResultLauncher( + private val onLaunch: ( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ) -> Unit, +) { + public fun launch( + bytes: ByteArray, + baseName: String = "file", + extension: String, + initialDirectory: String? = null, + ) { + onLaunch(bytes, baseName, extension, initialDirectory) + } +} diff --git a/picker-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.nonAndroid.kt b/picker-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.nonAndroid.kt index 548a966..c2fe173 100644 --- a/picker-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.nonAndroid.kt +++ b/picker-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/picker/compose/PickerCompose.nonAndroid.kt @@ -1,46 +1,6 @@ package io.github.vinceglb.picker.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -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 kotlinx.coroutines.launch @Composable -public actual fun rememberPickerLauncher( - mode: PickerSelectionMode, - title: String?, - initialDirectory: String?, - onResult: (Out?) -> Unit -): PickerResultLauncher { - // Coroutine - val coroutineScope = rememberCoroutineScope() - - // Updated state - val currentMode by rememberUpdatedState(mode) - 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.pick( - mode = currentMode, - title = currentTitle, - initialDirectory = currentInitialDirectory, - ) - currentOnResult(result) - } - } - } - - return returnedLauncher -} +internal actual fun InitPicker() {} 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 16f2ff0..81d9958 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 @@ -15,72 +15,116 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine public actual object Picker { - private var registry: ActivityResultRegistry? = null - internal var context: WeakReference = WeakReference(null) - private set - - public fun init(activity: ComponentActivity) { - context = WeakReference(activity.applicationContext) - registry = activity.activityResultRegistry - } - - public actual suspend fun pick( - mode: PickerSelectionMode, - title: String?, - initialDirectory: String?, - ): Out? = 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() - - // 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) } - )) - } - launcher.launch(getMimeType(mode.extensions)) - } - - is PickerSelectionMode.MultipleFiles -> { - val contract = ActivityResultContracts.OpenMultipleDocuments() - val launcher = registry.register(key, contract) { uris -> - continuation.resume(SelectionResult(files = uris)) - } - launcher.launch(getMimeType(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) - } - - else -> throw IllegalArgumentException("Unsupported mode: $mode") - } - } - - // Return result - return@withContext mode.result(selection) - } - - public fun getMimeType(fileExtensions: List?): Array { - val mimeTypeMap = MimeTypeMap.getSingleton() - return fileExtensions - ?.takeIf { it.isNotEmpty() } - ?.mapNotNull { mimeTypeMap.getMimeTypeFromExtension(it) } - ?.toTypedArray() - ?: arrayOf("*/*") - } + private var registry: ActivityResultRegistry? = null + internal var context: WeakReference = WeakReference(null) + private set + + public fun init(activity: ComponentActivity) { + context = WeakReference(activity.applicationContext) + registry = activity.activityResultRegistry + } + + public actual suspend fun pick( + mode: PickerSelectionMode, + title: String?, + initialDirectory: String?, + ): Out? = 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() + + // 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) } + )) + } + launcher.launch(getMimeTypes(mode.extensions)) + } + + is PickerSelectionMode.MultipleFiles -> { + val contract = ActivityResultContracts.OpenMultipleDocuments() + val launcher = registry.register(key, contract) { uris -> + continuation.resume(SelectionResult(files = uris)) + } + 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) + } + + else -> throw IllegalArgumentException("Unsupported mode: $mode") + } + } + + // Return result + return@withContext mode.result(selection) + } + + public actual suspend fun save( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String? + ): PlatformFile? = withContext(Dispatchers.IO) { + suspendCoroutine { continuation -> + // 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() + + // Get context + val context = Picker.context.get() + ?: throw PickerNotInitializedException() + + // Get MIME type + val mimeType = getMimeType(extension) + + // Create Launcher + val contract = ActivityResultContracts.CreateDocument(mimeType) + val launcher = registry.register(key, contract) { uri -> + val platformFile = uri?.let { + // Write the bytes to the file + context.contentResolver.openOutputStream(it)?.use { output -> + output.write(bytes) + } + + PlatformFile(it, context) + } + continuation.resume(platformFile) + } + + // Launch + launcher.launch("$baseName.$extension") + } + } + + public fun getMimeTypes(fileExtensions: List?): Array { + val mimeTypeMap = MimeTypeMap.getSingleton() + return fileExtensions + ?.takeIf { it.isNotEmpty() } + ?.mapNotNull { mimeTypeMap.getMimeTypeFromExtension(it) } + ?.toTypedArray() + ?: arrayOf("*/*") + } + + public 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 index 26dfcb6..0b9fed4 100644 --- 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 @@ -3,11 +3,11 @@ package io.github.vinceglb.picker.core import android.net.Uri public actual sealed class PickerSelectionMode { - public actual class SelectionResult( - public val files: List? + internal actual class SelectionResult( + val files: List? ) - public actual abstract fun result(selection: SelectionResult): Out? + internal actual abstract fun result(selection: SelectionResult): Out? public actual class SingleFile actual constructor( public val extensions: List? diff --git a/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.android.kt b/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.android.kt index 97c18d9..e33b8da 100644 --- a/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.android.kt +++ b/picker-core/src/androidMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.android.kt @@ -9,41 +9,41 @@ import kotlinx.coroutines.withContext import java.io.File public actual data class PlatformFile( - val uri: Uri, - private val context: Context, + val uri: Uri, + private val context: Context, ) { - public actual val name: String by lazy { - context.getFileName(uri) ?: throw IllegalStateException("Failed to get file name") - } + public actual val name: String by lazy { + context.getFileName(uri) ?: throw IllegalStateException("Failed to get file name") + } - public actual val path: String? = - uri.path + public actual val path: String? = + uri.path - public actual suspend fun readBytes(): ByteArray = withContext(Dispatchers.IO) { - context - .contentResolver - .openInputStream(uri) - .use { stream -> stream?.readBytes() } - ?: throw IllegalStateException("Failed to read file") - } + public actual suspend fun readBytes(): ByteArray = withContext(Dispatchers.IO) { + context + .contentResolver + .openInputStream(uri) + .use { stream -> stream?.readBytes() } + ?: throw IllegalStateException("Failed to read file") + } - private fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { - ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) - else -> uri.path?.let(::File)?.name - } + private fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) + else -> uri.path?.let(::File)?.name + } - private fun Context.getContentFileName(uri: Uri): String? = runCatching { - contentResolver.query(uri, null, null, null, null)?.use { cursor -> - cursor.moveToFirst() - return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) - .let(cursor::getString) - } - }.getOrNull() + private fun Context.getContentFileName(uri: Uri): String? = runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + .let(cursor::getString) + } + }.getOrNull() } public actual data class PlatformDirectory( - val uri: Uri, + val uri: Uri, ) { - public actual val path: String? = - uri.path + public actual val path: String? = + uri.path } 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 index 93324a9..152e673 100644 --- 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 @@ -3,11 +3,11 @@ package io.github.vinceglb.picker.core import platform.Foundation.NSURL public actual sealed class PickerSelectionMode { - public actual class SelectionResult( - public val nsUrls: List + internal actual class SelectionResult( + val nsUrls: List ) - public actual abstract fun result(selection: SelectionResult): Out? + internal actual abstract fun result(selection: SelectionResult): Out? public actual class SingleFile actual constructor( public val extensions: List? diff --git a/picker-core/src/appleMain/kotlin/io/github/vinceglb/picker/core/Utils.apple.kt b/picker-core/src/appleMain/kotlin/io/github/vinceglb/picker/core/Utils.apple.kt new file mode 100644 index 0000000..ce8edb4 --- /dev/null +++ b/picker-core/src/appleMain/kotlin/io/github/vinceglb/picker/core/Utils.apple.kt @@ -0,0 +1,23 @@ +package io.github.vinceglb.picker.core + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.refTo +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithBytes +import platform.Foundation.writeToURL + +@OptIn(ExperimentalForeignApi::class) +internal fun writeBytesArrayToNsUrl(bytes: ByteArray, nsUrl: NSURL) { + // Get the NSData from the ByteArray + val nsData = memScoped { + NSData.dataWithBytes( + bytes = bytes.refTo(0).getPointer(this), + length = bytes.size.toULong() + ) + } + + // Write the NSData to the NSURL + nsData.writeToURL(nsUrl, true) +} 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 de7f322..ae21103 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,9 +1,35 @@ package io.github.vinceglb.picker.core public expect object Picker { - public suspend fun pick( - mode: PickerSelectionMode, - title: String? = null, - initialDirectory: String? = null, - ): Out? + /** + * 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( + 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( + bytes: ByteArray, + baseName: String = "file", + extension: String, + initialDirectory: String? = null, + ): PlatformFile? } 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 2ec7aa2..a233d10 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,9 +1,9 @@ package io.github.vinceglb.picker.core public expect sealed class PickerSelectionMode { - public class SelectionResult + internal class SelectionResult - public abstract fun result(selection: SelectionResult): Out? + internal abstract fun result(selection: SelectionResult): Out? public class SingleFile( extensions: List? = null diff --git a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.kt b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.kt index f8daeba..b12d302 100644 --- a/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.kt +++ b/picker-core/src/commonMain/kotlin/io/github/vinceglb/picker/core/PlatformFile.kt @@ -1,14 +1,20 @@ package io.github.vinceglb.picker.core public expect class PlatformFile { - public val name: String - public val path: String? + public val name: String + public val path: String? - public suspend fun readBytes(): ByteArray + public suspend fun readBytes(): ByteArray } +public val PlatformFile.baseName: String + get() = name.substringBeforeLast(".", name) + +public val PlatformFile.extension: String + get() = name.substringAfterLast(".") + public expect class PlatformDirectory { - public val path: String? + public val path: String? } public typealias PlatformFiles = List 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 e5730aa..52cc0ea 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,7 +1,11 @@ package io.github.vinceglb.picker.core import io.github.vinceglb.picker.core.util.PickerDelegate +import platform.Foundation.NSFileManager import platform.Foundation.NSURL +import platform.Foundation.fileURLWithPathComponents +import platform.Foundation.pathComponents +import platform.Foundation.temporaryDirectory import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UISceneActivationStateForegroundActive @@ -14,73 +18,124 @@ 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 - - public actual suspend fun pick( - 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)) - }, - 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 - } - - // Assign the delegate to the picker controller - pickerController.delegate = pickerDelegate - - // Present the picker controller - UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( - pickerController, - animated = true, - completion = null - ) - } - - - // How to get Root view controller in Swift - // https://sarunw.com/posts/how-to-get-root-view-controller/ - private val UIApplication.firstKeyWindow: UIWindow? - get() = this.connectedScenes - .filterIsInstance() - .firstOrNull { it.activationState == UISceneActivationStateForegroundActive } - ?.keyWindow - - private val PickerSelectionMode<*>.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 - ?.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 + // Create a reference to the picker delegate to prevent it from being garbage collected + private lateinit var pickerDelegate: PickerDelegate + + public actual suspend fun pick( + 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)) + }, + 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 + } + + // Assign the delegate to the picker controller + pickerController.delegate = pickerDelegate + + // Present the picker controller + UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( + pickerController, + animated = true, + completion = null + ) + } + + public actual suspend fun save( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ): PlatformFile? = suspendCoroutine { continuation -> + // Create a picker delegate + pickerDelegate = PickerDelegate( + onFilesPicked = { urls -> + val file = urls.firstOrNull()?.let { PlatformFile(it) } + continuation.resume(file) + }, + onPickerCancelled = { + continuation.resume(null) + } + ) + + val fileName = "$baseName.$extension" + + // Get the fileManager + val fileManager = NSFileManager.defaultManager + + // Get the temporary directory + val fileComponents = fileManager.temporaryDirectory.pathComponents?.plus(fileName) + ?: throw IllegalStateException("Failed to get temporary directory") + + // Create a file URL + val fileUrl = NSURL.fileURLWithPathComponents(fileComponents) + ?: throw IllegalStateException("Failed to create file URL") + + // Write the bytes to the temp file + writeBytesArrayToNsUrl(bytes, fileUrl) + + // Create a picker controller + val pickerController = UIDocumentPickerViewController( + forExportingURLs = listOf(fileUrl) + ) + + // Set the initial directory + initialDirectory?.let { pickerController.directoryURL = NSURL.fileURLWithPath(it) } + + // Assign the delegate to the picker controller + pickerController.delegate = pickerDelegate + + // Present the picker controller + UIApplication.sharedApplication.firstKeyWindow?.rootViewController?.presentViewController( + pickerController, + animated = true, + completion = null + ) + } + + // How to get Root view controller in Swift + // https://sarunw.com/posts/how-to-get-root-view-controller/ + private val UIApplication.firstKeyWindow: UIWindow? + get() = this.connectedScenes + .filterIsInstance() + .firstOrNull { it.activationState == UISceneActivationStateForegroundActive } + ?.keyWindow + + private val PickerSelectionMode<*>.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 + ?.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 } 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 38719be..2f6340d 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 @@ -3,8 +3,11 @@ package io.github.vinceglb.picker.core import kotlinx.browser.document import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.w3c.dom.HTMLAnchorElement import org.w3c.dom.HTMLInputElement import org.w3c.dom.asList +import org.w3c.dom.url.URL +import org.w3c.files.File import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -24,8 +27,6 @@ public actual object Picker { // Setup the change listener input.onchange = { event -> - print("onchange") - try { // Get the selected files val files = event.target @@ -50,6 +51,30 @@ public actual object Picker { } } + public actual suspend fun save( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ): PlatformFile? = withContext(Dispatchers.Default) { + // Create a blob + val file = File( + fileBits = bytes.toTypedArray(), + fileName = "$baseName.$extension", + ) + + // Create a element + val a = document.createElement("a") as HTMLAnchorElement + a.href = URL.createObjectURL(file) + a.download = "$baseName.$extension" + + // Trigger the download + a.click() + + // Return the file + PlatformFile(file) + } + private fun HTMLInputElement.configure( mode: PickerSelectionMode<*>, ): HTMLInputElement { 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 index 3af3bd8..cc1318f 100644 --- 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 @@ -3,11 +3,11 @@ package io.github.vinceglb.picker.core import org.w3c.files.File public actual sealed class PickerSelectionMode { - public actual class SelectionResult( - public val files: List? + internal actual class SelectionResult( + val files: List? ) - public actual abstract fun result(selection: SelectionResult): Out? + internal actual abstract fun result(selection: SelectionResult): Out? public actual class SingleFile actual constructor( public val extensions: List? 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 f72eddd..7d45d17 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 @@ -1,40 +1,55 @@ package io.github.vinceglb.picker.core import io.github.vinceglb.picker.core.platform.PlatformFilePicker +import io.github.vinceglb.picker.core.platform.awt.AwtFileSaver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext public actual object Picker { - public actual suspend fun pick( + public actual suspend fun pick( mode: PickerSelectionMode, title: String?, initialDirectory: String? - ): Out? = withContext(Dispatchers.IO) { - val picker = PlatformFilePicker.current - - // Open native file picker - val selection = when (mode) { - is PickerSelectionMode.SingleFile -> picker.pickFile( - title = title, - initialDirectory = initialDirectory, - fileExtensions = mode.extensions - )?.let { listOf(it) } - - is PickerSelectionMode.MultipleFiles -> picker.pickFiles( - title = title, - initialDirectory = initialDirectory, - fileExtensions = mode.extensions - ) - - is PickerSelectionMode.Directory -> picker.pickDirectory( - title = title, - initialDirectory = initialDirectory - )?.let { listOf(it) } - - else -> throw IllegalArgumentException("Unsupported mode: $mode") - }.let { PickerSelectionMode.SelectionResult(it) } - - // Return result - return@withContext mode.result(selection) - } + ): Out? = withContext(Dispatchers.IO) { + val picker = PlatformFilePicker.current + + // Open native file picker + val selection = when (mode) { + is PickerSelectionMode.SingleFile -> picker.pickFile( + title = title, + initialDirectory = initialDirectory, + fileExtensions = mode.extensions + )?.let { listOf(it) } + + is PickerSelectionMode.MultipleFiles -> picker.pickFiles( + title = title, + initialDirectory = initialDirectory, + fileExtensions = mode.extensions + ) + + is PickerSelectionMode.Directory -> picker.pickDirectory( + title = title, + initialDirectory = initialDirectory + )?.let { listOf(it) } + + else -> throw IllegalArgumentException("Unsupported mode: $mode") + }.let { PickerSelectionMode.SelectionResult(it) } + + // Return result + return@withContext mode.result(selection) + } + + public actual suspend fun save( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ): PlatformFile? = withContext(Dispatchers.IO) { + AwtFileSaver.saveFile( + bytes = bytes, + baseName = baseName, + extension = extension, + initialDirectory = initialDirectory + ) + } } 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 index 395e628..2d198f7 100644 --- 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 @@ -5,11 +5,11 @@ import io.github.vinceglb.picker.core.platform.util.PlatformUtil import java.io.File public actual sealed class PickerSelectionMode { - public actual class SelectionResult( - public val files: List? + internal actual class SelectionResult( + val files: List? ) - public actual abstract fun result(selection: SelectionResult): Out? + internal actual abstract fun result(selection: SelectionResult): Out? public actual class SingleFile actual constructor( public val extensions: List? diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/PlatformFilePicker.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/PlatformFilePicker.kt index ae5f892..086e910 100644 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/PlatformFilePicker.kt +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/PlatformFilePicker.kt @@ -1,6 +1,6 @@ package io.github.vinceglb.picker.core.platform -import io.github.vinceglb.picker.core.platform.linux.LinuxFilePicker +import io.github.vinceglb.picker.core.platform.awt.AwtFilePicker import io.github.vinceglb.picker.core.platform.mac.MacOSFilePicker import io.github.vinceglb.picker.core.platform.util.Platform import io.github.vinceglb.picker.core.platform.util.PlatformUtil @@ -32,7 +32,7 @@ internal interface PlatformFilePicker { return when (PlatformUtil.current) { Platform.MacOS -> MacOSFilePicker() Platform.Windows -> WindowsFilePicker() - Platform.Linux -> LinuxFilePicker() + Platform.Linux -> AwtFilePicker() } } } diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/linux/LinuxFilePicker.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt similarity index 95% rename from picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/linux/LinuxFilePicker.kt rename to picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt index 0ce326c..e665d79 100644 --- a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/linux/LinuxFilePicker.kt +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFilePicker.kt @@ -1,4 +1,4 @@ -package io.github.vinceglb.picker.core.platform.linux +package io.github.vinceglb.picker.core.platform.awt import io.github.vinceglb.picker.core.PickerSelectionMode import io.github.vinceglb.picker.core.platform.PlatformFilePicker @@ -9,7 +9,7 @@ import java.io.File import java.io.FilenameFilter import kotlin.coroutines.resume -internal class LinuxFilePicker : PlatformFilePicker { +internal class AwtFilePicker : PlatformFilePicker { override suspend fun pickFile( initialDirectory: String?, fileExtensions: List?, diff --git a/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFileSaver.kt b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFileSaver.kt new file mode 100644 index 0000000..cb0994f --- /dev/null +++ b/picker-core/src/jvmMain/kotlin/io/github/vinceglb/picker/core/platform/awt/AwtFileSaver.kt @@ -0,0 +1,42 @@ +package io.github.vinceglb.picker.core.platform.awt + +import io.github.vinceglb.picker.core.PlatformFile +import kotlinx.coroutines.suspendCancellableCoroutine +import java.awt.FileDialog +import java.awt.Frame +import kotlin.coroutines.resume + +internal object AwtFileSaver { + suspend fun saveFile( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ): PlatformFile? = suspendCancellableCoroutine { continuation -> + val parent: Frame? = null + val dialog = object : FileDialog(parent, "Save dialog", SAVE) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + + val file = files?.firstOrNull()?.let { + it.writeBytes(bytes) + PlatformFile(it) + } + + continuation.resume(file) + } + } + + // Set initial directory + dialog.directory = initialDirectory + + // Set file name + dialog.file = "$baseName.$extension" + + // Show the dialog + dialog.isVisible = true + + // Dispose the dialog when the continuation is cancelled + continuation.invokeOnCancellation { dialog.dispose() } + } +} 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 07cdf28..821093d 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 @@ -2,75 +2,115 @@ package io.github.vinceglb.picker.core import platform.AppKit.NSModalResponseOK import platform.AppKit.NSOpenPanel +import platform.AppKit.NSSavePanel import platform.AppKit.allowedFileTypes import platform.Foundation.NSURL public actual object Picker { - public actual suspend fun pick( - mode: PickerSelectionMode, - 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 - } - - // Return the result - val urls = nsOpenPanel.URLs.mapNotNull { it as? NSURL } - val selection = PickerSelectionMode.SelectionResult(urls) - return mode.result(selection) - } - - private fun NSOpenPanel.configure( - mode: PickerSelectionMode<*>, - title: String?, - initialDirectory: String?, - ): NSOpenPanel { - // Set the title - title?.let { message = it } - - // Set the initial directory - initialDirectory?.let { directoryURL = NSURL.fileURLWithPath(it) } - - // Setup the picker mode and files extensions - when(mode) { - is PickerSelectionMode.SingleFile -> { - canChooseFiles = true - canChooseDirectories = false - allowsMultipleSelection = false - - // Set the allowed file types - mode.extensions?.let { allowedFileTypes = mode.extensions } - } - - is PickerSelectionMode.MultipleFiles -> { - canChooseFiles = true - canChooseDirectories = false - allowsMultipleSelection = true - - // Set the allowed file types - mode.extensions?.let { allowedFileTypes = mode.extensions } - } - - is PickerSelectionMode.Directory -> { - canChooseFiles = false - canChooseDirectories = true - allowsMultipleSelection = false - } - - else -> throw IllegalArgumentException("Unsupported mode: $mode") - } - - return this - } + public actual suspend fun pick( + mode: PickerSelectionMode, + 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 + } + + // Return the result + val urls = nsOpenPanel.URLs.mapNotNull { it as? NSURL } + val selection = PickerSelectionMode.SelectionResult(urls) + return mode.result(selection) + } + + public actual suspend fun save( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ): PlatformFile? { + // Create an NSSavePanel + val nsSavePanel = NSSavePanel() + + // Set the initial directory + initialDirectory?.let { nsSavePanel.directoryURL = NSURL.fileURLWithPath(it) } + + // Set the file name + nsSavePanel.nameFieldStringValue = "$baseName.$extension" + nsSavePanel.allowedFileTypes = listOf(extension) + + // Accept the creation of directories + nsSavePanel.canCreateDirectories = true + + // Run the NSSavePanel + val result = nsSavePanel.runModal() + + // If the user cancelled the operation, return null + if (result != NSModalResponseOK) { + return null + } + + // Return the result + val platformFile = nsSavePanel.URL?.let { nsUrl -> + // Write the bytes to the file + writeBytesArrayToNsUrl(bytes, nsUrl) + + // Create the PlatformFile + PlatformFile(nsUrl) + } + + return platformFile + } + + private fun NSOpenPanel.configure( + mode: PickerSelectionMode<*>, + title: String?, + initialDirectory: String?, + ): NSOpenPanel { + // Set the title + title?.let { message = it } + + // Set the initial directory + initialDirectory?.let { directoryURL = NSURL.fileURLWithPath(it) } + + // Setup the picker mode and files extensions + when (mode) { + is PickerSelectionMode.SingleFile -> { + canChooseFiles = true + canChooseDirectories = false + allowsMultipleSelection = false + + // Set the allowed file types + mode.extensions?.let { allowedFileTypes = mode.extensions } + } + + is PickerSelectionMode.MultipleFiles -> { + canChooseFiles = true + canChooseDirectories = false + allowsMultipleSelection = true + + // Set the allowed file types + mode.extensions?.let { allowedFileTypes = mode.extensions } + } + + is PickerSelectionMode.Directory -> { + canChooseFiles = false + canChooseDirectories = true + allowsMultipleSelection = false + } + + else -> throw IllegalArgumentException("Unsupported mode: $mode") + } + + return this + } } 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 bc12fe6..6e2bb36 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 @@ -3,86 +3,123 @@ package io.github.vinceglb.picker.core import kotlinx.browser.document import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.set +import org.w3c.dom.HTMLAnchorElement import org.w3c.dom.HTMLInputElement import org.w3c.dom.asList +import org.w3c.dom.url.URL +import org.w3c.files.File import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine public actual object Picker { - public actual suspend fun pick( - mode: PickerSelectionMode, - title: String?, - initialDirectory: String? - ): Out? = withContext(Dispatchers.Default) { - suspendCoroutine { continuation -> - // Create input element - val input = document.createElement("input") as HTMLInputElement - - // Configure the input element - input.configure(mode) - - // Setup the change listener - input.onchange = { event -> - print("onchange") - - try { - // Get the selected files - val files = event.target - ?.unsafeCast() - ?.files - ?.asList() - - // Return the result - val selection = PickerSelectionMode.SelectionResult(files) - continuation.resume(mode.result(selection)) - } catch (e: Throwable) { - continuation.resumeWithException(e) - } - } - - input.oncancel = { - continuation.resume(null) - } - - // Trigger the file picker - input.click() - } - } - - 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 - } + public actual suspend fun pick( + mode: PickerSelectionMode, + title: String?, + initialDirectory: String? + ): Out? = withContext(Dispatchers.Default) { + suspendCoroutine { continuation -> + // Create input element + val input = document.createElement("input") as HTMLInputElement + + // Configure the input element + input.configure(mode) + + // Setup the change listener + input.onchange = { event -> + try { + // Get the selected files + val files = event.target + ?.unsafeCast() + ?.files + ?.asList() + + // Return the result + val selection = PickerSelectionMode.SelectionResult(files) + continuation.resume(mode.result(selection)) + } catch (e: Throwable) { + continuation.resumeWithException(e) + } + } + + input.oncancel = { + continuation.resume(null) + } + + // Trigger the file picker + input.click() + } + } + + public actual suspend fun save( + bytes: ByteArray, + baseName: String, + extension: String, + initialDirectory: String?, + ): PlatformFile? = withContext(Dispatchers.Default) { + // Create a byte array + val array = Uint8Array(bytes.size) + for (i in bytes.indices) { + array[i] = bytes[i] + } + + // Create a JS array + val jsArray = JsArray() + jsArray[0] = array + + // Create a blob + val file = File( + fileBits = jsArray, + fileName = "$baseName.$extension", + ) + + // Create a element + val a = document.createElement("a") as HTMLAnchorElement + a.href = URL.createObjectURL(file) + a.download = "$baseName.$extension" + + // Trigger the download + a.click() + + // 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 index 3214a49..c914438 100644 --- 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 @@ -3,11 +3,11 @@ package io.github.vinceglb.picker.core import org.w3c.files.File public actual sealed class PickerSelectionMode { - public actual class SelectionResult( - public val files: List? + internal actual class SelectionResult( + val files: List? ) - public actual abstract fun result(selection: SelectionResult): Out? + internal actual abstract fun result(selection: SelectionResult): Out? public actual class SingleFile actual constructor( public val extensions: List? diff --git a/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt b/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt index fe36a55..7044050 100644 --- a/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt +++ b/samples/sample-compose/composeApp/src/commonMain/kotlin/App.kt @@ -14,14 +14,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.core.PickerSelectionMode import io.github.vinceglb.picker.core.PlatformDirectory import io.github.vinceglb.picker.core.PlatformFile +import io.github.vinceglb.picker.core.baseName +import io.github.vinceglb.picker.core.extension +import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -58,6 +63,22 @@ private fun SampleApp() { onResult = { dir -> directory = dir } ) + val saver = rememberSaverLauncher( + onResult = { file -> file?.let { files += it } } + ) + + val scope = rememberCoroutineScope() + fun saveFile(file: PlatformFile) { + scope.launch { + saver.launch( + bytes = file.readBytes(), + baseName = file.baseName, + extension = file.extension, + initialDirectory = directory?.path + ) + } + } + Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -94,7 +115,7 @@ private fun SampleApp() { modifier = Modifier.fillMaxWidth(), ) { items(files.toList()) { - PhotoItem(it) + PhotoItem(file = it, onSaveFile = ::saveFile) } } } diff --git a/samples/sample-compose/composeApp/src/commonMain/kotlin/PhotoItem.kt b/samples/sample-compose/composeApp/src/commonMain/kotlin/PhotoItem.kt index a9f21ee..c241565 100644 --- a/samples/sample-compose/composeApp/src/commonMain/kotlin/PhotoItem.kt +++ b/samples/sample-compose/composeApp/src/commonMain/kotlin/PhotoItem.kt @@ -3,6 +3,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -21,7 +27,10 @@ import coil3.compose.AsyncImage import io.github.vinceglb.picker.core.PlatformFile @Composable -fun PhotoItem(file: PlatformFile) { +fun PhotoItem( + file: PlatformFile, + onSaveFile: (PlatformFile) -> Unit, +) { var bytes by remember(file) { mutableStateOf(null) } var showName by remember { mutableStateOf(false) } @@ -47,6 +56,24 @@ fun PhotoItem(file: PlatformFile) { ) } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = CircleShape, + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp) + ) { + IconButton( + onClick = { onSaveFile(file) }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Default.Check, + modifier = Modifier.size(22.dp), + contentDescription = "Save", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + AnimatedVisibility( visible = showName, modifier = Modifier.padding(4.dp).align(Alignment.BottomStart) diff --git a/samples/sample-core/appleApps/iOSApp/ContentView.swift b/samples/sample-core/appleApps/iOSApp/ContentView.swift index baf4c1b..861d436 100644 --- a/samples/sample-core/appleApps/iOSApp/ContentView.swift +++ b/samples/sample-core/appleApps/iOSApp/ContentView.swift @@ -44,6 +44,7 @@ struct ContentView: View { List(files, id: \.nsUrl) { file in Text(file.name) + .onTapGesture { viewModel.saveFile(file: file) } } } .padding() diff --git a/samples/sample-core/appleApps/macOSApp/ContentView.swift b/samples/sample-core/appleApps/macOSApp/ContentView.swift index 4ffb822..e5f3803 100644 --- a/samples/sample-core/appleApps/macOSApp/ContentView.swift +++ b/samples/sample-core/appleApps/macOSApp/ContentView.swift @@ -44,6 +44,7 @@ struct ContentView: View { List(files, id: \.nsUrl) { file in Text(file.name) + .onTapGesture { viewModel.saveFile(file: file) } } } .padding() diff --git a/samples/sample-core/appleApps/macOSApp/macOSApp.entitlements b/samples/sample-core/appleApps/macOSApp/macOSApp.entitlements index 18aff0c..19afff1 100644 --- a/samples/sample-core/appleApps/macOSApp/macOSApp.entitlements +++ b/samples/sample-core/appleApps/macOSApp/macOSApp.entitlements @@ -4,7 +4,7 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write 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 433e6a3..6bd26f8 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 @@ -82,7 +82,7 @@ private fun SampleApp(viewModel: MainViewModel = koinInject()) { modifier = Modifier.fillMaxWidth(), ) { items(uiState.files.toList()) { - PhotoItem(it) + PhotoItem(it, viewModel::saveFile) } } } diff --git a/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/PhotoItem.kt b/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/PhotoItem.kt index e7cfa94..bccc25b 100644 --- a/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/PhotoItem.kt +++ b/samples/sample-core/composeApp/src/commonMain/kotlin/io/github/vinceglb/sample/core/compose/PhotoItem.kt @@ -5,6 +5,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -23,7 +29,10 @@ import coil3.compose.AsyncImage import io.github.vinceglb.picker.core.PlatformFile @Composable -fun PhotoItem(file: PlatformFile) { +fun PhotoItem( + file: PlatformFile, + onSaveFile: (PlatformFile) -> Unit, +) { var bytes by remember(file) { mutableStateOf(null) } var showName by remember { mutableStateOf(false) } @@ -45,10 +54,27 @@ fun PhotoItem(file: PlatformFile) { contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() - ) } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = CircleShape, + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp) + ) { + IconButton( + onClick = { onSaveFile(file) }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Default.Check, + modifier = Modifier.size(22.dp), + contentDescription = "Save", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + AnimatedVisibility( visible = showName, modifier = Modifier.padding(4.dp).align(Alignment.BottomStart) 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 ab831bc..e129764 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 @@ -6,76 +6,93 @@ import io.github.vinceglb.picker.core.Picker import io.github.vinceglb.picker.core.PickerSelectionMode 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class MainViewModel : KMMViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(MainUiState()) - 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, - title = "Custom title here", - initialDirectory = downloadDirectoryPath() - ) - - // Add file to the state - if (file != null) { - val newFiles = _uiState.value.files + file - _uiState.update { it.copy(files = newFiles) } - } - } - - fun pickImages() = executeWithLoading { - // Multiple files mode - val mode = PickerSelectionMode.MultipleFiles(extensions = listOf("jpg", "jpeg", "png")) - - // Pick files - val files = Picker.pick(mode = mode) - - // Add files to the state - if (files != null) { - // Add files to the state - val newFiles = _uiState.value.files + files - _uiState.update { it.copy(files = newFiles) } - } - } - - fun pickDirectory() = executeWithLoading { - // Directory mode - val mode = PickerSelectionMode.Directory - - // Pick a directory - val directory = Picker.pick(mode) - - // Update the state - if (directory != null) { - _uiState.update { it.copy(directory = directory) } - } - } - - private fun executeWithLoading(block: suspend () -> Unit) { - viewModelScope.coroutineScope.launch { - _uiState.update { it.copy(loading = true) } - block() - _uiState.update { it.copy(loading = false) } - } - } + private val _uiState: MutableStateFlow = MutableStateFlow(MainUiState()) + 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, + title = "Custom title here", + initialDirectory = downloadDirectoryPath() + ) + + // Add file to the state + if (file != null) { + val newFiles = _uiState.value.files + file + _uiState.update { it.copy(files = newFiles) } + } + } + + fun pickImages() = executeWithLoading { + // Multiple files mode + val mode = PickerSelectionMode.MultipleFiles(extensions = listOf("jpg", "jpeg", "png")) + + // Pick files + val files = Picker.pick(mode = mode) + + // Add files to the state + if (files != null) { + // Add files to the state + val newFiles = _uiState.value.files + files + _uiState.update { it.copy(files = newFiles) } + } + } + + fun pickDirectory() = executeWithLoading { + // Directory mode + val mode = PickerSelectionMode.Directory + + // Pick a directory + val directory = Picker.pick(mode) + + // Update the state + if (directory != null) { + _uiState.update { it.copy(directory = directory) } + } + } + + fun saveFile(file: PlatformFile) = executeWithLoading { + // Save a file + val newFile = Picker.save( + bytes = file.readBytes(), + baseName = file.baseName, + extension = file.extension + ) + + // Add file to the state + if (newFile != null) { + val newFiles = _uiState.value.files + newFile + _uiState.update { it.copy(files = newFiles) } + } + } + + private fun executeWithLoading(block: suspend () -> Unit) { + viewModelScope.coroutineScope.launch { + _uiState.update { it.copy(loading = true) } + block() + _uiState.update { it.copy(loading = false) } + } + } } data class MainUiState( - val files: Set = emptySet(), // Set instead of List to avoid duplicates - val directory: PlatformDirectory? = null, - val loading: Boolean = false + val files: Set = emptySet(), // Set instead of List to avoid duplicates + val directory: PlatformDirectory? = null, + val loading: Boolean = false ) { - constructor() : this(emptySet(), null, false) + constructor() : this(emptySet(), null, false) } expect fun downloadDirectoryPath(): String?