diff --git a/CHANGELOG.md b/CHANGELOG.md
index e05cd32f179..4f0288f92a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.20.1
+
+* `SmallTextField`:
+ * Module is initialized
+* `Pickers`:
+ * Module is initialized
+* `Coroutines`:
+ * Add `SmartSemaphore`
+ * Add `SmartRWLocker`
+
## 0.20.0
* `Versions`:
diff --git a/android/pickers/build.gradle b/android/pickers/build.gradle
new file mode 100644
index 00000000000..c96f8bbeb52
--- /dev/null
+++ b/android/pickers/build.gradle
@@ -0,0 +1,18 @@
+plugins {
+ id "org.jetbrains.kotlin.multiplatform"
+ id "org.jetbrains.kotlin.plugin.serialization"
+ id "com.android.library"
+ alias(libs.plugins.jb.compose)
+}
+
+apply from: "$mppProjectWithSerializationAndComposePresetPath"
+
+kotlin {
+ sourceSets {
+ androidMain {
+ dependencies {
+ api project(":micro_utils.android.smalltextfield")
+ }
+ }
+ }
+}
diff --git a/android/pickers/src/androidMain/AndroidManifest.xml b/android/pickers/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000000..f305284765e
--- /dev/null
+++ b/android/pickers/src/androidMain/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/android/pickers/src/androidMain/kotlin/Fling.kt b/android/pickers/src/androidMain/kotlin/Fling.kt
new file mode 100644
index 00000000000..72eae558909
--- /dev/null
+++ b/android/pickers/src/androidMain/kotlin/Fling.kt
@@ -0,0 +1,27 @@
+package dev.inmo.micro_utils.android.pickers
+
+import androidx.compose.animation.core.*
+
+internal suspend fun Animatable.fling(
+ initialVelocity: Float,
+ animationSpec: DecayAnimationSpec,
+ adjustTarget: ((Float) -> Float)?,
+ block: (Animatable.() -> Unit)? = null,
+): AnimationResult {
+ val targetValue = animationSpec.calculateTargetValue(value, initialVelocity)
+ val adjustedTarget = adjustTarget?.invoke(targetValue)
+
+ return if (adjustedTarget != null) {
+ animateTo(
+ targetValue = adjustedTarget,
+ initialVelocity = initialVelocity,
+ block = block
+ )
+ } else {
+ animateDecay(
+ initialVelocity = initialVelocity,
+ animationSpec = animationSpec,
+ block = block,
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/pickers/src/androidMain/kotlin/NumberPicker.kt b/android/pickers/src/androidMain/kotlin/NumberPicker.kt
new file mode 100644
index 00000000000..4226e1d4168
--- /dev/null
+++ b/android/pickers/src/androidMain/kotlin/NumberPicker.kt
@@ -0,0 +1,222 @@
+package dev.inmo.micro_utils.android.pickers
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.exponentialDecay
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.ContentAlpha
+import androidx.compose.material.IconButton
+import androidx.compose.material.ProvideTextStyle
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.center
+import androidx.compose.ui.unit.dp
+import dev.inmo.micro_utils.android.smalltextfield.SmallTextField
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+import kotlin.math.roundToInt
+
+private inline fun PointerInputScope.checkContains(offset: Offset): Boolean {
+ return ((size.center.x - offset.x).absoluteValue < size.width / 2) && ((size.center.y - offset.y).absoluteValue < size.height / 2)
+}
+
+// src: https://gist.github.com/vganin/a9a84653a9f48a2d669910fbd48e32d5
+
+@OptIn(ExperimentalTextApi::class, ExperimentalComposeUiApi::class)
+@Composable
+fun NumberPicker(
+ number: Int,
+ modifier: Modifier = Modifier,
+ range: IntRange? = null,
+ textStyle: TextStyle = LocalTextStyle.current,
+ arrowsColor: Color = MaterialTheme.colorScheme.primary,
+ allowUseManualInput: Boolean = true,
+ onStateChanged: (Int) -> Unit = {},
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val numbersColumnHeight = 36.dp
+ val halvedNumbersColumnHeight = numbersColumnHeight / 2
+ val halvedNumbersColumnHeightPx = with(LocalDensity.current) { halvedNumbersColumnHeight.toPx() }
+
+ fun animatedStateValue(offset: Float): Int = number - (offset / halvedNumbersColumnHeightPx).toInt()
+
+ val animatedOffset = remember { Animatable(0f) }.apply {
+ if (range != null) {
+ val offsetRange = remember(number, range) {
+ val value = number
+ val first = -(range.last - value) * halvedNumbersColumnHeightPx
+ val last = -(range.first - value) * halvedNumbersColumnHeightPx
+ first..last
+ }
+ updateBounds(offsetRange.start, offsetRange.endInclusive)
+ }
+ }
+ val coercedAnimatedOffset = animatedOffset.value % halvedNumbersColumnHeightPx
+ val animatedStateValue = animatedStateValue(animatedOffset.value)
+ val disabledArrowsColor = arrowsColor.copy(alpha = ContentAlpha.disabled)
+
+ val inputFieldShown = if (allowUseManualInput) {
+ remember { mutableStateOf(false) }
+ } else {
+ null
+ }
+
+ Column(
+ modifier = modifier
+ .wrapContentSize()
+ .draggable(
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { deltaY ->
+ if (inputFieldShown ?.value != true) {
+ coroutineScope.launch {
+ animatedOffset.snapTo(animatedOffset.value + deltaY)
+ }
+ }
+ },
+ onDragStopped = { velocity ->
+ if (inputFieldShown ?.value != true) {
+ coroutineScope.launch {
+ val endValue = animatedOffset.fling(
+ initialVelocity = velocity,
+ animationSpec = exponentialDecay(frictionMultiplier = 20f),
+ adjustTarget = { target ->
+ val coercedTarget = target % halvedNumbersColumnHeightPx
+ val coercedAnchors =
+ listOf(-halvedNumbersColumnHeightPx, 0f, halvedNumbersColumnHeightPx)
+ val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!!
+ val base =
+ halvedNumbersColumnHeightPx * (target / halvedNumbersColumnHeightPx).toInt()
+ coercedPoint + base
+ }
+ ).endState.value
+
+ onStateChanged(animatedStateValue(endValue))
+ animatedOffset.snapTo(0f)
+ }
+ }
+ }
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val spacing = 4.dp
+
+ val upEnabled = range == null || range.first < number
+ IconButton(
+ {
+ onStateChanged(number - 1)
+ inputFieldShown ?.value = false
+ },
+ enabled = upEnabled
+ ) {
+ Icon(Icons.Default.KeyboardArrowUp, "", tint = if (upEnabled) arrowsColor else disabledArrowsColor)
+ }
+
+ Spacer(modifier = Modifier.height(spacing))
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(x = 0, y = coercedAnimatedOffset.roundToInt()) },
+ contentAlignment = Alignment.Center
+ ) {
+ val baseLabelModifier = Modifier.align(Alignment.Center)
+ ProvideTextStyle(textStyle) {
+ Text(
+ text = (animatedStateValue - 1).toString(),
+ modifier = baseLabelModifier
+ .offset(y = -halvedNumbersColumnHeight)
+ .alpha(coercedAnimatedOffset / halvedNumbersColumnHeightPx)
+ )
+
+ if (inputFieldShown ?.value == true) {
+ val currentValue = remember { mutableStateOf(number.toString()) }
+
+ val focusRequester = remember { FocusRequester() }
+ SmallTextField(
+ currentValue.value,
+ {
+ val asDigit = it.toIntOrNull()
+ when {
+ (asDigit == null && it.isEmpty()) -> currentValue.value = (range ?.first ?: 0).toString()
+ (asDigit != null && (range == null || asDigit in range)) -> currentValue.value = it
+ else -> { /* do nothing */ }
+ }
+ },
+ baseLabelModifier.focusRequester(focusRequester).width(IntrinsicSize.Min).pointerInput(number) {
+ detectTapGestures {
+ if (!checkContains(it)) {
+ currentValue.value.toIntOrNull() ?.let(onStateChanged)
+ inputFieldShown.value = false
+ }
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number
+ ),
+ keyboardActions = KeyboardActions {
+ currentValue.value.toIntOrNull() ?.let(onStateChanged)
+ inputFieldShown.value = false
+ },
+ singleLine = true,
+ textStyle = textStyle
+ )
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ } else {
+ Text(
+ text = animatedStateValue.toString(),
+ modifier = baseLabelModifier
+ .alpha(1 - abs(coercedAnimatedOffset) / halvedNumbersColumnHeightPx)
+ .clickable {
+ if (inputFieldShown ?.value == false) {
+ inputFieldShown.value = true
+ }
+ }
+ )
+ }
+ Text(
+ text = (animatedStateValue + 1).toString(),
+ modifier = baseLabelModifier
+ .offset(y = halvedNumbersColumnHeight)
+ .alpha(-coercedAnimatedOffset / halvedNumbersColumnHeightPx)
+ )
+ }
+ }
+
+
+ Spacer(modifier = Modifier.height(spacing))
+
+ val downEnabled = range == null || range.last > number
+ IconButton(
+ {
+ onStateChanged(number + 1)
+ inputFieldShown ?.value = false
+ },
+ enabled = downEnabled
+ ) {
+ Icon(Icons.Default.KeyboardArrowDown, "", tint = if (downEnabled) arrowsColor else disabledArrowsColor)
+ }
+ }
+}
+
diff --git a/android/pickers/src/androidMain/kotlin/SetPicker.kt b/android/pickers/src/androidMain/kotlin/SetPicker.kt
new file mode 100644
index 00000000000..b1cfc9fbebd
--- /dev/null
+++ b/android/pickers/src/androidMain/kotlin/SetPicker.kt
@@ -0,0 +1,156 @@
+package dev.inmo.micro_utils.android.pickers
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.exponentialDecay
+import androidx.compose.foundation.gestures.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.ContentAlpha
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import kotlin.math.*
+
+@OptIn(ExperimentalTextApi::class, ExperimentalComposeUiApi::class)
+@Composable
+fun SetPicker(
+ current: T,
+ dataList: List,
+ modifier: Modifier = Modifier,
+ textStyle: TextStyle = LocalTextStyle.current,
+ arrowsColor: Color = MaterialTheme.colorScheme.primary,
+ dataToString: @Composable (T) -> String = { it.toString() },
+ onStateChanged: (T) -> Unit = {},
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val numbersColumnHeight = 8.dp + with(LocalDensity.current) {
+ textStyle.lineHeight.toDp()
+ }
+ val numbersColumnHeightPx = with(LocalDensity.current) { numbersColumnHeight.toPx() }
+ val halvedNumbersColumnHeight = numbersColumnHeight / 2
+ val halvedNumbersColumnHeightPx = with(LocalDensity.current) { halvedNumbersColumnHeight.toPx() }
+
+ val index = dataList.indexOfFirst { it === current }.takeIf { it > -1 } ?: dataList.indexOf(current)
+ val lastIndex = dataList.size - 1
+
+ fun animatedStateValue(offset: Float): Int = index - (offset / halvedNumbersColumnHeightPx).toInt()
+
+ val animatedOffset = remember { Animatable(0f) }.apply {
+ val offsetRange = remember(index, lastIndex) {
+ val value = index
+ val first = -(lastIndex - value) * halvedNumbersColumnHeightPx
+ val last = value * halvedNumbersColumnHeightPx
+ first..last
+ }
+ updateBounds(offsetRange.start, offsetRange.endInclusive)
+ }
+ val indexAnimatedOffset = if (animatedOffset.value > 0) {
+ (index - floor(animatedOffset.value / halvedNumbersColumnHeightPx).toInt())
+ } else {
+ (index - ceil(animatedOffset.value / halvedNumbersColumnHeightPx).toInt())
+ }
+ val coercedAnimatedOffset = animatedOffset.value % halvedNumbersColumnHeightPx
+ val boxOffset = (indexAnimatedOffset * halvedNumbersColumnHeightPx) - coercedAnimatedOffset
+ val disabledArrowsColor = arrowsColor.copy(alpha = ContentAlpha.disabled)
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = modifier
+ .wrapContentSize()
+ .draggable(
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { deltaY ->
+ coroutineScope.launch {
+ animatedOffset.snapTo(animatedOffset.value + deltaY)
+ }
+ },
+ onDragStopped = { velocity ->
+ coroutineScope.launch {
+ val endValue = animatedOffset.fling(
+ initialVelocity = velocity,
+ animationSpec = exponentialDecay(frictionMultiplier = 20f),
+ adjustTarget = { target ->
+ val coercedTarget = target % halvedNumbersColumnHeightPx
+ val coercedAnchors =
+ listOf(-halvedNumbersColumnHeightPx, 0f, halvedNumbersColumnHeightPx)
+ val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!!
+ val base =
+ halvedNumbersColumnHeightPx * (target / halvedNumbersColumnHeightPx).toInt()
+ coercedPoint + base
+ }
+ ).endState.value
+
+ onStateChanged(dataList.elementAt(animatedStateValue(endValue)))
+ animatedOffset.snapTo(0f)
+ }
+ }
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val spacing = 4.dp
+
+ val upEnabled = index > 0
+ IconButton(
+ {
+ onStateChanged(dataList.elementAt(index - 1))
+ },
+ enabled = upEnabled
+ ) {
+ Icon(Icons.Default.KeyboardArrowUp, "", tint = if (upEnabled) arrowsColor else disabledArrowsColor)
+ }
+
+ Spacer(modifier = Modifier.height(spacing))
+ Box(
+ modifier = Modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ ProvideTextStyle(textStyle) {
+ dataList.forEachIndexed { i, t ->
+ val alpha = when {
+ i == indexAnimatedOffset - 1 -> coercedAnimatedOffset / halvedNumbersColumnHeightPx
+ i == indexAnimatedOffset -> 1 - (abs(coercedAnimatedOffset) / halvedNumbersColumnHeightPx)
+ i == indexAnimatedOffset + 1 -> -coercedAnimatedOffset / halvedNumbersColumnHeightPx
+ else -> return@forEachIndexed
+ }
+ val offset = when {
+ i == indexAnimatedOffset - 1 && coercedAnimatedOffset > 0 -> coercedAnimatedOffset - halvedNumbersColumnHeightPx
+ i == indexAnimatedOffset -> coercedAnimatedOffset
+ i == indexAnimatedOffset + 1 && coercedAnimatedOffset < 0 -> coercedAnimatedOffset + halvedNumbersColumnHeightPx
+ else -> return@forEachIndexed
+ }
+ Text(
+ text = dataToString(t),
+ modifier = Modifier
+ .alpha(alpha)
+ .offset(y = with(LocalDensity.current) { offset.toDp() })
+ )
+ }
+ }
+ }
+
+
+ Spacer(modifier = Modifier.height(spacing))
+
+ val downEnabled = index < lastIndex
+ IconButton(
+ {
+ onStateChanged(dataList.elementAt(index + 1))
+ },
+ enabled = downEnabled
+ ) {
+ Icon(Icons.Default.KeyboardArrowDown, "", tint = if (downEnabled) arrowsColor else disabledArrowsColor)
+ }
+ }
+}
diff --git a/android/smalltextfield/build.gradle b/android/smalltextfield/build.gradle
new file mode 100644
index 00000000000..43d40ccd762
--- /dev/null
+++ b/android/smalltextfield/build.gradle
@@ -0,0 +1,18 @@
+plugins {
+ id "org.jetbrains.kotlin.multiplatform"
+ id "org.jetbrains.kotlin.plugin.serialization"
+ id "com.android.library"
+ alias(libs.plugins.jb.compose)
+}
+
+apply from: "$mppProjectWithSerializationAndComposePresetPath"
+
+kotlin {
+ sourceSets {
+ androidMain {
+ dependencies {
+ api libs.android.compose.material3
+ }
+ }
+ }
+}
diff --git a/android/smalltextfield/src/androidMain/AndroidManifest.xml b/android/smalltextfield/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000000..1d8e283be89
--- /dev/null
+++ b/android/smalltextfield/src/androidMain/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt b/android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt
new file mode 100644
index 00000000000..3b875a93dae
--- /dev/null
+++ b/android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt
@@ -0,0 +1,66 @@
+package dev.inmo.micro_utils.android.smalltextfield
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.VisualTransformation
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SmallTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ textStyle: TextStyle = LocalTextStyle.current,
+ textColor: Color = textStyle.color.takeOrElse {
+ LocalContentColor.current
+ },
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ singleLine: Boolean = false,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
+ minLines: Int = 1,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ BasicTextField(
+ value = value,
+ modifier = modifier,
+ onValueChange = onValueChange,
+ enabled = enabled,
+ readOnly = readOnly,
+ textStyle = textStyle.copy(
+ color = textColor
+ ),
+ visualTransformation = visualTransformation,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ singleLine = singleLine,
+ maxLines = maxLines,
+ minLines = minLines,
+ cursorBrush = SolidColor(
+ textStyle.color.takeOrElse {
+ LocalContentColor.current
+ }
+ )
+ )
+}
diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt
new file mode 100644
index 00000000000..2ca18d8d4ed
--- /dev/null
+++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt
@@ -0,0 +1,102 @@
+package dev.inmo.micro_utils.coroutines
+
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * Composite mutex which works with next rules:
+ *
+ * * [acquireRead] require to [writeMutex] be free. Then it will take one lock from [readSemaphore]
+ * * [releaseRead] will just free up one permit in [readSemaphore]
+ * * [lockWrite] will lock [writeMutex] and then await while all [readSemaphore] will be freed
+ * * [unlockWrite] will just unlock [writeMutex]
+ */
+class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked: Boolean = false) {
+ private val _readSemaphore = SmartSemaphore.Mutable(permits = readPermits, acquiredPermits = 0)
+ private val _writeMutex = SmartMutex.Mutable(locked = writeIsLocked)
+
+ val readSemaphore: SmartSemaphore.Immutable = _readSemaphore.immutable()
+ val writeMutex: SmartMutex.Immutable = _writeMutex.immutable()
+
+ /**
+ * Do lock in [readSemaphore] inside of [writeMutex] locking
+ */
+ suspend fun acquireRead() {
+ _writeMutex.withLock {
+ _readSemaphore.acquire()
+ }
+ }
+
+ /**
+ * Release one read permit in [readSemaphore]
+ */
+ suspend fun releaseRead(): Boolean {
+ return _readSemaphore.release()
+ }
+
+ /**
+ * Locking [writeMutex] and wait while all [readSemaphore] permits will be freed
+ */
+ suspend fun lockWrite() {
+ _writeMutex.lock()
+ readSemaphore.waitRelease(readPermits)
+ }
+
+ /**
+ * Unlock [writeMutex]
+ */
+ suspend fun unlockWrite(): Boolean {
+ return _writeMutex.unlock()
+ }
+}
+
+/**
+ * Will call [SmartSemaphore.Mutable.lock], then execute [action] and return the result after [SmartSemaphore.Mutable.unlock]
+ */
+@OptIn(ExperimentalContracts::class)
+suspend inline fun SmartRWLocker.withReadAcquire(action: () -> T): T {
+ contract {
+ callsInPlace(action, InvocationKind.EXACTLY_ONCE)
+ }
+
+ acquireRead()
+ try {
+ return action()
+ } finally {
+ releaseRead()
+ }
+}
+
+/**
+ * Will wait until the [SmartSemaphore.permitsStateFlow] of [this] instance will have [permits] count free permits.
+ *
+ * Anyway, after the end of this block there are no any guaranties that [SmartSemaphore.freePermits] >= [permits] due to
+ * the fact that some other parties may lock it again
+ */
+suspend fun SmartRWLocker.waitReadRelease(permits: Int = 1) = readSemaphore.waitRelease(permits)
+
+/**
+ * Will call [SmartMutex.Mutable.lock], then execute [action] and return the result after [SmartMutex.Mutable.unlock]
+ */
+@OptIn(ExperimentalContracts::class)
+suspend inline fun SmartRWLocker.withWriteLock(action: () -> T): T {
+ contract {
+ callsInPlace(action, InvocationKind.EXACTLY_ONCE)
+ }
+
+ lockWrite()
+ try {
+ return action()
+ } finally {
+ unlockWrite()
+ }
+}
+
+/**
+ * Will wait until the [SmartMutex.lockStateFlow] of [this] instance will be false.
+ *
+ * Anyway, after the end of this block there are no any guaranties that [SmartMutex.isLocked] == false due to the fact
+ * that some other parties may lock it again
+ */
+suspend fun SmartRWLocker.waitWriteUnlock() = writeMutex.waitUnlock()
diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt
new file mode 100644
index 00000000000..fd072f1e415
--- /dev/null
+++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt
@@ -0,0 +1,142 @@
+package dev.inmo.micro_utils.coroutines
+
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withLock
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * It is interface which will work like classic [Semaphore], but in difference have [permitsStateFlow] for listening of the
+ * [SmartSemaphore] state.
+ *
+ * There is [Mutable] and [Immutable] realizations. In case you are owner and manager current state of lock, you need
+ * [Mutable] [SmartSemaphore]. Otherwise, [Immutable].
+ *
+ * Any [Mutable] [SmartSemaphore] may produce its [Immutable] variant which will contains [permitsStateFlow] equal to its
+ * [Mutable] creator
+ */
+sealed interface SmartSemaphore {
+ val permitsStateFlow: StateFlow
+
+ /**
+ * * True - locked
+ * * False - unlocked
+ */
+ val freePermits: Int
+ get() = permitsStateFlow.value
+
+ /**
+ * Immutable variant of [SmartSemaphore]. In fact will depend on the owner of [permitsStateFlow]
+ */
+ class Immutable(override val permitsStateFlow: StateFlow) : SmartSemaphore
+
+ /**
+ * Mutable variant of [SmartSemaphore]. With that variant you may [lock] and [unlock]. Besides, you may create
+ * [Immutable] variant of [this] instance with [immutable] factory
+ *
+ * @param locked Preset state of [freePermits] and its internal [_permitsStateFlow]
+ */
+ class Mutable(private val permits: Int, acquiredPermits: Int = 0) : SmartSemaphore {
+ private val _permitsStateFlow = MutableStateFlow(permits - acquiredPermits)
+ override val permitsStateFlow: StateFlow = _permitsStateFlow.asStateFlow()
+
+ private val internalChangesMutex = Mutex(false)
+
+ fun immutable() = Immutable(permitsStateFlow)
+
+ private fun checkedPermits(permits: Int) = permits.coerceIn(1 .. this.permits)
+
+ /**
+ * Holds call until this [SmartSemaphore] will be re-locked. That means that while [freePermits] == true, [holds] will
+ * wait for [freePermits] == false and then try to lock
+ */
+ suspend fun acquire(permits: Int = 1) {
+ do {
+ val checkedPermits = checkedPermits(permits)
+ waitRelease(checkedPermits)
+ val shouldContinue = internalChangesMutex.withLock {
+ if (_permitsStateFlow.value < checkedPermits) {
+ true
+ } else {
+ _permitsStateFlow.value -= checkedPermits
+ false
+ }
+ }
+ } while (shouldContinue && currentCoroutineContext().isActive)
+ }
+
+ /**
+ * Will try to lock this [SmartSemaphore] immediataly
+ *
+ * @return True if lock was successful. False otherwise
+ */
+ suspend fun tryAcquire(permits: Int = 1): Boolean {
+ val checkedPermits = checkedPermits(permits)
+ return if (_permitsStateFlow.value < checkedPermits) {
+ internalChangesMutex.withLock {
+ if (_permitsStateFlow.value < checkedPermits) {
+ _permitsStateFlow.value -= checkedPermits
+ true
+ } else {
+ false
+ }
+ }
+ } else {
+ false
+ }
+ }
+
+ /**
+ * If [freePermits] == true - will change it to false and return true. If current call will not unlock this
+ * [SmartSemaphore] - false
+ */
+ suspend fun release(permits: Int = 1): Boolean {
+ val checkedPermits = checkedPermits(permits)
+ return if (_permitsStateFlow.value < this.permits) {
+ internalChangesMutex.withLock {
+ if (_permitsStateFlow.value < this.permits) {
+ _permitsStateFlow.value = minOf(_permitsStateFlow.value + checkedPermits, this.permits)
+ true
+ } else {
+ false
+ }
+ }
+ } else {
+ false
+ }
+ }
+ }
+}
+
+/**
+ * Will call [SmartSemaphore.Mutable.lock], then execute [action] and return the result after [SmartSemaphore.Mutable.unlock]
+ */
+@OptIn(ExperimentalContracts::class)
+suspend inline fun SmartSemaphore.Mutable.withAcquire(permits: Int = 1, action: () -> T): T {
+ contract {
+ callsInPlace(action, InvocationKind.EXACTLY_ONCE)
+ }
+
+ acquire(permits)
+ try {
+ return action()
+ } finally {
+ release(permits)
+ }
+}
+
+/**
+ * Will wait until the [SmartSemaphore.permitsStateFlow] of [this] instance will have [permits] count free permits.
+ *
+ * Anyway, after the end of this block there are no any guaranties that [SmartSemaphore.freePermits] >= [permits] due to
+ * the fact that some other parties may lock it again
+ */
+suspend fun SmartSemaphore.waitRelease(permits: Int = 1) = permitsStateFlow.first { it >= permits }
diff --git a/coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt b/coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt
new file mode 100644
index 00000000000..cef0ff93526
--- /dev/null
+++ b/coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt
@@ -0,0 +1,60 @@
+import dev.inmo.micro_utils.coroutines.SmartRWLocker
+import dev.inmo.micro_utils.coroutines.withReadAcquire
+import dev.inmo.micro_utils.coroutines.withWriteLock
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SmartRWLockerTests {
+ @Test
+ fun compositeTest() {
+ val locker = SmartRWLocker()
+
+ val readAndWriteWorkers = 10
+ runTest {
+ var started = 0
+ var done = 0
+ val doneMutex = Mutex()
+ val readWorkers = (0 until readAndWriteWorkers).map {
+ launch(start = CoroutineStart.LAZY) {
+ locker.withReadAcquire {
+ doneMutex.withLock {
+ started++
+ }
+ delay(100L)
+ doneMutex.withLock {
+ done++
+ }
+ }
+ }
+ }
+
+ var doneWrites = 0
+
+ val writeWorkers = (0 until readAndWriteWorkers).map {
+ launch(start = CoroutineStart.LAZY) {
+ locker.withWriteLock {
+ assertTrue(done == readAndWriteWorkers || started == 0)
+ delay(10L)
+ doneWrites++
+ }
+ }
+ }
+ readWorkers.forEach { it.start() }
+ writeWorkers.forEach { it.start() }
+
+ readWorkers.joinAll()
+ writeWorkers.joinAll()
+
+ assertEquals(expected = readAndWriteWorkers, actual = done)
+ assertEquals(expected = readAndWriteWorkers, actual = doneWrites)
+ }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index d5225567187..8bb22490175 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -15,5 +15,5 @@ crypto_js_version=4.1.1
# Project data
group=dev.inmo
-version=0.20.0
-android_code_version=206
+version=0.20.1
+android_code_version=207
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3960ed514bd..6afe972fe56 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -35,6 +35,7 @@ android-appCompat = "1.6.1"
android-fragment = "1.6.1"
android-espresso = "3.5.1"
android-test = "1.1.5"
+android-compose-material3 = "1.1.1"
android-props-minSdk = "21"
android-props-compileSdk = "33"
@@ -83,6 +84,7 @@ jb-exposed = { module = "org.jetbrains.exposed:exposed-core", version.ref = "jb-
android-coreKtx = { module = "androidx.core:core-ktx", version.ref = "android-coreKtx" }
android-recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "android-recyclerView" }
android-appCompat-resources = { module = "androidx.appcompat:appcompat-resources", version.ref = "android-appCompat" }
+android-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "android-compose-material3" }
android-fragment = { module = "androidx.fragment:fragment", version.ref = "android-fragment" }
android-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "android-espresso" }
android-test-junit = { module = "androidx.test.ext:junit", version.ref = "android-test" }
diff --git a/mppAndroidProject.gradle b/mppAndroidProject.gradle
index 1817e67049d..7bd45386817 100644
--- a/mppAndroidProject.gradle
+++ b/mppAndroidProject.gradle
@@ -18,6 +18,7 @@ kotlin {
dependencies {
implementation kotlin('test-common')
implementation kotlin('test-annotations-common')
+ implementation libs.kt.coroutines.test
}
}
}
diff --git a/mppJavaProject.gradle b/mppJavaProject.gradle
index 5f6d62de0cb..4d2f2b6d5e4 100644
--- a/mppJavaProject.gradle
+++ b/mppJavaProject.gradle
@@ -22,6 +22,7 @@ kotlin {
dependencies {
implementation kotlin('test-common')
implementation kotlin('test-annotations-common')
+ implementation libs.kt.coroutines.test
}
}
diff --git a/mppJvmJsLinuxMingwProject.gradle b/mppJvmJsLinuxMingwProject.gradle
index 1a5b08c7de5..a38f9435da1 100644
--- a/mppJvmJsLinuxMingwProject.gradle
+++ b/mppJvmJsLinuxMingwProject.gradle
@@ -28,6 +28,7 @@ kotlin {
dependencies {
implementation kotlin('test-common')
implementation kotlin('test-annotations-common')
+ implementation libs.kt.coroutines.test
}
}
diff --git a/mppProjectWithSerialization.gradle b/mppProjectWithSerialization.gradle
index 8d0239816e5..2ed6bff423d 100644
--- a/mppProjectWithSerialization.gradle
+++ b/mppProjectWithSerialization.gradle
@@ -32,6 +32,7 @@ kotlin {
dependencies {
implementation kotlin('test-common')
implementation kotlin('test-annotations-common')
+ implementation libs.kt.coroutines.test
}
}
jvmTest {
diff --git a/mppProjectWithSerializationAndCompose.gradle b/mppProjectWithSerializationAndCompose.gradle
index e7b75fbd456..d05dcb9f3b6 100644
--- a/mppProjectWithSerializationAndCompose.gradle
+++ b/mppProjectWithSerializationAndCompose.gradle
@@ -31,6 +31,7 @@ kotlin {
dependencies {
implementation kotlin('test-common')
implementation kotlin('test-annotations-common')
+ implementation libs.kt.coroutines.test
}
}
jvmMain {
diff --git a/settings.gradle b/settings.gradle
index ec21091c550..2fe83ceb97b 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -32,6 +32,8 @@ String[] includes = [
":coroutines",
":coroutines:compose",
":android:recyclerview",
+ ":android:pickers",
+ ":android:smalltextfield",
":android:alerts:common",
":android:alerts:recyclerview",
":serialization:base64",