diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt index 17cc21832a8c7b0197aca179b3ea3182c11c9c09..114c8c396472eee9dbbbb866403a19aeeeea9573 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt @@ -2,4 +2,4 @@ package net.novagamestudios.kaffeekasse.model.credentials val Login.isValid get() = username.isNotBlank() && password.isNotBlank() -val DeviceCredentials.isValid get() = deviceId.isNotBlank() && apiKey.isNotBlank() +val DeviceCredentials.isValid get() = deviceId.isNotBlank() && apiKey.isNotBlank() && deviceId.length == ((2 + 1) * 6 - 1) diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginDevice.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginDevice.kt index e37cd50dd913eb3317271762884988fcf1e904ce..a5431dd3538a006ac705e2d39dba9008b3448cb3 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginDevice.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginDevice.kt @@ -1,24 +1,35 @@ package net.novagamestudios.kaffeekasse.ui.login import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.PhonelinkErase +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.ScreenLockLandscape import androidx.compose.material.icons.filled.SmartScreen +import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,19 +39,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import net.novagamestudios.common_utils.compose.Toasts +import net.novagamestudios.common_utils.compose.ToastsState import net.novagamestudios.common_utils.compose.components.CircularLoadingBox +import net.novagamestudios.common_utils.compose.components.ColumnCenter import net.novagamestudios.common_utils.compose.state.collectAsStateIn import net.novagamestudios.common_utils.voyager.nearestScreen import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials import net.novagamestudios.kaffeekasse.model.credentials.isValid import net.novagamestudios.kaffeekasse.repositories.LoginRepository import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation +import net.novagamestudios.kaffeekasse.ui.theme.disabled +import net.novagamestudios.kaffeekasse.ui.util.MacAddress import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState +import java.util.LinkedList +import kotlin.math.absoluteValue +import kotlin.random.Random class LoginDeviceState( @@ -113,6 +133,9 @@ private fun LoginDeviceDialog( Text("Abbrechen") } }, + title = { + Text("Gerät einloggen") + }, text = { CircularLoadingBox(loading = isLoading) { LoginDeviceForm( @@ -131,6 +154,7 @@ private fun LoginDeviceForm( modifier: Modifier = Modifier ) { var input by inputState + Column( modifier.width(IntrinsicSize.Min), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -138,11 +162,13 @@ private fun LoginDeviceForm( ) { val focusManager = LocalFocusManager.current OutlinedTextField( - value = input.deviceId, - onValueChange = { input = input.copy(deviceId = it.uppercase()) }, + value = with(MacAddress) { input.deviceId.compress() }, + onValueChange = { + input = input.copy(deviceId = with(MacAddress) { it.decompress() }) + }, label = { Text("Geräte-Id") }, - placeholder = { Text("XX:XX:XX:XX:XX:XX") }, leadingIcon = { Icon(Icons.Default.SmartScreen, "Geräte-Id") }, + visualTransformation = MacAddress.VisualTransformation(placeholderColor = LocalContentColor.current.disabled()), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Next @@ -150,6 +176,7 @@ private fun LoginDeviceForm( keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }), singleLine = true ) + OutlinedTextField( value = input.apiKey, onValueChange = { input = input.copy(apiKey = it) }, diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/MacAddress.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/MacAddress.kt new file mode 100644 index 0000000000000000000000000000000000000000..8e4a1c78ff17c8f0af9d286bbb988eefc70b711f --- /dev/null +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/MacAddress.kt @@ -0,0 +1,70 @@ +package net.novagamestudios.kaffeekasse.ui.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.input.TransformedText + +object MacAddress { + + private const val ChunkCount = 6 + private const val ChunkLength = 2 + private const val ChunkSeparator = ":" + + fun String.compress() = this + .filter { it.isLetterOrDigit() } + .take(ChunkCount * ChunkLength) + .uppercase() + + private fun String.chunks() = this + .compress() + .chunked(ChunkLength) + + fun String.decompress(): String { + return chunks().joinToString(ChunkSeparator) + } + + val String.isValid: Boolean get() { + return compress().length == ChunkCount * ChunkLength + } + + class VisualTransformation( + private val placeholderColor: Color, + private val placeholder: Char = 'X' + ) : androidx.compose.ui.text.input.VisualTransformation { + override fun filter(original: AnnotatedString): TransformedText { + val chunks = original.text.chunks() + val transformed = AnnotatedString.Builder().apply { + for (i in 0 until ChunkCount) { + val chunk = chunks.getOrElse(i) { "" } + append(chunk) + val missing = ChunkLength - chunk.length + if (missing > 0) { + pushStyle(SpanStyle(color = placeholderColor)) + append("$placeholder".repeat(missing)) + pop() + } + if (i < ChunkCount - 1) { + append(ChunkSeparator) + } + } + }.toAnnotatedString() + + return TransformedText( + text = transformed, + offsetMapping = OffsetMapping(original.text) + ) + } + } + + private class OffsetMapping( + private val originalText: String + ) : androidx.compose.ui.text.input.OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return (offset + offset / ChunkLength).coerceIn(0, (ChunkLength + 1) * ChunkCount - 1) + } + override fun transformedToOriginal(offset: Int): Int { + return (offset - offset / (ChunkLength + 1)).coerceIn(0, originalText.length) + } + } +}