diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d894cfd624..d1cde2f5e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ * `Cache`: * All cache repos now do not have `open` vals - to avoid collisions in runtime +## 0.24.8 + +* `Versions`: + * `Ktor`: `3.1.0` -> `3.1.1` + * `KSP`: `2.1.10-1.0.30` -> `2.1.10-1.0.31` +* `Common`: + * `Compose`: + * Add component `LoadableComponent` +* `Coroutines`: + * Add `SortedBinaryTreeNode` +* `Pagination`: + * `Compose`: + * Add components `PagedComponent` and `InfinityPagedComponent` + ## 0.24.7 * `Versions`: diff --git a/common/compose/build.gradle b/common/compose/build.gradle index df504ddfb76..eb6e9137d55 100644 --- a/common/compose/build.gradle +++ b/common/compose/build.gradle @@ -13,6 +13,7 @@ kotlin { commonMain { dependencies { api project(":micro_utils.common") + api libs.kt.coroutines } } } diff --git a/common/compose/src/commonMain/kotlin/dev/inmo/micro_utils/common/compose/LoadableComponent.kt b/common/compose/src/commonMain/kotlin/dev/inmo/micro_utils/common/compose/LoadableComponent.kt new file mode 100644 index 00000000000..8e93701ad1c --- /dev/null +++ b/common/compose/src/commonMain/kotlin/dev/inmo/micro_utils/common/compose/LoadableComponent.kt @@ -0,0 +1,74 @@ +package dev.inmo.micro_utils.common.compose + +import androidx.compose.runtime.* +import dev.inmo.micro_utils.common.Optional +import dev.inmo.micro_utils.common.dataOrThrow +import dev.inmo.micro_utils.common.optional + +class LoadableComponentContext internal constructor( + presetOptional: Optional, +) { + internal val iterationState: MutableState = mutableStateOf(0) + + internal var dataOptional: Optional = if (presetOptional.dataPresented) presetOptional else Optional.absent() + private set + internal val dataState: MutableState> = mutableStateOf(dataOptional) + + fun reload() { + iterationState.value++ + } +} + +/** + * Showing data with ability to reload data + * + * [block] will be shown when [loader] will complete loading. If you want to reload data, just call + * [LoadableComponentContext.reload] + */ +@Composable +fun LoadableComponent( + preload: Optional, + loader: suspend LoadableComponentContext.() -> T, + block: @Composable LoadableComponentContext.(T) -> Unit +) { + val context = remember { LoadableComponentContext(preload) } + + LaunchedEffect(context.iterationState.value) { + context.dataState.value = loader(context).optional + } + + context.dataState.let { + if (it.value.dataPresented) { + context.block(it.value.dataOrThrow(IllegalStateException("Data must be presented, but optional has been changed by some way"))) + } + } +} + +/** + * Showing data with ability to reload data + * + * [block] will be shown when [loader] will complete loading. If you want to reload data, just call + * [LoadableComponentContext.reload] + */ +@Composable +fun LoadableComponent( + preload: T, + loader: suspend LoadableComponentContext.() -> T, + block: @Composable LoadableComponentContext.(T) -> Unit +) { + LoadableComponent(preload.optional, loader, block) +} + +/** + * Showing data with ability to reload data + * + * [block] will be shown when [loader] will complete loading. If you want to reload data, just call + * [LoadableComponentContext.reload] + */ +@Composable +fun LoadableComponent( + loader: suspend LoadableComponentContext.() -> T, + block: @Composable LoadableComponentContext.(T) -> Unit +) { + LoadableComponent(Optional.absent(), loader, block) +} diff --git a/common/compose/src/jvmTest/kotlin/LoadableComponentTests.kt b/common/compose/src/jvmTest/kotlin/LoadableComponentTests.kt new file mode 100644 index 00000000000..dc33811f442 --- /dev/null +++ b/common/compose/src/jvmTest/kotlin/LoadableComponentTests.kt @@ -0,0 +1,42 @@ +import androidx.compose.runtime.remember +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import dev.inmo.micro_utils.common.compose.LoadableComponent +import dev.inmo.micro_utils.coroutines.SpecialMutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import org.jetbrains.annotations.TestOnly +import kotlin.test.Test +import kotlin.test.assertTrue + +class LoadableComponentTests { + @OptIn(ExperimentalTestApi::class) + @Test + @TestOnly + fun testSimpleLoad() = runComposeUiTest { + val loadingFlow = SpecialMutableStateFlow(0) + val loadedFlow = SpecialMutableStateFlow(0) + setContent { + LoadableComponent({ + loadingFlow.filter { it == 1 }.first() + }) { + assert(dataState.value.data == 1) + remember { + loadedFlow.value = 2 + } + } + } + + waitForIdle() + + assertTrue(loadedFlow.value == 0) + + loadingFlow.value = 1 + + waitForIdle() + + assertTrue(loadedFlow.value == 2) + } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/dev/inmo/micro_utils/common/Comparator.kt b/common/src/commonMain/kotlin/dev/inmo/micro_utils/common/Comparator.kt new file mode 100644 index 00000000000..61ce00847fe --- /dev/null +++ b/common/src/commonMain/kotlin/dev/inmo/micro_utils/common/Comparator.kt @@ -0,0 +1,6 @@ +package dev.inmo.micro_utils.common + +/** + * Creates simple [Comparator] which will use [compareTo] of [T] for both objects + */ +fun , C : T> T.createComparator() = Comparator { o1, o2 -> o1.compareTo(o2) } diff --git a/coroutines/src/androidUnitTest/kotlin/SortedBinaryTreeNodeTests.android.kt b/coroutines/src/androidUnitTest/kotlin/SortedBinaryTreeNodeTests.android.kt new file mode 100644 index 00000000000..75410922c07 --- /dev/null +++ b/coroutines/src/androidUnitTest/kotlin/SortedBinaryTreeNodeTests.android.kt @@ -0,0 +1,2 @@ +actual val AllowDeepInsertOnWorksTest: Boolean + get() = true \ No newline at end of file diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt new file mode 100644 index 00000000000..fade45c3e61 --- /dev/null +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/collections/SortedBinaryTreeNode.kt @@ -0,0 +1,319 @@ +package dev.inmo.micro_utils.coroutines.collections + +import dev.inmo.micro_utils.coroutines.SmartRWLocker +import dev.inmo.micro_utils.coroutines.waitReadRelease +import dev.inmo.micro_utils.coroutines.withReadAcquire +import dev.inmo.micro_utils.coroutines.withWriteLock +import kotlinx.coroutines.job +import kotlinx.serialization.Serializable +import kotlin.coroutines.coroutineContext + +/** + * Creates simple [Comparator] which will use [compareTo] of [T] for both objects + */ +private fun , C : T> T.createComparator() = Comparator { o1, o2 -> o1.compareTo(o2) } + +@Serializable +class SortedBinaryTreeNode( + val data: T, + internal val comparator: Comparator, +) : Iterable> { + internal var leftNode: SortedBinaryTreeNode? = null + internal var rightNode: SortedBinaryTreeNode? = null + internal val locker: SmartRWLocker by lazy { + SmartRWLocker() + } + + suspend fun getLeftNode() = locker.withReadAcquire { + leftNode + } + + suspend fun getRightNode() = locker.withReadAcquire { + rightNode + } + + suspend fun getLeft() = getLeftNode() ?.data + + suspend fun getRight() = getRightNode() ?.data + + override fun equals(other: Any?): Boolean { + return other === this || (other is SortedBinaryTreeNode<*> && other.data == data && other.rightNode == rightNode && other.leftNode == leftNode) + } + + override fun hashCode(): Int { + return data.hashCode() * 31 + rightNode.hashCode() + leftNode.hashCode() + } + + suspend fun size(): Int { + return locker.withReadAcquire { + 1 + (leftNode ?.size() ?: 0) + (rightNode ?.size() ?: 0) + } + } + + /** + * This [Iterator] will run from less to greater values of nodes starting the + * [dev.inmo.micro_utils.coroutines.collections.SortedBinaryTreeNode]-receiver. Due to non-suspending + * nature of [iterator] builder, this [Iterator] **DO NOT** guarantee consistent content due to iterations. It + * means, that tree can be changed during to iteration process + */ + override fun iterator(): Iterator> = iterator { + leftNode ?.let { + it.iterator().forEach { yield(it) } + } + yield(this@SortedBinaryTreeNode) + rightNode ?.let { + it.iterator().forEach { yield(it) } + } + } + + override fun toString(): String { + return "$data($leftNode;$rightNode)" + } + + companion object { + operator fun > invoke( + data: T, + ) = SortedBinaryTreeNode( + data, + data.createComparator() + ) + } +} + +/** + * Will add subnode in tree if there are no any node with [newData] + * + * * If [newData] is greater than [SortedBinaryTreeNode.data] of currently checking node, + * will be used [SortedBinaryTreeNode.rightNode] + * * If [newData] is equal to [SortedBinaryTreeNode.data] of currently + * checking node - will be returned currently checking node + * * If [newData] is less than [SortedBinaryTreeNode.data] of currently + * checking node - will be used [SortedBinaryTreeNode.leftNode] + * + * This process will continue until function will not find place to put [SortedBinaryTreeNode] with data or + * [SortedBinaryTreeNode] with [SortedBinaryTreeNode.data] same as [newData] will be found + */ +private suspend fun SortedBinaryTreeNode.addSubNode( + subNode: SortedBinaryTreeNode, + skipLockers: Set = emptySet() +): SortedBinaryTreeNode { + var currentlyChecking = this + val lockedLockers = mutableSetOf() + try { + while (coroutineContext.job.isActive) { + if (currentlyChecking.locker !in lockedLockers && currentlyChecking.locker !in skipLockers) { + currentlyChecking.locker.lockWrite() + lockedLockers.add(currentlyChecking.locker) + } + val left = currentlyChecking.leftNode + val right = currentlyChecking.rightNode + val comparingResult = currentlyChecking.comparator.compare(subNode.data, currentlyChecking.data) + val isGreater = comparingResult > 0 + when { + comparingResult == 0 -> return currentlyChecking + isGreater && right == null -> { + currentlyChecking.rightNode = subNode + return subNode + } + isGreater && right != null -> { + currentlyChecking = right + } + left == null -> { + currentlyChecking.leftNode = subNode + return subNode + } + else -> { + currentlyChecking = left + } + } + } + } finally { + lockedLockers.forEach { + runCatching { it.unlockWrite() } + } + } + error("Unable to add node") +} + +/** + * Will add subnode in tree if there are no any node with [newData] + * + * * If [newData] is greater than [SortedBinaryTreeNode.data] of currently checking node, + * will be used [SortedBinaryTreeNode.rightNode] + * * If [newData] is equal to [SortedBinaryTreeNode.data] of currently + * checking node - will be returned currently checking node + * * If [newData] is less than [SortedBinaryTreeNode.data] of currently + * checking node - will be used [SortedBinaryTreeNode.leftNode] + * + * This process will continue until function will not find place to put [SortedBinaryTreeNode] with data or + * [SortedBinaryTreeNode] with [SortedBinaryTreeNode.data] same as [newData] will be found + */ +suspend fun SortedBinaryTreeNode.addSubNode(newData: T): SortedBinaryTreeNode { + return addSubNode( + SortedBinaryTreeNode(newData, comparator) + ) +} + +suspend fun SortedBinaryTreeNode.findParentNode(data: T): SortedBinaryTreeNode? { + var currentParent: SortedBinaryTreeNode? = null + var currentlyChecking: SortedBinaryTreeNode? = this + val lockedLockers = mutableSetOf() + try { + while (coroutineContext.job.isActive) { + if (currentlyChecking == null) { + return null + } + if (currentlyChecking.locker !in lockedLockers) { + currentlyChecking.locker.acquireRead() + lockedLockers.add(currentlyChecking.locker) + } + val comparingResult = currentlyChecking.comparator.compare(data, currentlyChecking.data) + when { + comparingResult > 0 -> { + currentParent = currentlyChecking + currentlyChecking = currentlyChecking.rightNode + continue + } + comparingResult < 0 -> { + currentParent = currentlyChecking + currentlyChecking = currentlyChecking.leftNode + continue + } + else -> return currentParent + } + } + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + error("Unable to find node") +} + +/** + * Will remove (detach) node from tree starting with [this] [SortedBinaryTreeNode] + * + * @return If data were found, [Pair] where [Pair.first] is the parent node where from [Pair.second] has been detached; + * null otherwise + */ +suspend fun SortedBinaryTreeNode.removeSubNode(data: T): Pair, SortedBinaryTreeNode>? { + val onFoundToRemoveCallback: suspend SortedBinaryTreeNode.(left: SortedBinaryTreeNode?, right: SortedBinaryTreeNode?) -> Unit = { left, right -> + left ?.also { leftNode -> addSubNode(leftNode, setOf(locker)) } + right ?.also { rightNode -> addSubNode(rightNode, setOf(locker)) } + } + while (coroutineContext.job.isActive) { + val foundParentNode = findParentNode(data) ?: return null + foundParentNode.locker.withWriteLock { + val left = foundParentNode.leftNode + val right = foundParentNode.rightNode + when { + left != null && left.comparator.compare(data, left.data) == 0 -> { + foundParentNode.leftNode = null + foundParentNode.onFoundToRemoveCallback(left.leftNode, left.rightNode) + return foundParentNode to left + } + right != null && right.comparator.compare(data, right.data) == 0 -> { + foundParentNode.rightNode = null + foundParentNode.onFoundToRemoveCallback(right.leftNode, right.rightNode) + return foundParentNode to right + } + else -> { + return@withWriteLock // data has been changed, new search required + } + } + } + } + error("Unable to remove node") +} +suspend fun SortedBinaryTreeNode.findNode(data: T): SortedBinaryTreeNode? { + var currentlyChecking: SortedBinaryTreeNode? = this + val lockedLockers = mutableSetOf() + try { + while (coroutineContext.job.isActive) { + if (currentlyChecking == null) { + return null + } + if (currentlyChecking.locker !in lockedLockers) { + currentlyChecking.locker.acquireRead() + lockedLockers.add(currentlyChecking.locker) + } + val comparingResult = currentlyChecking.comparator.compare(data, currentlyChecking.data) + when { + comparingResult > 0 -> { + currentlyChecking = currentlyChecking.rightNode + continue + } + comparingResult < 0 -> { + currentlyChecking = currentlyChecking.leftNode + continue + } + else -> return currentlyChecking + } + } + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + error("Unable to find node") +} +suspend fun SortedBinaryTreeNode.contains(data: T): Boolean = findNode(data) != null + +suspend fun SortedBinaryTreeNode.findNodesInRange(from: T, to: T, fromInclusiveMode: Boolean, toInclusiveMode: Boolean): Set> { + val results = mutableSetOf>() + val leftToCheck = mutableSetOf(this) + val lockedLockers = mutableSetOf() + val fromComparingFun: (SortedBinaryTreeNode) -> Boolean = if (fromInclusiveMode) { + { it.comparator.compare(from, it.data) <= 0 } + } else { + { it.comparator.compare(from, it.data) < 0 } + } + val toComparingFun: (SortedBinaryTreeNode) -> Boolean = if (toInclusiveMode) { + { it.comparator.compare(to, it.data) >= 0 } + } else { + { it.comparator.compare(to, it.data) > 0 } + } + try { + while (coroutineContext.job.isActive && leftToCheck.isNotEmpty()) { + val currentlyChecking = leftToCheck.first() + leftToCheck.remove(currentlyChecking) + if (currentlyChecking in results) { + continue + } + currentlyChecking.locker.acquireRead() + lockedLockers.add(currentlyChecking.locker) + if (fromComparingFun(currentlyChecking) && toComparingFun(currentlyChecking)) { + results.add(currentlyChecking) + currentlyChecking.leftNode ?.let { leftToCheck.add(it) } + currentlyChecking.rightNode ?.let { leftToCheck.add(it) } + continue + } + when { + currentlyChecking.comparator.compare(to, currentlyChecking.data) < 0 -> currentlyChecking.leftNode ?.let { leftToCheck.add(it) } + currentlyChecking.comparator.compare(from, currentlyChecking.data) > 0 -> currentlyChecking.rightNode ?.let { leftToCheck.add(it) } + } + } + return results.toSet() + } finally { + lockedLockers.forEach { + runCatching { it.releaseRead() } + } + } + error("Unable to find nodes range") +} +suspend fun SortedBinaryTreeNode.findNodesInRange(from: T, to: T): Set> = findNodesInRange( + from = from, + to = to, + fromInclusiveMode = true, + toInclusiveMode = true +) +suspend fun SortedBinaryTreeNode.findNodesInRangeExcluding(from: T, to: T): Set> = findNodesInRange( + from = from, + to = to, + fromInclusiveMode = false, + toInclusiveMode = false +) +suspend fun > SortedBinaryTreeNode.findNodesInRange(range: ClosedRange): Set> = findNodesInRange( + from = range.start, + to = range.endInclusive, +) \ No newline at end of file diff --git a/coroutines/src/commonTest/kotlin/SortedBinaryTreeNodeTests.kt b/coroutines/src/commonTest/kotlin/SortedBinaryTreeNodeTests.kt new file mode 100644 index 00000000000..03f81d15ace --- /dev/null +++ b/coroutines/src/commonTest/kotlin/SortedBinaryTreeNodeTests.kt @@ -0,0 +1,178 @@ +import dev.inmo.micro_utils.coroutines.collections.SortedBinaryTreeNode +import dev.inmo.micro_utils.coroutines.collections.addSubNode +import dev.inmo.micro_utils.coroutines.collections.findNode +import dev.inmo.micro_utils.coroutines.collections.findNodesInRange +import dev.inmo.micro_utils.coroutines.collections.findParentNode +import dev.inmo.micro_utils.coroutines.collections.removeSubNode +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +expect val AllowDeepInsertOnWorksTest: Boolean + +class SortedBinaryTreeNodeTests { + @Test + fun insertOnZeroLevelWorks() = runTest { + val zeroNode = SortedBinaryTreeNode(0) + zeroNode.addSubNode(1) + zeroNode.addSubNode(-1) + + assertEquals(0, zeroNode.data) + assertEquals(1, zeroNode.getRightNode() ?.data) + assertEquals(-1, zeroNode.getLeftNode() ?.data) + } + @Test + fun searchOnZeroLevelWorks() = runTest { + val zeroNode = SortedBinaryTreeNode(0) + val oneNode = zeroNode.addSubNode(1) + val minusOneNode = zeroNode.addSubNode(-1) + + val assertingNodesToSearchQuery = mapOf( + setOf(oneNode) to (1 .. 1), + setOf(zeroNode, oneNode) to (0 .. 1), + setOf(minusOneNode, zeroNode, oneNode) to (-1 .. 1), + setOf(minusOneNode, zeroNode) to (-1 .. 0), + setOf(minusOneNode) to (-1 .. -1), + setOf(zeroNode) to (0 .. 0), + ) + + assertingNodesToSearchQuery.forEach { + val foundData = zeroNode.findNodesInRange(it.value) + assertTrue(foundData.containsAll(it.key)) + assertTrue(it.key.containsAll(foundData)) + } + } + @Test + fun deepReInsertOnWorks() = runTest(timeout = 300.seconds) { + if (AllowDeepInsertOnWorksTest == false) return@runTest + val zeroNode = SortedBinaryTreeNode(0) + val rangeRadius = 500 + val nodes = mutableMapOf>() + for (i in -rangeRadius .. rangeRadius) { + nodes[i] = zeroNode.addSubNode(i) + } + + for (i in -rangeRadius .. rangeRadius) { + val expectedNode = nodes.getValue(i) + val foundNode = zeroNode.findNode(i) + + assertTrue(expectedNode === foundNode) + + if (expectedNode === zeroNode) continue + + val parentNode = zeroNode.findParentNode(i) + assertTrue( + parentNode ?.getLeftNode() === expectedNode || parentNode ?.getRightNode() === expectedNode, + "It is expected, that parent node with data ${parentNode ?.data} will be parent of ${expectedNode.data}, but its left subnode is ${parentNode ?.getLeftNode() ?.data} and right one is ${parentNode ?.getRightNode() ?.data}" + ) + } + + val sourceTreeSize = zeroNode.size() + assertTrue(sourceTreeSize == nodes.size) + assertTrue(sourceTreeSize == (rangeRadius * 2 + 1)) + + for (i in -rangeRadius .. rangeRadius) { + val expectedNode = nodes.getValue(i) + val parentNode = zeroNode.findParentNode(i) + + if (parentNode == null && i == zeroNode.data && expectedNode === zeroNode) continue + + assertTrue(parentNode != null, "It is expected, that parent node of ${expectedNode.data} will not be null") + + assertTrue( + parentNode.getLeftNode() === expectedNode || parentNode.getRightNode() === expectedNode, + "It is expected, that parent node with data ${parentNode ?.data} will be parent of ${expectedNode.data}, but its left subnode is ${parentNode ?.getLeftNode() ?.data} and right one is ${parentNode ?.getRightNode() ?.data}" + ) + + val removeResult = zeroNode.removeSubNode(i) + assertTrue(removeResult ?.first === parentNode) + assertTrue(removeResult.second === expectedNode) + + nodes[i] = zeroNode.addSubNode(i) + assertTrue(nodes[i] != null) + assertTrue(nodes[i] != expectedNode) + assertTrue(nodes[i] ?.data == i) + } + + assertTrue(sourceTreeSize == zeroNode.size()) + + for (i in -rangeRadius .. rangeRadius) { + val expectedNode = nodes.getValue(i) + val foundNode = zeroNode.findNode(i) + + assertTrue(expectedNode === foundNode) + + if (expectedNode === zeroNode) continue + + val parentNode = zeroNode.findParentNode(i) + assertTrue( + parentNode ?.getLeftNode() === expectedNode || parentNode ?.getRightNode() === expectedNode, + "It is expected, that parent node with data ${parentNode ?.data} will be parent of ${expectedNode.data}, but its left subnode is ${parentNode ?.getLeftNode() ?.data} and right one is ${parentNode ?.getRightNode() ?.data}" + ) + } + + var previousData = -rangeRadius - 1 + for (node in zeroNode) { + assertTrue(nodes[node.data] === node) + assertTrue(previousData == node.data - 1) + previousData = node.data + } + + assertTrue(sourceTreeSize == zeroNode.size()) + } + @Test + fun deepInsertOnWorks() = runTest(timeout = 240.seconds) { + if (AllowDeepInsertOnWorksTest == false) return@runTest + val zeroNode = SortedBinaryTreeNode(0) + val rangeRadius = 500 + val nodes = mutableMapOf>() + for (i in -rangeRadius .. rangeRadius) { + nodes[i] = zeroNode.addSubNode(i) + } + + for (i in -rangeRadius .. rangeRadius) { + val expectedNode = nodes.getValue(i) + val foundNode = zeroNode.findNode(i) + + assertTrue(expectedNode === foundNode) + + if (expectedNode === zeroNode) continue + + val parentNode = zeroNode.findParentNode(i) + assertTrue( + parentNode ?.getLeftNode() === expectedNode || parentNode ?.getRightNode() === expectedNode, + "It is expected, that parent node with data ${parentNode ?.data} will be parent of ${expectedNode.data}, but its left subnode is ${parentNode ?.getLeftNode() ?.data} and right one is ${parentNode ?.getRightNode() ?.data}" + ) + } + + val sourceTreeSize = zeroNode.size() + + var previousData = -rangeRadius - 1 + for (node in zeroNode) { + assertTrue(nodes[node.data] === node) + assertTrue(previousData == node.data - 1) + previousData = node.data + } + + assertTrue(sourceTreeSize == zeroNode.size()) + } + @Test + fun deepInsertIteratorWorking() = runTest { + val zeroNode = SortedBinaryTreeNode(0) + val rangeRadius = 500 + val nodes = mutableMapOf>() + for (i in -rangeRadius .. rangeRadius) { + nodes[i] = zeroNode.addSubNode(i) + } + + var previousData = -rangeRadius - 1 + for (node in zeroNode) { + assertTrue(nodes[node.data] === node) + assertTrue(previousData == node.data - 1) + previousData = node.data + } + assertTrue(previousData == rangeRadius) + } +} \ No newline at end of file diff --git a/coroutines/src/jsTest/kotlin/SortedBinaryTreeNodeTests.js.kt b/coroutines/src/jsTest/kotlin/SortedBinaryTreeNodeTests.js.kt new file mode 100644 index 00000000000..4f2e6974a96 --- /dev/null +++ b/coroutines/src/jsTest/kotlin/SortedBinaryTreeNodeTests.js.kt @@ -0,0 +1,2 @@ +actual val AllowDeepInsertOnWorksTest: Boolean + get() = false \ No newline at end of file diff --git a/coroutines/src/jvmTest/kotlin/SortedBinaryTreeNodeTests.jvm.kt b/coroutines/src/jvmTest/kotlin/SortedBinaryTreeNodeTests.jvm.kt new file mode 100644 index 00000000000..75410922c07 --- /dev/null +++ b/coroutines/src/jvmTest/kotlin/SortedBinaryTreeNodeTests.jvm.kt @@ -0,0 +1,2 @@ +actual val AllowDeepInsertOnWorksTest: Boolean + get() = true \ No newline at end of file diff --git a/coroutines/src/linuxArm64Test/kotlin/SortedBinaryTreeNodeTests.linuxArm64.kt b/coroutines/src/linuxArm64Test/kotlin/SortedBinaryTreeNodeTests.linuxArm64.kt new file mode 100644 index 00000000000..75410922c07 --- /dev/null +++ b/coroutines/src/linuxArm64Test/kotlin/SortedBinaryTreeNodeTests.linuxArm64.kt @@ -0,0 +1,2 @@ +actual val AllowDeepInsertOnWorksTest: Boolean + get() = true \ No newline at end of file diff --git a/coroutines/src/linuxX64Test/kotlin/SortedBinaryTreeNodeTests.linuxX64.kt b/coroutines/src/linuxX64Test/kotlin/SortedBinaryTreeNodeTests.linuxX64.kt new file mode 100644 index 00000000000..75410922c07 --- /dev/null +++ b/coroutines/src/linuxX64Test/kotlin/SortedBinaryTreeNodeTests.linuxX64.kt @@ -0,0 +1,2 @@ +actual val AllowDeepInsertOnWorksTest: Boolean + get() = true \ No newline at end of file diff --git a/coroutines/src/mingwX64Test/kotlin/SortedBinaryTreeNodeTests.mingwX64.kt b/coroutines/src/mingwX64Test/kotlin/SortedBinaryTreeNodeTests.mingwX64.kt new file mode 100644 index 00000000000..75410922c07 --- /dev/null +++ b/coroutines/src/mingwX64Test/kotlin/SortedBinaryTreeNodeTests.mingwX64.kt @@ -0,0 +1,2 @@ +actual val AllowDeepInsertOnWorksTest: Boolean + get() = true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 744238c128f..2135de11fce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ sqlite = "3.49.1.0" korlibs = "5.4.0" uuid = "0.8.4" -ktor = "3.1.0" +ktor = "3.1.1" gh-release = "2.5.2" @@ -23,7 +23,7 @@ koin = "4.0.2" okio = "3.10.2" -ksp = "2.1.10-1.0.30" +ksp = "2.1.10-1.0.31" kotlin-poet = "1.18.1" versions = "0.51.0" diff --git a/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle b/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle index 093baec0738..5e2606e4b50 100644 --- a/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle +++ b/gradle/templates/mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project.gradle @@ -15,14 +15,14 @@ kotlin { browser { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } nodejs { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } diff --git a/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle b/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle index 0da72807753..574b69a0107 100644 --- a/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle +++ b/gradle/templates/mppJvmJsAndroidLinuxMingwLinuxArm64Project.gradle @@ -15,14 +15,14 @@ kotlin { browser { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } nodejs { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } diff --git a/gradle/templates/mppJvmJsLinuxMingwProject.gradle b/gradle/templates/mppJvmJsLinuxMingwProject.gradle index 82e5a4025b7..b62c12dc548 100644 --- a/gradle/templates/mppJvmJsLinuxMingwProject.gradle +++ b/gradle/templates/mppJvmJsLinuxMingwProject.gradle @@ -15,14 +15,14 @@ kotlin { browser { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } nodejs { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } diff --git a/gradle/templates/mppProjectWithSerializationAndCompose.gradle b/gradle/templates/mppProjectWithSerializationAndCompose.gradle index f0cea653e66..0a5c853bf56 100644 --- a/gradle/templates/mppProjectWithSerializationAndCompose.gradle +++ b/gradle/templates/mppProjectWithSerializationAndCompose.gradle @@ -15,14 +15,14 @@ kotlin { browser { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } nodejs { testTask { useMocha { - timeout = "60000" + timeout = "240000" } } } diff --git a/pagination/common/src/commonMain/kotlin/dev/inmo/micro_utils/pagination/Pagination.kt b/pagination/common/src/commonMain/kotlin/dev/inmo/micro_utils/pagination/Pagination.kt index d6d70d24748..519c9c43bfb 100644 --- a/pagination/common/src/commonMain/kotlin/dev/inmo/micro_utils/pagination/Pagination.kt +++ b/pagination/common/src/commonMain/kotlin/dev/inmo/micro_utils/pagination/Pagination.kt @@ -40,6 +40,8 @@ fun Pagination.intersect( inline val Pagination.isFirstPage get() = page == 0 +fun Pagination.firstPage() = if (isFirstPage) this else SimplePagination(0, size) + /** * First number in index of objects. It can be used as offset for databases or other data sources */ diff --git a/pagination/compose/build.gradle b/pagination/compose/build.gradle new file mode 100644 index 00000000000..df1a5bb3725 --- /dev/null +++ b/pagination/compose/build.gradle @@ -0,0 +1,21 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" + id "com.android.library" + alias(libs.plugins.jb.compose) + alias(libs.plugins.kt.jb.compose) +} + +apply from: "$mppComposeJvmJsAndroidLinuxMingwLinuxArm64Project" + +kotlin { + sourceSets { + commonMain { + dependencies { + api project(":micro_utils.pagination.common") + api project(":micro_utils.common.compose") + api project(":micro_utils.coroutines") + } + } + } +} diff --git a/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt b/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt new file mode 100644 index 00000000000..b94590f80e8 --- /dev/null +++ b/pagination/compose/src/commonMain/kotlin/InfinityPagedComponent.kt @@ -0,0 +1,120 @@ +package dev.inmo.micro_utils.pagination.compose + +import androidx.compose.runtime.* +import dev.inmo.micro_utils.pagination.* + +/** + * Context for managing infinite pagination in a Compose UI. + * + * @param T The type of the data being paginated. + * @property iterationState Holds the current pagination state and iteration count. + * @property dataState Stores the loaded data, initially null. + * @constructor Internal constructor to initialize pagination. + * @param page Initial page number. + * @param size Number of items per page. + */ +class InfinityPagedComponentContext internal constructor( + page: Int, + size: Int +) { + internal val startPage = SimplePagination(page, size) + internal val iterationState: MutableState> = mutableStateOf(0 to null) + internal val dataState: MutableState?> = mutableStateOf(null) + internal var lastPageLoaded = false + + /** + * Loads the next page of data. If the current page is the last one, the function returns early. + */ + fun loadNext() { + if (lastPageLoaded) return + if (iterationState.value.second is SimplePagination) return // Data loading has been inited but not loaded yet + + iterationState.value = iterationState.value.let { + if ((it.second as? PaginationResult<*>) ?.isLastPage == true) return + (it.first + 1) to (it.second ?: startPage).nextPage() + } + } + + /** + * Reloads the pagination from the first page, clearing previously loaded data. + */ + fun reload() { + dataState.value = null + lastPageLoaded = false + iterationState.value = iterationState.value.let { + (it.first + 1) to null + } + } +} + +/** + * Composable function for managing an infinitely paged component. + * + * @param T The type of the paginated data. + * @param page Initial page number. + * @param size Number of items per page. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. When data is in loading state, block will + * receive null as `it` parameter + */ +@Composable +internal fun InfinityPagedComponent( + page: Int, + size: Int, + loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable InfinityPagedComponentContext.(List?) -> Unit +) { + val context = remember { InfinityPagedComponentContext(page, size) } + + LaunchedEffect(context.iterationState.value.first) { + val paginationResult = loader(context, context.iterationState.value.second ?: context.startPage) + if (paginationResult.isLastPage) { + context.lastPageLoaded = true + } + context.iterationState.value = context.iterationState.value.copy(second = paginationResult) + context.dataState.value = (context.dataState.value ?: emptyList()) + paginationResult.results + } + + context.block(context.dataState.value) +} + +/** + * Overloaded composable function for an infinitely paged component. + * + * @param T The type of the paginated data. + * @param pageInfo Initial pagination information. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. When data is in loading state, block will + * receive null as `it` parameter + */ +@Composable +fun InfinityPagedComponent( + pageInfo: Pagination, + loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable InfinityPagedComponentContext.(List?) -> Unit +) { + InfinityPagedComponent( + pageInfo.page, + pageInfo.size, + loader, + block + ) +} + +/** + * Overloaded composable function for an infinitely paged component. + * + * @param T The type of the paginated data. + * @param size Number of items per page. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. When data is in loading state, block will + * receive null as `it` parameter + */ +@Composable +fun InfinityPagedComponent( + size: Int, + loader: suspend InfinityPagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable InfinityPagedComponentContext.(List?) -> Unit +) { + InfinityPagedComponent(0, size, loader, block) +} diff --git a/pagination/compose/src/commonMain/kotlin/PagedComponent.kt b/pagination/compose/src/commonMain/kotlin/PagedComponent.kt new file mode 100644 index 00000000000..88127bf0563 --- /dev/null +++ b/pagination/compose/src/commonMain/kotlin/PagedComponent.kt @@ -0,0 +1,174 @@ +package dev.inmo.micro_utils.pagination.compose + +import androidx.compose.runtime.* +import dev.inmo.micro_utils.common.Optional +import dev.inmo.micro_utils.common.dataOrThrow +import dev.inmo.micro_utils.common.optional +import dev.inmo.micro_utils.pagination.* + +/** + * Context for managing paginated data in a Compose UI. + * + * @param T The type of data being paginated. + * @property iterationState Holds the current pagination state and iteration count. + * @property dataOptional Stores the optional preloaded pagination result. + * @property dataState Stores the current pagination result. + * @constructor Internal constructor for setting up pagination. + * @param preset Optional preset pagination result. + * @param initialPage Initial page number. + * @param size Number of items per page. + */ +class PagedComponentContext internal constructor( + preset: PaginationResult? = null, + initialPage: Int, + size: Int +) { + internal val iterationState: MutableState> = mutableStateOf(0 to SimplePagination(preset?.page ?: initialPage, preset?.size ?: size)) + + internal var dataOptional: PaginationResult? = preset + private set + internal val dataState: MutableState?> = mutableStateOf(dataOptional) + + /** + * Loads the next page of data. If the last page is reached, this function returns early. + */ + fun loadNext() { + iterationState.value = iterationState.value.let { + if (dataState.value ?.isLastPage == true) return + (it.first + 1) to it.second.nextPage() + } + } + + /** + * Loads the previous page of data if available. + */ + fun loadPrevious() { + iterationState.value = iterationState.value.let { + if (it.second.isFirstPage) return + (it.first - 1) to SimplePagination( + it.second.page - 1, + it.second.size + ) + } + } + + /** + * Reloads the current page, refreshing the data. + */ + fun reload() { + iterationState.value = iterationState.value.let { + it.copy(it.first + 1) + } + } +} + +/** + * Composable function for paginated data displaying in a Compose UI. + * + * @param T The type of paginated data. + * @param preload Optional preloaded pagination result. + * @param initialPage Initial page number. + * @param size Number of items per page. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. + */ +@Composable +internal fun PagedComponent( + preload: PaginationResult?, + initialPage: Int, + size: Int, + loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable PagedComponentContext.(PaginationResult) -> Unit +) { + val context = remember { PagedComponentContext(preload, initialPage, size) } + + LaunchedEffect(context.iterationState.value) { + context.dataState.value = loader(context, context.iterationState.value.second) + } + + context.dataState.value ?.let { + context.block(it) + } +} + +/** + * Overloaded composable function for paginated components with preloaded data. + * + * @param T The type of paginated data. + * @param preload Preloaded pagination result. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. + */ +@Composable +fun PagedComponent( + preload: PaginationResult, + loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable PagedComponentContext.(PaginationResult) -> Unit +) { + PagedComponent( + preload, + preload.page, + preload.size, + loader, + block + ) +} + +/** + * Overloaded composable function for paginated components with pagination info. + * + * @param T The type of paginated data. + * @param pageInfo Initial pagination information. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. + */ +@Composable +fun PagedComponent( + pageInfo: Pagination, + loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable PagedComponentContext.(PaginationResult) -> Unit +) { + PagedComponent( + null, + pageInfo.page, + pageInfo.size, + loader, + block + ) +} + +/** + * Overloaded composable function for paginated components with an initial page. + * + * @param T The type of paginated data. + * @param initialPage Initial page number. + * @param size Number of items per page. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. + */ +@Composable +fun PagedComponent( + initialPage: Int, + size: Int, + loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable PagedComponentContext.(PaginationResult) -> Unit +) { + PagedComponent(null, initialPage, size, loader, block) +} + +/** + * Overloaded composable function for paginated components with only a size parameter. + * + * @param T The type of paginated data. + * @param size Number of items per page. + * @param loader Suspended function that loads paginated data. + * @param block Composable function that renders the UI with the loaded data. + */ +@Composable +fun PagedComponent( + size: Int, + loader: suspend PagedComponentContext.(Pagination) -> PaginationResult, + block: @Composable PagedComponentContext.(PaginationResult) -> Unit +) { + PagedComponent(0, size, loader, block) +} diff --git a/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt b/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt new file mode 100644 index 00000000000..0dfb82a7fe8 --- /dev/null +++ b/pagination/compose/src/jvmTest/kotlin/InfinityPagedComponentTests.kt @@ -0,0 +1,53 @@ +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import dev.inmo.micro_utils.pagination.* +import dev.inmo.micro_utils.pagination.compose.InfinityPagedComponent +import dev.inmo.micro_utils.pagination.compose.PagedComponent +import org.jetbrains.annotations.TestOnly +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class InfinityPagedComponentTests { + @OptIn(ExperimentalTestApi::class) + @Test + @TestOnly + fun testSimpleLoad() = runComposeUiTest { + var expectedList = listOf() + setContent { + InfinityPagedComponent( + size = 1, + loader = { + PaginationResult( + page = it.page, + size = it.size, + results = (it.firstIndex .. it.lastIndex).toList(), + objectsNumber = 3 + ).also { + expectedList += it.results + } + } + ) { + if (it == null) { + if (this.iterationState.value.second != null) { + assertEquals(0, (this.iterationState.value.second as? SimplePagination) ?.page) + } + } else { + assertEquals(expectedList, it) + } + + LaunchedEffect(it ?.size) { + loadNext() + } + } + } + + waitForIdle() + + assertContentEquals( + listOf(0, 1, 2), + expectedList + ) + } +} \ No newline at end of file diff --git a/pagination/compose/src/jvmTest/kotlin/PagedComponentTests.kt b/pagination/compose/src/jvmTest/kotlin/PagedComponentTests.kt new file mode 100644 index 00000000000..375db11cdef --- /dev/null +++ b/pagination/compose/src/jvmTest/kotlin/PagedComponentTests.kt @@ -0,0 +1,64 @@ +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import dev.inmo.micro_utils.pagination.* +import dev.inmo.micro_utils.pagination.compose.PagedComponent +import org.jetbrains.annotations.TestOnly +import kotlin.test.Test +import kotlin.test.assertEquals + +class PagedComponentTests { + @OptIn(ExperimentalTestApi::class) + @Test + @TestOnly + fun testSimpleLoad() = runComposeUiTest { + var expectedPage = PaginationResult( + page = 0, + size = 1, + results = listOf(0), + objectsNumber = 3 + ) + var previousPage = expectedPage + setContent { + PagedComponent( + initialPage = 0, + size = 1, + loader = { + previousPage = expectedPage + expectedPage = PaginationResult( + page = it.page, + size = it.size, + results = (it.firstIndex .. it.lastIndex).toList(), + objectsNumber = 3 + ) + expectedPage + } + ) { + assertEquals(expectedPage, it) + assertEquals(expectedPage.results, it.results) + + if (it.isLastPage || it.page < previousPage.page) { + if (it.isFirstPage) { + // do nothing - end of test + } else { + loadPrevious() + } + } else { + loadNext() + } + } + } + + waitForIdle() + + assertEquals( + PaginationResult( + page = 0, + size = 1, + results = listOf(0), + objectsNumber = 3 + ), + expectedPage + ) + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 6379141d8cb..a8e6a646598 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ String[] includes = [ ":koin:generator:test", ":selector:common", ":pagination:common", + ":pagination:compose", ":pagination:exposed", ":pagination:ktor:common", ":pagination:ktor:server",