From bbbf0f401c49118eb43be712a7967d73454389db Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 9 Mar 2026 13:09:10 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20iOS=20picker=20ca?= =?UTF-8?q?ncel=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/dialogs/FileKitMode.kt | 27 ++++++++---- .../dialogs/FileKitModeMaxItemsTest.kt | 44 +++++++++++++++++++ .../vinceglb/filekit/dialogs/FileKit.ios.kt | 14 +++--- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt index 4a7e4875..bb83b7d0 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt @@ -63,9 +63,10 @@ public sealed class FileKitMode { override suspend fun parseResult(flow: Flow>>): List? = flow.last().let { when (it) { is FileKitPickerState.Completed -> { - maxItems + val files = maxItems ?.let { max -> it.result.take(max) } ?: it.result + files.takeIfNotEmpty() } else -> { @@ -145,29 +146,35 @@ public sealed class FileKitMode { override suspend fun parseResult( flow: Flow>>, ): Flow>> = flow - .mapNotNull { - when (it) { + .mapNotNull { pickerState -> + when (pickerState) { is FileKitPickerState.Cancelled -> { FileKitPickerState.Cancelled } is FileKitPickerState.Started -> { FileKitPickerState.Started( - total = maxItems?.let { max -> minOf(it.total, max) } ?: it.total, + total = maxItems?.let { max -> minOf(pickerState.total, max) } ?: pickerState.total, ) } is FileKitPickerState.Progress -> { FileKitPickerState.Progress( - processed = maxItems?.let { max -> it.processed.take(max) } ?: it.processed, - total = maxItems?.let { max -> minOf(it.total, max) } ?: it.total, + processed = maxItems?.let { max -> pickerState.processed.take(max) } ?: pickerState.processed, + total = maxItems?.let { max -> minOf(pickerState.total, max) } ?: pickerState.total, ) } is FileKitPickerState.Completed -> { - FileKitPickerState.Completed( - result = maxItems?.let { max -> it.result.take(max) } ?: it.result, - ) + val files = ( + maxItems?.let { max -> pickerState.result.take(max) } + ?: pickerState.result + ).takeIfNotEmpty() + + when { + files != null -> FileKitPickerState.Completed(result = files) + else -> FileKitPickerState.Cancelled + } } } } @@ -181,6 +188,8 @@ public sealed class FileKitMode { } } +private fun List.takeIfNotEmpty(): List? = takeIf { it.isNotEmpty() } + internal sealed class PickerMode { data object Single : PickerMode() diff --git a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt index 1f7f0a22..274e3e2a 100644 --- a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt +++ b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt @@ -11,6 +11,28 @@ import kotlin.test.Test import kotlin.test.assertEquals class FileKitModeMaxItemsTest { + @Test + fun Single_parseResult_returnsNull_whenCompletedResultEmpty() = runTest { + val result = FileKitMode.Single.parseResult( + flow = flowOf(FileKitPickerState.Completed(emptyList())), + ) + + assertEquals(expected = null, actual = result) + } + + @Test + fun SingleWithState_parseResult_treatsEmptyCompletedResultAsCancelled() = runTest { + val states = FileKitMode + .SingleWithState + .parseResult(flow = flowOf(FileKitPickerState.Completed(emptyList()))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Cancelled), + actual = states, + ) + } + @Test fun Multiple_parseResult_truncatesCompletedResult_whenMaxItemsSet() = runTest { val files = createFiles(count = 4) @@ -31,6 +53,15 @@ class FileKitModeMaxItemsTest { assertEquals(expected = files, actual = result) } + @Test + fun Multiple_parseResult_returnsNull_whenCompletedResultEmpty() = runTest { + val result = FileKitMode.Multiple(maxItems = null).parseResult( + flow = flowOf(FileKitPickerState.Completed(emptyList())), + ) + + assertEquals(expected = null, actual = result) + } + @Test fun MultipleWithState_parseResult_capsStartedProgressCompleted_whenMaxItemsSet() = runTest { val files = createFiles(count = 4) @@ -68,6 +99,19 @@ class FileKitModeMaxItemsTest { ) } + @Test + fun MultipleWithState_parseResult_treatsEmptyCompletedResultAsCancelled() = runTest { + val states = FileKitMode + .MultipleWithState(maxItems = null) + .parseResult(flow = flowOf(FileKitPickerState.Completed(emptyList()))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Cancelled), + actual = states, + ) + } + @Test fun MultipleWithState_parseResult_returnsUnchangedFlow_whenMaxItemsNull() = runTest { val files = createFiles(count = 4) diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt index 5ca5c746..131c7aa0 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt @@ -61,7 +61,6 @@ import platform.UniformTypeIdentifiers.UTTypeImage import platform.UniformTypeIdentifiers.UTTypeItem import platform.UniformTypeIdentifiers.UTTypeMovie import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException private object FileKitDialog { // Create a reference to the picker delegate to prevent it from being garbage collected @@ -497,7 +496,7 @@ private fun callPhPicker( ) { url, error -> when { error != null -> { - cont.resumeWithException(IllegalStateException(error.localizedFailureReason())) + cont.resume(null) } else -> { @@ -519,7 +518,11 @@ private fun callPhPicker( } }.joinAll() - send(FileKitPickerState.Completed(orderedFiles.filterNotNull())) + val files = orderedFiles.filterNotNull() + when { + files.isEmpty() -> send(FileKitPickerState.Cancelled) + else -> send(FileKitPickerState.Completed(files)) + } } private val FileKitType.contentTypes: List @@ -551,7 +554,7 @@ private fun copyToTempFile( fileManager: NSFileManager, url: NSURL, id: String, -): NSURL { +): NSURL? { // Get the temporary directory val fileComponents = fileManager.temporaryDirectory.pathComponents ?.plus(id) @@ -563,11 +566,12 @@ private fun copyToTempFile( ?: throw IllegalStateException("Failed to create file URL") // Write the data to the file URL - fileManager.copyItemAtURL( + val didCopy = fileManager.copyItemAtURL( srcURL = url, toURL = fileUrl, error = null, ) + if (!didCopy) return null return fileUrl } From 1a66b5a48438c093ef539c3a87fcc73d2b665710 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 9 Mar 2026 13:28:54 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Propagate=20picker=20f?= =?UTF-8?q?ailures=20explicitly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/compose/FileKitCompose.android.kt | 8 ++ .../vinceglb/filekit/dialogs/FileKit.kt | 2 + .../vinceglb/filekit/dialogs/FileKitMode.kt | 18 +++- .../filekit/dialogs/FileKitPickerException.kt | 9 ++ .../filekit/dialogs/FileKitPickerState.kt | 9 ++ .../dialogs/FileKitModeMaxItemsTest.kt | 57 +++++++++++++ .../vinceglb/filekit/dialogs/FileKit.ios.kt | 82 +++++++++++++------ 7 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt diff --git a/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt b/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt index c1d7671a..4e62147d 100644 --- a/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt +++ b/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt @@ -612,6 +612,10 @@ internal fun dispatchPickerConsumedResult( onConsumed(FileKitPickerState.Cancelled) } + is FileKitPickerState.Failed -> { + onConsumed(FileKitPickerState.Failed(cause = state.cause)) + } + is FileKitPickerState.Started -> { onConsumed(FileKitPickerState.Started(total = state.total)) } @@ -647,6 +651,10 @@ internal fun dispatchPickerConsumedResult( onConsumed(FileKitPickerState.Cancelled) } + is FileKitPickerState.Failed -> { + onConsumed(FileKitPickerState.Failed(cause = state.cause)) + } + is FileKitPickerState.Started -> { onConsumed( FileKitPickerState.Started( diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt index c0349ef6..5ff74393 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. * @return The result of the picker, depending on the [mode]. + * @throws FileKitPickerException When the user selected files but FileKit could not resolve them. */ public suspend fun FileKit.openFilePicker( type: FileKitType = FileKitType.File(), @@ -35,6 +36,7 @@ public suspend fun FileKit.openFilePicker( * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. * @return The picked [PlatformFile], or null if cancelled. + * @throws FileKitPickerException When the user selected a file but FileKit could not resolve it. */ public suspend fun FileKit.openFilePicker( type: FileKitType = FileKitType.File(), diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt index bb83b7d0..acb4e0ce 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt @@ -30,6 +30,10 @@ public sealed class FileKitMode { override suspend fun parseResult(flow: Flow>>): PlatformFile? = flow.last().let { when (it) { + is FileKitPickerState.Failed -> { + throw it.cause + } + is FileKitPickerState.Completed -> { it.result.firstOrNull() } @@ -62,6 +66,10 @@ public sealed class FileKitMode { override suspend fun parseResult(flow: Flow>>): List? = flow.last().let { when (it) { + is FileKitPickerState.Failed -> { + throw it.cause + } + is FileKitPickerState.Completed -> { val files = maxItems ?.let { max -> it.result.take(max) } @@ -97,6 +105,10 @@ public sealed class FileKitMode { emit(FileKitPickerState.Cancelled) } + is FileKitPickerState.Failed -> { + emit(FileKitPickerState.Failed(cause = pickerState.cause)) + } + is FileKitPickerState.Started -> { emit(FileKitPickerState.Started(total = pickerState.total)) } @@ -152,6 +164,10 @@ public sealed class FileKitMode { FileKitPickerState.Cancelled } + is FileKitPickerState.Failed -> { + FileKitPickerState.Failed(cause = pickerState.cause) + } + is FileKitPickerState.Started -> { FileKitPickerState.Started( total = maxItems?.let { max -> minOf(pickerState.total, max) } ?: pickerState.total, @@ -169,7 +185,7 @@ public sealed class FileKitMode { val files = ( maxItems?.let { max -> pickerState.result.take(max) } ?: pickerState.result - ).takeIfNotEmpty() + ).takeIfNotEmpty() when { files != null -> FileKitPickerState.Completed(result = files) diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt new file mode 100644 index 00000000..3f44d476 --- /dev/null +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt @@ -0,0 +1,9 @@ +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.exceptions.FileKitException + +public class FileKitPickerException : FileKitException { + public constructor(message: String) : super(message) + + public constructor(message: String, cause: Throwable) : super(message, cause) +} diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt index 1e35b393..a8a39e2b 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt @@ -11,6 +11,15 @@ public sealed class FileKitPickerState { */ public data object Cancelled : FileKitPickerState() + /** + * The picker failed after the user made a selection. + * + * @property cause The underlying picker failure. + */ + public data class Failed( + val cause: FileKitPickerException, + ) : FileKitPickerState() + /** * The picker has started and is processing the files. * diff --git a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt index 274e3e2a..aba2612e 100644 --- a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt +++ b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt @@ -9,8 +9,22 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class FileKitModeMaxItemsTest { + @Test + fun Single_parseResult_throwsPickerException_whenFailed() = runTest { + val failure = FileKitPickerException("Failed to load the selected file.") + + val thrown = assertFailsWith { + FileKitMode.Single.parseResult( + flow = flowOf(FileKitPickerState.Failed(failure)), + ) + } + + assertEquals(expected = failure.message, actual = thrown.message) + } + @Test fun Single_parseResult_returnsNull_whenCompletedResultEmpty() = runTest { val result = FileKitMode.Single.parseResult( @@ -33,6 +47,21 @@ class FileKitModeMaxItemsTest { ) } + @Test + fun SingleWithState_parseResult_keepsFailedUnchanged() = runTest { + val failure = FileKitPickerException("Failed to load the selected file.") + + val states = FileKitMode + .SingleWithState + .parseResult(flow = flowOf(FileKitPickerState.Failed(failure))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Failed(failure)), + actual = states, + ) + } + @Test fun Multiple_parseResult_truncatesCompletedResult_whenMaxItemsSet() = runTest { val files = createFiles(count = 4) @@ -62,6 +91,19 @@ class FileKitModeMaxItemsTest { assertEquals(expected = null, actual = result) } + @Test + fun Multiple_parseResult_throwsPickerException_whenFailed() = runTest { + val failure = FileKitPickerException("Failed to load one of the selected files.") + + val thrown = assertFailsWith { + FileKitMode.Multiple(maxItems = null).parseResult( + flow = flowOf(FileKitPickerState.Failed(failure)), + ) + } + + assertEquals(expected = failure.message, actual = thrown.message) + } + @Test fun MultipleWithState_parseResult_capsStartedProgressCompleted_whenMaxItemsSet() = runTest { val files = createFiles(count = 4) @@ -112,6 +154,21 @@ class FileKitModeMaxItemsTest { ) } + @Test + fun MultipleWithState_parseResult_keepsFailedUnchanged() = runTest { + val failure = FileKitPickerException("Failed to load one of the selected files.") + + val states = FileKitMode + .MultipleWithState(maxItems = null) + .parseResult(flow = flowOf(FileKitPickerState.Failed(failure))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Failed(failure)), + actual = states, + ) + } + @Test fun MultipleWithState_parseResult_returnsUnchangedFlow_whenMaxItemsNull() = runTest { val files = createFiles(count = 4) diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt index 131c7aa0..c42fe6a2 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt @@ -61,6 +61,7 @@ import platform.UniformTypeIdentifiers.UTTypeImage import platform.UniformTypeIdentifiers.UTTypeItem import platform.UniformTypeIdentifiers.UTTypeMovie import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException private object FileKitDialog { // Create a reference to the picker delegate to prevent it from being garbage collected @@ -480,44 +481,73 @@ private fun callPhPicker( // Pre-allocated array to preserve selection order val orderedFiles = arrayOfNulls(pickerResults.size) val lock = Mutex() + var failure: FileKitPickerException? = null // Launch a child coroutine for every copy, preserving the index pickerResults .mapIndexed { index, result -> launch(Dispatchers.IO) { - val src = suspendCancellableCoroutine { cont -> - result.itemProvider.loadFileRepresentationForTypeIdentifier( - when (type) { - is FileKitType.Image -> UTTypeImage.identifier - is FileKitType.Video -> UTTypeMovie.identifier - is FileKitType.ImageAndVideo -> UTTypeContent.identifier - else -> error("Unsupported type $type") - }, - ) { url, error -> - when { - error != null -> { - cont.resume(null) - } + try { + val src = suspendCancellableCoroutine { cont -> + result.itemProvider.loadFileRepresentationForTypeIdentifier( + when (type) { + is FileKitType.Image -> UTTypeImage.identifier + is FileKitType.Video -> UTTypeMovie.identifier + is FileKitType.ImageAndVideo -> UTTypeContent.identifier + else -> error("Unsupported type $type") + }, + ) { url, error -> + when { + error != null -> { + cont.resumeWithException( + FileKitPickerException( + message = error.localizedDescription, + ), + ) + } + + url == null -> { + cont.resumeWithException( + FileKitPickerException("The selected file could not be resolved."), + ) + } - else -> { - // Must copy the URL here because it becomes invalid outside the loadFileRepresentationForTypeIdentifier callback scope - val tempUrl = url?.let { - copyToTempFile(fileManager, it, tempRoot.lastPathComponent!!) + else -> { + // Must copy the URL here because it becomes invalid outside the loadFileRepresentationForTypeIdentifier callback scope + runCatching { + copyToTempFile(fileManager, url, tempRoot.lastPathComponent!!) + }.onSuccess(cont::resume) + .onFailure { cont.resumeWithException(it) } } - cont.resume(tempUrl) } } } - } ?: return@launch // skip nulls - lock.withLock { - // Insert at the original index to preserve selection order - orderedFiles[index] = PlatformFile(src) - send(FileKitPickerState.Progress(orderedFiles.filterNotNull(), pickerResults.size)) + lock.withLock { + if (failure != null) return@launch + + // Insert at the original index to preserve selection order + orderedFiles[index] = PlatformFile(src) + send(FileKitPickerState.Progress(orderedFiles.filterNotNull(), pickerResults.size)) + } + } catch (cause: Throwable) { + val pickerFailure = cause as? FileKitPickerException + ?: FileKitPickerException("Failed to load the selected file.", cause) + + lock.withLock { + if (failure == null) { + failure = pickerFailure + } + } } } }.joinAll() + failure?.let { + send(FileKitPickerState.Failed(it)) + return@channelFlow + } + val files = orderedFiles.filterNotNull() when { files.isEmpty() -> send(FileKitPickerState.Cancelled) @@ -554,7 +584,7 @@ private fun copyToTempFile( fileManager: NSFileManager, url: NSURL, id: String, -): NSURL? { +): NSURL { // Get the temporary directory val fileComponents = fileManager.temporaryDirectory.pathComponents ?.plus(id) @@ -571,7 +601,9 @@ private fun copyToTempFile( toURL = fileUrl, error = null, ) - if (!didCopy) return null + if (!didCopy) { + throw FileKitPickerException("Failed to copy the selected file to a temporary location.") + } return fileUrl } From 3eca9a75fc0a745567497de93cefd640156ab297 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 9 Mar 2026 13:43:56 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=A7=20Fix=20sample=20picker=20stat?= =?UTF-8?q?e=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sample/shared/ui/screens/filepicker/FilePickerScreen.kt | 2 ++ .../shared/ui/screens/gallerypicker/GalleryPickerScreen.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt index d72ddcd5..f4da0899 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt @@ -117,6 +117,7 @@ private fun FilePickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed -> listOf(state.result) is FileKitPickerState.Progress -> listOf(state.processed) is FileKitPickerState.Started -> emptyList() @@ -132,6 +133,7 @@ private fun FilePickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed> -> state.result is FileKitPickerState.Progress> -> state.processed is FileKitPickerState.Started -> emptyList() diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt index 61fdee4a..6ef3a654 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt @@ -103,6 +103,7 @@ private fun GalleryPickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed -> listOf(state.result) is FileKitPickerState.Progress -> listOf(state.processed) is FileKitPickerState.Started -> emptyList() @@ -117,6 +118,7 @@ private fun GalleryPickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed> -> state.result is FileKitPickerState.Progress> -> state.processed is FileKitPickerState.Started -> emptyList() From 2b675d672d5341b861a211b42ecc4b231537ef71 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 9 Mar 2026 14:04:37 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9D=20Document=20picker=20failure?= =?UTF-8?q?=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/file-picker.mdx | 28 ++++++++++++++++++++++++++++ docs/dialogs/gallery-picker.mdx | 25 +++++++++++++++++++++++++ docs/migrate-to-v0.10.mdx | 17 +++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/docs/dialogs/file-picker.mdx b/docs/dialogs/file-picker.mdx index 92f3d869..f6ebb8bd 100644 --- a/docs/dialogs/file-picker.mdx +++ b/docs/dialogs/file-picker.mdx @@ -34,6 +34,13 @@ Select one or multiple files using the `mode` parameter. FileKit provides four d - `Single` - Allows the user to select a single file (default). - `Multiple()` - Allows the user to select multiple files. + +`Single` and `Multiple` now distinguish between user cancellation and picker I/O failures. + +- If the user dismisses the picker, FileKit returns `null`. +- If the user selects a file but FileKit cannot resolve it, FileKit throws `FileKitPickerException`. + + ```kotlin filekit-dialogs // Single file selection @@ -63,6 +70,15 @@ val multipleLauncher = rememberFilePickerLauncher( ``` +```kotlin +try { + val files = FileKit.openFilePicker(mode = FileKitMode.Multiple(maxItems = 5)) + println("Picked ${files?.size ?: 0} files") +} catch (error: FileKitPickerException) { + println("Failed to load one of the selected files: ${error.message}") +} +``` + ### State-tracking modes For advanced use cases where you need to track the selection progress: @@ -79,6 +95,7 @@ stateFlow.collect { state -> is FileKitPickerState.Started -> println("Selection started with ${state.total} files") is FileKitPickerState.Progress -> println("Processing: ${state.processed.size} / ${state.total}") is FileKitPickerState.Completed -> println("Completed: ${state.result.size} files selected") + is FileKitPickerState.Failed -> println("Selection failed: ${state.cause.message}") is FileKitPickerState.Cancelled -> println("Selection cancelled") } } @@ -102,6 +119,10 @@ val stateLauncher = rememberFilePickerLauncher( // Handle selected file: state.result println("Completed: ${state.result.size} files selected") } + is FileKitPickerState.Failed -> { + // Handle picker failure + println("Selection failed: ${state.cause.message}") + } is FileKitPickerState.Cancelled -> { // Handle cancellation println("Selection cancelled") @@ -111,6 +132,13 @@ val stateLauncher = rememberFilePickerLauncher( ``` + +Stateful picker modes now preserve failure information explicitly: + +- `Cancelled` means the user dismissed the picker. +- `Failed` means the user selected one or more files, but FileKit could not resolve them. + + The `Multiple` and `MultipleWithState` modes support a `maxItems` parameter (1-50 files). If not specified, there's no limit. diff --git a/docs/dialogs/gallery-picker.mdx b/docs/dialogs/gallery-picker.mdx index b23ca1ed..8741a207 100644 --- a/docs/dialogs/gallery-picker.mdx +++ b/docs/dialogs/gallery-picker.mdx @@ -38,6 +38,31 @@ val launcher = rememberFilePickerLauncher( ``` +### Failure handling + +Gallery pickers can fail after the user makes a valid selection, for example when a provider cannot vend a temporary file or a cloud-backed asset is not available locally yet. + +- `FileKitMode.Single` and `FileKitMode.Multiple` throw `FileKitPickerException` on these failures. +- `FileKitMode.SingleWithState` and `FileKitMode.MultipleWithState` emit `FileKitPickerState.Failed`. +- `FileKitPickerState.Cancelled` is reserved for user cancellation only. + +```kotlin +val stateFlow = FileKit.openFilePicker( + type = FileKitType.ImageAndVideo, + mode = FileKitMode.MultipleWithState(maxItems = 5), +) + +stateFlow.collect { state -> + when (state) { + is FileKitPickerState.Started -> println("Selection started") + is FileKitPickerState.Progress -> println("Loaded ${state.processed.size} / ${state.total}") + is FileKitPickerState.Completed -> println("Loaded ${state.result.size} items") + is FileKitPickerState.Failed -> println("Gallery picker failed: ${state.cause.message}") + is FileKitPickerState.Cancelled -> println("User cancelled the picker") + } +} +``` + ### Maximum number of items On both Android and iOS, you can set a limit on the number of items a user can select by passing the `maxItems` parameter to `FileKitMode.Multiple()`: diff --git a/docs/migrate-to-v0.10.mdx b/docs/migrate-to-v0.10.mdx index e38846a1..e4035cb1 100644 --- a/docs/migrate-to-v0.10.mdx +++ b/docs/migrate-to-v0.10.mdx @@ -65,6 +65,23 @@ FileKit v0.10 is a full rewrite of the library. It introduces a new API that is Take a look at the [Writing files](/core/write-file) documentation to learn more about the new API. + + File picker failures are now distinct from user cancellation. + + - `FileKit.openFilePicker()` with `Single` or `Multiple` returns `null` only when the user cancels. + - If the user selects files but FileKit cannot resolve them, FileKit throws `FileKitPickerException`. + - Stateful picker modes now emit `FileKitPickerState.Failed(cause)` instead of collapsing that case into `Cancelled`. + + ```kotlin + when (state) { + is FileKitPickerState.Started -> Unit + is FileKitPickerState.Progress -> Unit + is FileKitPickerState.Completed -> Unit + is FileKitPickerState.Failed -> println(state.cause.message) + is FileKitPickerState.Cancelled -> Unit + } + ``` + ## Feedback From 9a416a94333d18c962379c41322b34fde1f5622b Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 9 Mar 2026 14:08:56 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Trim=20picker=20documentatio?= =?UTF-8?q?n=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/file-picker.mdx | 23 ----------------------- docs/dialogs/gallery-picker.mdx | 25 ------------------------- docs/migrate-to-v0.10.mdx | 17 ----------------- 3 files changed, 65 deletions(-) diff --git a/docs/dialogs/file-picker.mdx b/docs/dialogs/file-picker.mdx index f6ebb8bd..fa4a325b 100644 --- a/docs/dialogs/file-picker.mdx +++ b/docs/dialogs/file-picker.mdx @@ -34,13 +34,6 @@ Select one or multiple files using the `mode` parameter. FileKit provides four d - `Single` - Allows the user to select a single file (default). - `Multiple()` - Allows the user to select multiple files. - -`Single` and `Multiple` now distinguish between user cancellation and picker I/O failures. - -- If the user dismisses the picker, FileKit returns `null`. -- If the user selects a file but FileKit cannot resolve it, FileKit throws `FileKitPickerException`. - - ```kotlin filekit-dialogs // Single file selection @@ -70,15 +63,6 @@ val multipleLauncher = rememberFilePickerLauncher( ``` -```kotlin -try { - val files = FileKit.openFilePicker(mode = FileKitMode.Multiple(maxItems = 5)) - println("Picked ${files?.size ?: 0} files") -} catch (error: FileKitPickerException) { - println("Failed to load one of the selected files: ${error.message}") -} -``` - ### State-tracking modes For advanced use cases where you need to track the selection progress: @@ -132,13 +116,6 @@ val stateLauncher = rememberFilePickerLauncher( ``` - -Stateful picker modes now preserve failure information explicitly: - -- `Cancelled` means the user dismissed the picker. -- `Failed` means the user selected one or more files, but FileKit could not resolve them. - - The `Multiple` and `MultipleWithState` modes support a `maxItems` parameter (1-50 files). If not specified, there's no limit. diff --git a/docs/dialogs/gallery-picker.mdx b/docs/dialogs/gallery-picker.mdx index 8741a207..b23ca1ed 100644 --- a/docs/dialogs/gallery-picker.mdx +++ b/docs/dialogs/gallery-picker.mdx @@ -38,31 +38,6 @@ val launcher = rememberFilePickerLauncher( ``` -### Failure handling - -Gallery pickers can fail after the user makes a valid selection, for example when a provider cannot vend a temporary file or a cloud-backed asset is not available locally yet. - -- `FileKitMode.Single` and `FileKitMode.Multiple` throw `FileKitPickerException` on these failures. -- `FileKitMode.SingleWithState` and `FileKitMode.MultipleWithState` emit `FileKitPickerState.Failed`. -- `FileKitPickerState.Cancelled` is reserved for user cancellation only. - -```kotlin -val stateFlow = FileKit.openFilePicker( - type = FileKitType.ImageAndVideo, - mode = FileKitMode.MultipleWithState(maxItems = 5), -) - -stateFlow.collect { state -> - when (state) { - is FileKitPickerState.Started -> println("Selection started") - is FileKitPickerState.Progress -> println("Loaded ${state.processed.size} / ${state.total}") - is FileKitPickerState.Completed -> println("Loaded ${state.result.size} items") - is FileKitPickerState.Failed -> println("Gallery picker failed: ${state.cause.message}") - is FileKitPickerState.Cancelled -> println("User cancelled the picker") - } -} -``` - ### Maximum number of items On both Android and iOS, you can set a limit on the number of items a user can select by passing the `maxItems` parameter to `FileKitMode.Multiple()`: diff --git a/docs/migrate-to-v0.10.mdx b/docs/migrate-to-v0.10.mdx index e4035cb1..e38846a1 100644 --- a/docs/migrate-to-v0.10.mdx +++ b/docs/migrate-to-v0.10.mdx @@ -65,23 +65,6 @@ FileKit v0.10 is a full rewrite of the library. It introduces a new API that is Take a look at the [Writing files](/core/write-file) documentation to learn more about the new API. - - File picker failures are now distinct from user cancellation. - - - `FileKit.openFilePicker()` with `Single` or `Multiple` returns `null` only when the user cancels. - - If the user selects files but FileKit cannot resolve them, FileKit throws `FileKitPickerException`. - - Stateful picker modes now emit `FileKitPickerState.Failed(cause)` instead of collapsing that case into `Cancelled`. - - ```kotlin - when (state) { - is FileKitPickerState.Started -> Unit - is FileKitPickerState.Progress -> Unit - is FileKitPickerState.Completed -> Unit - is FileKitPickerState.Failed -> println(state.cause.message) - is FileKitPickerState.Cancelled -> Unit - } - ``` - ## Feedback