Coverage Summary for Class: DurationPickerKt (com.vsevolodganin.clicktrack.ui.piece)
Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
DurationPickerKt |
0%
(0/15)
|
0%
(0/50)
|
0%
(0/124)
|
0%
(0/1432)
|
DurationPickerKt$DurationPicker$6$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/3)
|
0%
(0/25)
|
DurationPickerKt$DurationPicker$7$1 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/4)
|
0%
(0/29)
|
Total |
0%
(0/17)
|
0%
(0/56)
|
0%
(0/131)
|
0%
(0/1486)
|
@file:Suppress("DEPRECATION") // FIXME: Use PlatformTextInputModifierNode instead of TextInputSession
package com.vsevolodganin.clicktrack.ui.piece
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key.Companion.Backspace
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.text.input.BackspaceCommand
import androidx.compose.ui.text.input.CommitTextCommand
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.ImeOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TextInputSession
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vsevolodganin.clicktrack.generated.resources.MR
import com.vsevolodganin.clicktrack.utils.compose.Preview
import dev.icerock.moko.resources.compose.stringResource
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@Composable
fun DurationPicker(state: MutableState<Duration>, modifier: Modifier = Modifier) {
DurationPicker(
value = state.value,
onValueChange = { state.value = it },
modifier = modifier,
)
}
@Composable
fun DurationPicker(value: Duration, onValueChange: (Duration) -> Unit, modifier: Modifier = Modifier) {
/** Converts CharSequence in format "hhmmss" to Duration */
fun CharSequence.toDuration(): Duration {
val hoursSequence = subSequence(0, 2)
val minutesSequence = subSequence(2, 4)
val secondsSequence = subSequence(4, 6)
val sequenceToTimeMultiplier = listOf(
hoursSequence to SECONDS_PER_MINUTE * MINUTES_PER_HOUR,
minutesSequence to SECONDS_PER_MINUTE,
secondsSequence to 1L,
)
val radix = 10
var seconds = 0L
for ((sequence, timeMultiplier) in sequenceToTimeMultiplier) {
var baseMultiplier = 1
for (char in sequence.reversed()) {
val int = char.code - '0'.code
seconds += int * baseMultiplier * timeMultiplier
baseMultiplier *= radix
}
}
return seconds.seconds
}
/** Converts Duration to String in format "hhmmss" */
fun Duration.asString(): String {
return toComponents { hours, minutes, seconds, _ ->
"${hours.coerceAtMost(99L).toInt().atLeastTwoDigits()}${minutes.atLeastTwoDigits()}${seconds.atLeastTwoDigits()}"
}
}
val internalStringState = remember { mutableStateOf(value.asString()) }
if (internalStringState.value.toDuration() != value) {
internalStringState.value = value.asString()
}
fun updateInternalStringState(newInternalStringState: String) {
internalStringState.value = newInternalStringState
onValueChange(newInternalStringState.toDuration())
}
fun enterDigit(char: Char): Boolean {
return if (char.isDigit() && internalStringState.value.first() == '0') {
updateInternalStringState(internalStringState.value.drop(1) + char)
true
} else {
false
}
}
fun removeDigit(): Boolean {
updateInternalStringState('0' + internalStringState.value.dropLast(1))
return true
}
@Composable
fun formatInternalState(): String {
val hoursString = stringResource(MR.strings.duration_picker_hours)
val minutesString = stringResource(MR.strings.duration_picker_minutes)
val secondsString = stringResource(MR.strings.duration_picker_seconds)
return StringBuilder().apply {
append(internalStringState.value.subSequence(0, 2))
append(hoursString)
append(' ')
append(internalStringState.value.subSequence(2, 4))
append(minutesString)
append(' ')
append(internalStringState.value.subSequence(4, 6))
append(secondsString)
}.toString()
}
val inputService = LocalTextInputService.current!!
val textInputSession: MutableState<TextInputSession?> = remember { mutableStateOf(null) }
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
if (isFocused && textInputSession.value == null) {
textInputSession.value = inputService.startInput(
value = TextFieldValue(),
imeOptions = ImeOptions(
singleLine = true,
autoCorrect = false,
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
onEditCommand = { operations ->
operations.forEach { operation ->
when (operation) {
is BackspaceCommand -> removeDigit()
is CommitTextCommand -> operation.text.forEach(::enterDigit)
}
}
},
onImeActionPerformed = { action ->
if (action == ImeAction.Done) {
focusManager.clearFocus()
}
},
)
} else if (!isFocused && textInputSession.value != null) {
textInputSession.value?.let(inputService::stopInput)
textInputSession.value = null
}
LaunchedEffect(textInputSession.value) {
val textInputSessionValue = textInputSession.value
if (textInputSessionValue != null) {
bringIntoViewRequester.bringIntoView()
}
}
Row(
modifier = modifier
.focusableBorder()
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.bringIntoViewRequester(bringIntoViewRequester)
.onKeyEvent {
// FIXME(https://issuetracker.google.com/issues/188119984): Should keep only onEditCommand
if (it.type != KeyEventType.KeyDown) return@onKeyEvent false
if (it.key == Backspace) {
removeDigit()
} else {
enterDigit(it.utf16CodePoint.toChar())
}
}
.clickable {
if (!isFocused) {
focusRequester.requestFocus()
} else {
textInputSession.value?.showSoftwareKeyboard()
}
}
.padding(8.dp),
) {
Text(
text = formatInternalState(),
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1.0f),
style = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
)
Spacer(Modifier.width(8.dp))
CloseIcon {
onValueChange(Duration.ZERO)
}
}
}
@Composable
private fun RowScope.CloseIcon(onClick: () -> Unit) {
Box(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(16.dp, 16.dp)
.clickable(onClick = onClick),
) {
Icon(imageVector = Icons.Default.Close, contentDescription = null)
}
}
private fun Int.atLeastTwoDigits() = "${this / 10}${this % 10}"
private const val SECONDS_PER_MINUTE = 60L
private const val MINUTES_PER_HOUR = 60L
@Preview
@Composable
private fun Preview() {
val sharedState = remember {
mutableStateOf(
1.hours + 2.minutes + 3.seconds + 4.milliseconds,
)
}
Column {
DurationPicker(sharedState)
DurationPicker(sharedState)
DurationPicker(remember { mutableStateOf(1.minutes + 2.seconds + 3.milliseconds) })
DurationPicker(remember { mutableStateOf(1.seconds + 2.milliseconds) })
DurationPicker(remember { mutableStateOf(1.milliseconds) })
}
}