diff --git a/.gitignore b/.gitignore index cd15e16..c5fbefd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .externalNativeBuild /apk/ /app/release/ +.kotlin/ diff --git a/app/build.gradle b/app/build.gradle index 32be2d3..cc96a39 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,29 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.detekt) +} + +detekt { + toolVersion = libs.versions.detekt.get() + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + allRules = false + ignoreFailures = false + basePath = rootProject.projectDir +} + +tasks.withType(dev.detekt.gradle.Detekt).configureEach { + exclude("**/build/**") + exclude("**/generated/**") + exclude("**/ksp/**") + + reports { + checkstyle.required.set(true) + html.required.set(true) + markdown.required.set(true) + sarif.required.set(true) + } } def Properties versionProps = new Properties() diff --git a/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt b/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt index a9aec8d..9389d02 100644 --- a/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt +++ b/app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt @@ -23,36 +23,48 @@ class ConverterEngine @Inject constructor() { suspend fun convert(route: AppRoute, value: String, fromInput: Boolean, mode: Int): String = when (route) { - UnicodeRoute -> if (fromInput) { - value.map { "\\u${it.code.toString(16).padStart(4, '0')}" }.joinToString("") - } else Decoder.decodeUnicode(value) - - is Base64Route -> { - val flags = base64Flags[mode.coerceIn(base64Flags.indices)] - if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64( - value, - flags - ) - } - - is HexRoute -> if (mode == 1) { - if (fromInput) Decoder.intToHex(value) - else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X")) - } else if (fromInput) { - Decoder.toHexString(value) - } else Decoder.decodeHexString(value.removePrefix("0x")) - - is TextToolRoute -> when (route.tool) { - TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString() - TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString() - TextTool.XML -> Decoder.unescapeXml(value) - TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong()) - } - + UnicodeRoute -> convertUnicode(value, fromInput) + is Base64Route -> convertBase64(value, fromInput, mode) + is HexRoute -> convertHex(value, fromInput, mode) + is TextToolRoute -> convertTextTool(value, route.tool) else -> value } + private suspend fun convertUnicode(value: String, fromInput: Boolean): String = + if (fromInput) { + value.map { "\\u${it.code.toString(HEX_RADIX).padStart(UNICODE_HEX_DIGITS, '0')}" } + .joinToString("") + } else { + Decoder.decodeUnicode(value) + } + + private suspend fun convertBase64(value: String, fromInput: Boolean, mode: Int): String { + val flags = base64Flags[mode.coerceIn(base64Flags.indices)] + return if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64(value, flags) + } + + private suspend fun convertHex(value: String, fromInput: Boolean, mode: Int): String = + if (mode == HEX_NUMBER_MODE) { + if (fromInput) Decoder.intToHex(value) + else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X")) + } else if (fromInput) { + Decoder.toHexString(value) + } else { + Decoder.decodeHexString(value.removePrefix("0x")) + } + + private suspend fun convertTextTool(value: String, tool: TextTool): String = when (tool) { + TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString() + TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString() + TextTool.XML -> Decoder.unescapeXml(value) + TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong()) + } + private companion object { + const val HEX_RADIX = 16 + const val HEX_NUMBER_MODE = 1 + const val UNICODE_HEX_DIGITS = 4 + val base64Flags = intArrayOf( Base64.NO_WRAP, Base64.NO_CLOSE, Base64.NO_PADDING, Base64.URL_SAFE, Base64.CRLF, Base64.DEFAULT diff --git a/app/src/main/java/ru/svolf/convertx/data/SettingsRepository.kt b/app/src/main/java/ru/svolf/convertx/data/SettingsRepository.kt index a5d482d..77bb1b1 100644 --- a/app/src/main/java/ru/svolf/convertx/data/SettingsRepository.kt +++ b/app/src/main/java/ru/svolf/convertx/data/SettingsRepository.kt @@ -12,14 +12,22 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException +private const val DefaultFontSize = 16 +private const val MinFontSize = 12 +private const val MaxFontSize = 32 +private const val DefaultBase64Mode = 0 +private const val MaxBase64Mode = 5 +private const val DefaultHexMode = 0 +private const val MaxHexMode = 1 + enum class ThemeMode { LIGHT, DARK } data class SettingsState( val theme: ThemeMode = ThemeMode.LIGHT, - val fontSize: Int = 16, + val fontSize: Int = DefaultFontSize, val twiceBackToExit: Boolean = true, - val base64Mode: Int = 0, - val hexMode: Int = 0 + val base64Mode: Int = DefaultBase64Mode, + val hexMode: Int = DefaultHexMode ) class SettingsRepository(private val dataStore: DataStore) { @@ -38,12 +46,18 @@ class SettingsRepository(private val dataStore: DataStore) { } .map { preferences -> SettingsState( - theme = if (preferences[Keys.theme]?.toIntOrNull() == ThemeMode.DARK.ordinal) ThemeMode.DARK else ThemeMode.LIGHT, + theme = if (preferences[Keys.theme]?.toIntOrNull() == ThemeMode.DARK.ordinal) { + ThemeMode.DARK + } else { + ThemeMode.LIGHT + }, fontSize = (preferences[Keys.appFontSize] ?: preferences[Keys.oldFontSize] - ?: 16).coerceIn(12, 32), + ?: DefaultFontSize).coerceIn(MinFontSize, MaxFontSize), twiceBackToExit = preferences[Keys.twiceBack] ?: true, - base64Mode = (preferences[Keys.base64Mode] ?: 0).coerceIn(0, 5), - hexMode = (preferences[Keys.hexMode] ?: 0).coerceIn(0, 1) + base64Mode = (preferences[Keys.base64Mode] ?: DefaultBase64Mode) + .coerceIn(DefaultBase64Mode, MaxBase64Mode), + hexMode = (preferences[Keys.hexMode] ?: DefaultHexMode) + .coerceIn(DefaultHexMode, MaxHexMode) ) } @@ -51,13 +65,14 @@ class SettingsRepository(private val dataStore: DataStore) { dataStore.edit { it[Keys.theme] = theme.ordinal.toString() } suspend fun setFontSize(size: Int) = - dataStore.edit { it[Keys.appFontSize] = size.coerceIn(12, 32) } + dataStore.edit { it[Keys.appFontSize] = size.coerceIn(MinFontSize, MaxFontSize) } suspend fun setTwiceBackToExit(enabled: Boolean) = dataStore.edit { it[Keys.twiceBack] = enabled } suspend fun setBase64Mode(mode: Int) = - dataStore.edit { it[Keys.base64Mode] = mode.coerceIn(0, 5) } + dataStore.edit { it[Keys.base64Mode] = mode.coerceIn(DefaultBase64Mode, MaxBase64Mode) } - suspend fun setHexMode(mode: Int) = dataStore.edit { it[Keys.hexMode] = mode.coerceIn(0, 1) } + suspend fun setHexMode(mode: Int) = + dataStore.edit { it[Keys.hexMode] = mode.coerceIn(DefaultHexMode, MaxHexMode) } } diff --git a/app/src/main/java/ru/svolf/convertx/di/AppComponent.kt b/app/src/main/java/ru/svolf/convertx/di/AppComponent.kt index 8c2152f..5de1fb6 100644 --- a/app/src/main/java/ru/svolf/convertx/di/AppComponent.kt +++ b/app/src/main/java/ru/svolf/convertx/di/AppComponent.kt @@ -26,6 +26,7 @@ interface AppComponent { fun paletteViewModelFactory(): PaletteViewModelFactory fun settingsViewModelFactory(): SettingsViewModelFactory fun regexViewModelFactory(): RegexViewModelFactory + fun textToolViewModelFactory(): ru.svolf.convertx.presentation.viewmodel.TextToolViewModel.Factory @Component.Factory interface Factory { diff --git a/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterField.kt b/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterField.kt new file mode 100644 index 0000000..4f4149d --- /dev/null +++ b/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterField.kt @@ -0,0 +1,199 @@ +package ru.svolf.convertx.presentation.compose + +import android.content.ClipData +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.AssistChip +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.svolf.convertx.R +import ru.svolf.convertx.presentation.navigation.AppRoute +import ru.svolf.convertx.presentation.navigation.Base64Route +import ru.svolf.convertx.presentation.navigation.HexRoute +import ru.svolf.convertx.presentation.navigation.TextTool +import ru.svolf.convertx.presentation.navigation.TextToolRoute +import ru.svolf.convertx.presentation.navigation.UnicodeRoute + +internal enum class ConverterField { INPUT, OUTPUT } + +@Composable +internal fun inputHint(route: AppRoute): String = when (route) { + UnicodeRoute, is Base64Route -> stringResource(R.string.hint_utf) + is HexRoute -> stringResource(R.string.hint_string) + is TextToolRoute -> when (route.tool) { + TextTool.ADLER32 -> stringResource(R.string.hint_adler32) + TextTool.CRC32 -> stringResource(R.string.hint_crc) + TextTool.XML -> stringResource(R.string.hint_encoded_xml) + TextTool.TIMESTAMP -> stringResource(R.string.hint_timestamp) + } + + else -> stringResource(R.string.hint_string) +} + +@Composable +internal fun outputHint(route: AppRoute): String = when (route) { + UnicodeRoute -> stringResource(R.string.hint_unicode) + is Base64Route -> stringResource(R.string.hint_base64) + is HexRoute -> stringResource(R.string.hint_hex) + else -> stringResource(R.string.copy2clipboard) +} + +@Composable +internal fun ConverterControls( + route: AppRoute, + selectedMode: Int, + activeField: ConverterField, + onFieldToggle: () -> Unit, + onModeSelected: (Int) -> Unit +) { + Box(Modifier.fillMaxWidth().height(48.dp)) { + DirectionSwitcher(activeField, onFieldToggle, Modifier.align(Alignment.Center)) + if (route is Base64Route || route is ru.svolf.convertx.presentation.navigation.HexRoute) { + ModeMenu(route, selectedMode, onModeSelected, Modifier.align(Alignment.CenterStart)) + } + } +} + +@Composable +private fun DirectionSwitcher( + activeField: ConverterField, + onToggle: () -> Unit, + modifier: Modifier = Modifier +) { + val primary = MaterialTheme.colorScheme.primary + val muted = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) + val downTint by animateColorAsState( + targetValue = if (activeField == ConverterField.INPUT) primary else muted, + animationSpec = tween(durationMillis = 220), + label = "downArrowTint" + ) + val upTint by animateColorAsState( + targetValue = if (activeField == ConverterField.OUTPUT) primary else muted, + animationSpec = tween(durationMillis = 220), + label = "upArrowTint" + ) + Column( + modifier = modifier.height(46.dp).clickable(onClick = onToggle), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(Modifier.width(2.dp).weight(1f).background(primary.copy(alpha = 0.65f))) + Row(horizontalArrangement = Arrangement.Center) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = downTint, + modifier = Modifier.size(20.dp) + ) + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + tint = upTint, + modifier = Modifier.size(20.dp) + ) + } + Box(Modifier.width(2.dp).weight(1f).background(primary.copy(alpha = 0.65f))) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModeMenu( + route: AppRoute, + selected: Int, + onSelected: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val options = if (route is Base64Route) { + listOf("No wrap", "No close", "No padding", "URL safe", "CRLF", "Default") + } else { + listOf("String", "Integer") + } + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded, { expanded = !expanded }, modifier) { + AssistChip( + onClick = { expanded = true }, + label = { Text(options.getOrElse(selected) { options.first() }) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor() + ) + DropdownMenu(expanded, { expanded = false }) { + options.forEachIndexed { index, option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { expanded = false; onSelected(index) }, + leadingIcon = if (index == selected) { + { Icon(Icons.Default.Check, null) } + } else { + null + } + ) + } + } + } +} + +@Composable +internal fun ClearButton(onClick: () -> Unit) { + TextButton(modifier = Modifier.height(48.dp), onClick = onClick) { + Icon(Icons.Default.Clear, stringResource(R.string.clear)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.clear)) + } +} + +@Composable +internal fun CopyButton(value: String, message: String, snackbar: SnackbarHostState) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + TextButton( + modifier = Modifier.height(48.dp), + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, value))) + snackbar.showSnackbar(message) + } + } + ) { + Icon(Icons.Default.ContentCopy, stringResource(R.string.copy2clipboard)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.copy2clipboard)) + } +} diff --git a/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterScreen.kt b/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterScreen.kt index ebff2bf..456c89b 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterScreen.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/compose/ConverterScreen.kt @@ -1,10 +1,9 @@ package ru.svolf.convertx.presentation.compose -import android.content.ClipData import android.content.Context import android.media.MediaPlayer -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -32,17 +31,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material3.AssistChip -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -71,12 +61,12 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource @@ -85,18 +75,32 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import ru.svolf.convertx.R import ru.svolf.convertx.presentation.navigation.AppRoute -import ru.svolf.convertx.presentation.navigation.Base64Route -import ru.svolf.convertx.presentation.navigation.HexRoute -import ru.svolf.convertx.presentation.navigation.TextTool -import ru.svolf.convertx.presentation.navigation.TextToolRoute -import ru.svolf.convertx.presentation.navigation.UnicodeRoute import ru.svolf.convertx.presentation.viewmodel.ConverterUiState import ru.svolf.convertx.presentation.viewmodel.ConverterViewModel -@OptIn(ExperimentalMaterial3Api::class) +private const val ClearSoundVolume = 0.55f + +internal data class SwipeToClearState( + val offsetX: Animatable, + val hapticFeedback: HapticFeedback, + val context: Context, + val currentValue: String, + val currentOnSwipeToClear: () -> Unit, + val animationScope: CoroutineScope, + val textFieldState: androidx.compose.foundation.text.input.TextFieldState, + val maxSwipe: Float, + val swipeThreshold: Float, + val rainbowProgress: Animatable, + val activeBorderAlpha: Float, + val fieldShape: Shape, + val fieldColor: Color, + val activeBorderColor: Color +) + @Composable internal fun ConverterScreen( route: AppRoute, @@ -106,30 +110,12 @@ internal fun ConverterScreen( snackbar: SnackbarHostState ) { val copiedMessage = stringResource(R.string.copied2clipboard) - val inputHint = when (route) { - UnicodeRoute, is Base64Route -> stringResource(R.string.hint_utf) - is HexRoute -> stringResource(R.string.hint_string) - is TextToolRoute -> when (route.tool) { - TextTool.ADLER32 -> stringResource(R.string.hint_adler32) - TextTool.CRC32 -> stringResource(R.string.hint_crc) - TextTool.XML -> stringResource(R.string.hint_encoded_xml) - TextTool.TIMESTAMP -> stringResource(R.string.hint_timestamp) - } - - else -> stringResource(R.string.hint_string) - } - val outputHint = when (route) { - UnicodeRoute -> stringResource(R.string.hint_unicode) - is Base64Route -> stringResource(R.string.hint_base64) - is HexRoute -> stringResource(R.string.hint_hex) - else -> stringResource(R.string.copy2clipboard) - } + val inputHint = inputHint(route) + val outputHint = outputHint(route) val textStyle = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, - fontSize = fontSize.sp + fontFamily = FontFamily.Monospace, fontSize = fontSize.sp ) var activeField by remember { mutableStateOf(ConverterField.INPUT) } - Column( modifier = Modifier .fillMaxSize() @@ -137,7 +123,7 @@ internal fun ConverterScreen( .imePadding() .padding(horizontal = 16.dp, vertical = 8.dp) ) { - SwipeToClearTextField( + ConverterTextField( value = state.input, onValueChange = viewModel::onInputChanged, onSwipeToClear = viewModel::clearInput, @@ -152,31 +138,19 @@ internal fun ConverterScreen( CopyButton(state.input, copiedMessage, snackbar) } ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - ) { - DirectionSwitcher( - activeField = activeField, - onToggle = { - activeField = when (activeField) { - ConverterField.INPUT -> ConverterField.OUTPUT - ConverterField.OUTPUT -> ConverterField.INPUT - } - }, - modifier = Modifier.align(Alignment.Center) - ) - if (route is Base64Route || route is HexRoute) { - ModeMenu( - route = route, - selected = state.mode, - onSelected = viewModel::onModeChanged, - modifier = Modifier.align(Alignment.CenterStart) - ) - } - } - SwipeToClearTextField( + ConverterControls( + route = route, + selectedMode = state.mode, + activeField = activeField, + onFieldToggle = { + activeField = when (activeField) { + ConverterField.INPUT -> ConverterField.OUTPUT + ConverterField.OUTPUT -> ConverterField.INPUT + } + }, + onModeSelected = viewModel::onModeChanged + ) + ConverterTextField( value = state.output, onValueChange = viewModel::onOutputChanged, onSwipeToClear = viewModel::clearOutput, @@ -196,7 +170,36 @@ internal fun ConverterScreen( } @Composable -private fun SwipeToClearTextField( +private fun ConverterTextField( + value: String, + onValueChange: (String) -> Unit, + onSwipeToClear: () -> Unit, + isActive: Boolean, + onFocused: () -> Unit, + modifier: Modifier, + minLines: Int, + label: String, + textStyle: TextStyle, + highlightPulse: Long, + copyAction: @Composable () -> Unit +) { + SwipeToClearTextField( + value = value, + onValueChange = onValueChange, + onSwipeToClear = onSwipeToClear, + isActive = isActive, + onFocused = onFocused, + modifier = modifier, + minLines = minLines, + label = label, + textStyle = textStyle, + highlightPulse = highlightPulse, + copyAction = copyAction + ) +} + +@Composable +internal fun SwipeToClearTextField( value: String, onValueChange: (String) -> Unit, onSwipeToClear: () -> Unit, @@ -209,15 +212,75 @@ private fun SwipeToClearTextField( highlightPulse: Long, copyAction: @Composable () -> Unit ) { + val fieldState = rememberSwipeToClearState( + value = value, + onValueChange = onValueChange, + onSwipeToClear = onSwipeToClear, + isActive = isActive, + highlightPulse = highlightPulse, + ) + + Box( + modifier = modifier + .graphicsLayer { translationX = fieldState.offsetX.value } + .shadow(elevation = 3.dp, shape = fieldState.fieldShape) + .clip(fieldState.fieldShape) + .background(fieldState.fieldColor) + .clearSwipeGestures( + offsetX = fieldState.offsetX, + maxSwipe = fieldState.maxSwipe, + swipeThreshold = fieldState.swipeThreshold, + currentValue = fieldState.currentValue, + currentOnSwipeToClear = fieldState.currentOnSwipeToClear, + context = fieldState.context, + hapticFeedback = fieldState.hapticFeedback, + animationScope = fieldState.animationScope + ) + .drawSwipeBorder( + activeBorderAlpha = fieldState.activeBorderAlpha, + activeBorderColor = fieldState.activeBorderColor, + rainbowProgress = fieldState.rainbowProgress.value, + highlightPulse = highlightPulse + ) + ) { + SwipeToClearFieldContent( + textFieldState = fieldState.textFieldState, + onFocused = onFocused, + minLines = minLines, + textStyle = textStyle, + label = label, + onClear = { + animateClearFromButton( + fieldState.currentValue, + fieldState.hapticFeedback, + fieldState.animationScope, + fieldState.offsetX, + fieldState.context, + fieldState.maxSwipe, + fieldState.currentOnSwipeToClear + ) + }, + copyAction = copyAction + ) + } +} + +@Composable +internal fun rememberSwipeToClearState( + value: String, + onValueChange: (String) -> Unit, + onSwipeToClear: () -> Unit, + isActive: Boolean, + highlightPulse: Long +): SwipeToClearState { val offsetX = remember { Animatable(0f) } val hapticFeedback = LocalHapticFeedback.current val context = LocalContext.current val currentValue by rememberUpdatedState(value) val currentOnSwipeToClear by rememberUpdatedState(onSwipeToClear) - val currentOnValueChange by rememberUpdatedState(onValueChange) val animationScope = rememberCoroutineScope() val textFieldState = rememberTextFieldState(value) - var lastExternalValue by remember { mutableStateOf(value) } + val lastExternalValue = remember { mutableStateOf(value) } val density = androidx.compose.ui.platform.LocalDensity.current val maxSwipe = with(density) { 80.dp.toPx() } val swipeThreshold = with(density) { 56.dp.toPx() } @@ -227,7 +290,54 @@ private fun SwipeToClearTextField( animationSpec = tween(durationMillis = 220), label = "activeFieldBorder" ) + SyncSwipeField(value, highlightPulse, textFieldState, lastExternalValue, rainbowProgress, onValueChange) + return SwipeToClearState( + offsetX, + hapticFeedback, + context, + currentValue, + currentOnSwipeToClear, + animationScope, + textFieldState, + maxSwipe, + swipeThreshold, + rainbowProgress, + activeBorderAlpha, + RoundedCornerShape(20.dp), + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.primary + ) +} + +internal fun animateClearFromButton( + currentValue: String, + hapticFeedback: HapticFeedback, + animationScope: CoroutineScope, + offsetX: Animatable, + context: Context, + maxSwipe: Float, + onSwipeToClear: () -> Unit +) { + if (currentValue.isEmpty()) return + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + animationScope.launch { + offsetX.stop() + playClearSound(context) + offsetX.animateTo(-maxSwipe, tween(durationMillis = 500)) + onSwipeToClear() + offsetX.animateTo(0f, tween(durationMillis = 500)) + } +} +@Composable +internal fun SyncSwipeField( + value: String, + highlightPulse: Long, + textFieldState: androidx.compose.foundation.text.input.TextFieldState, + lastExternalValue: androidx.compose.runtime.MutableState, + rainbowProgress: Animatable, + onValueChange: (String) -> Unit +) { LaunchedEffect(highlightPulse) { if (highlightPulse != 0L) { rainbowProgress.snapTo(0f) @@ -237,205 +347,158 @@ private fun SwipeToClearTextField( ) } } - LaunchedEffect(value) { - lastExternalValue = value + lastExternalValue.value = value if (textFieldState.text.toString() != value) { textFieldState.setTextAndPlaceCursorAtEnd(value) } } - LaunchedEffect(textFieldState) { snapshotFlow { textFieldState.text.toString() }.collectLatest { newValue -> - if (newValue != lastExternalValue) { - lastExternalValue = newValue - currentOnValueChange(newValue) + if (newValue != lastExternalValue.value) { + lastExternalValue.value = newValue + onValueChange(newValue) } } } +} - val fieldShape = RoundedCornerShape(20.dp) - val fieldColor = MaterialTheme.colorScheme.surfaceContainerHighest - val activeBorderColor = MaterialTheme.colorScheme.primary - - fun animateClearFromButton() { - if (currentValue.isEmpty()) return - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - animationScope.launch { - offsetX.stop() - playClearSound(context) - offsetX.animateTo( - targetValue = -maxSwipe, - animationSpec = tween(durationMillis = 500) - ) - currentOnSwipeToClear() - offsetX.animateTo( - targetValue = 0f, - animationSpec = tween(durationMillis = 500) - ) - } - } - - Box( - modifier = modifier - .graphicsLayer { translationX = offsetX.value } - .shadow(elevation = 3.dp, shape = fieldShape) - .clip(fieldShape) - .background(fieldColor) - .pointerInput(Unit) { - var thresholdReached = false - detectHorizontalDragGestures( - onDragStart = { thresholdReached = false }, - onHorizontalDrag = { change, dragAmount -> - val nextOffset = (offsetX.value + dragAmount).coerceIn(-maxSwipe, 0f) - if (nextOffset != offsetX.value) { - change.consume() - animationScope.launch { offsetX.snapTo(nextOffset) } - if (!thresholdReached && nextOffset <= -swipeThreshold) { - thresholdReached = true - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - } - }, - onDragEnd = { - if (thresholdReached && currentValue.isNotEmpty()) { - playClearSound(context) - currentOnSwipeToClear() - } - animationScope.launch { - offsetX.animateTo(0f, tween(durationMillis = 220)) - } - thresholdReached = false - }, - onDragCancel = { - animationScope.launch { - offsetX.animateTo(0f, tween(durationMillis = 160)) - } - thresholdReached = false - } - ) - } - .drawWithCache { - val strokeWidth = 1.dp.toPx() - val inset = strokeWidth / 2f - val cornerRadius = 22.dp.toPx() - onDrawWithContent { - drawContent() - if (activeBorderAlpha > 0f) { - drawRoundRect( - color = activeBorderColor, - topLeft = Offset(inset, inset), - size = Size(size.width - strokeWidth, size.height - strokeWidth), - cornerRadius = CornerRadius(cornerRadius - inset), - style = Stroke(width = strokeWidth), - alpha = activeBorderAlpha - ) - } - val progress = rainbowProgress.value - val alpha = 1f - progress - if (highlightPulse != 0L && alpha > 0f) { - val colorPhase = progress * 360f - val rainbowBrush = Brush.sweepGradient( - colors = listOf(0f, 60f, 120f, 180f, 240f, 300f, 360f).map { hue -> - Color.hsv((hue - colorPhase + 360f) % 360f, 1f, 1f) - }, - center = Offset(size.width / 2f, size.height / 2f) - ) - drawRoundRect( - brush = rainbowBrush, - topLeft = Offset(inset, inset), - size = Size(size.width - strokeWidth, size.height - strokeWidth), - cornerRadius = CornerRadius(cornerRadius - inset), - style = Stroke(width = strokeWidth), - alpha = alpha - ) - } +internal fun Modifier.clearSwipeGestures( + offsetX: Animatable, + maxSwipe: Float, + swipeThreshold: Float, + currentValue: String, + currentOnSwipeToClear: () -> Unit, + context: Context, + hapticFeedback: HapticFeedback, + animationScope: CoroutineScope +): Modifier = pointerInput(Unit) { + var thresholdReached = false + detectHorizontalDragGestures( + onDragStart = { thresholdReached = false }, + onHorizontalDrag = { change, dragAmount -> + val nextOffset = (offsetX.value + dragAmount).coerceIn(-maxSwipe, 0f) + if (nextOffset != offsetX.value) { + change.consume() + animationScope.launch { offsetX.snapTo(nextOffset) } + if (!thresholdReached && nextOffset <= -swipeThreshold) { + thresholdReached = true + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) } } - ) { - Column(Modifier.fillMaxWidth()) { - Box(Modifier.fillMaxWidth()) { - CompositionLocalProvider( - LocalAutofillHighlightBrush provides SolidColor(Color.Transparent), - ) { - BasicTextField( - state = textFieldState, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { if (it.isFocused) onFocused() } - .padding(start = 16.dp, top = 34.dp, end = 16.dp, bottom = 14.dp), - lineLimits = TextFieldLineLimits.MultiLine(minLines), - textStyle = textStyle.copy(color = MaterialTheme.colorScheme.onSurface) - ) - } - Text( - text = label, - modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 16.dp, top = 12.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 1.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(end = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - ClearButton(onClick = { animateClearFromButton() }) - copyAction() + }, + onDragEnd = { + if (thresholdReached && currentValue.isNotEmpty()) { + playClearSound(context) + currentOnSwipeToClear() } + animationScope.launch { offsetX.animateTo(0f, tween(durationMillis = 220)) } + thresholdReached = false + }, + onDragCancel = { + animationScope.launch { offsetX.animateTo(0f, tween(durationMillis = 160)) } + thresholdReached = false } - } + ) } -@Composable -private fun ClearButton(onClick: () -> Unit) { - TextButton( - modifier = Modifier.height(48.dp), - onClick = onClick - ) { - Icon( - Icons.Default.Clear, - contentDescription = stringResource(R.string.clear) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.clear)) +internal fun Modifier.drawSwipeBorder( + activeBorderAlpha: Float, + activeBorderColor: Color, + rainbowProgress: Float, + highlightPulse: Long +): Modifier = drawWithCache { + val strokeWidth = 1.dp.toPx() + val inset = strokeWidth / 2f + val cornerRadius = 22.dp.toPx() + onDrawWithContent { + drawContent() + if (activeBorderAlpha > 0f) { + drawRoundRect( + color = activeBorderColor, + topLeft = Offset(inset, inset), + size = Size(size.width - strokeWidth, size.height - strokeWidth), + cornerRadius = CornerRadius(cornerRadius - inset), + style = Stroke(width = strokeWidth), + alpha = activeBorderAlpha + ) + } + val alpha = 1f - rainbowProgress + if (highlightPulse != 0L && alpha > 0f) { + val colorPhase = rainbowProgress * 360f + val rainbowBrush = Brush.sweepGradient( + colors = listOf(0f, 60f, 120f, 180f, 240f, 300f, 360f).map { hue -> + Color.hsv((hue - colorPhase + 360f) % 360f, 1f, 1f) + }, + center = Offset(size.width / 2f, size.height / 2f) + ) + drawRoundRect( + brush = rainbowBrush, + topLeft = Offset(inset, inset), + size = Size(size.width - strokeWidth, size.height - strokeWidth), + cornerRadius = CornerRadius(cornerRadius - inset), + style = Stroke(width = strokeWidth), + alpha = alpha + ) + } } } @Composable -private fun CopyButton(value: String, message: String, snackbar: SnackbarHostState) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - TextButton( - modifier = Modifier.height(48.dp), - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, value))) - snackbar.showSnackbar(message) +internal fun SwipeToClearFieldContent( + textFieldState: androidx.compose.foundation.text.input.TextFieldState, + onFocused: () -> Unit, + minLines: Int, + textStyle: TextStyle, + label: String, + onClear: () -> Unit, + copyAction: @Composable () -> Unit +) { + Column(Modifier.fillMaxWidth()) { + Box(Modifier.fillMaxWidth()) { + CompositionLocalProvider( + LocalAutofillHighlightBrush provides SolidColor(Color.Transparent), + ) { + BasicTextField( + state = textFieldState, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) onFocused() } + .padding(start = 16.dp, top = 34.dp, end = 16.dp, bottom = 14.dp), + lineLimits = TextFieldLineLimits.MultiLine(minLines), + textStyle = textStyle.copy(color = MaterialTheme.colorScheme.onSurface) + ) } + Text( + text = label, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 16.dp, top = 12.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - ) { - Icon( - Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.copy2clipboard) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.copy2clipboard)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + ClearButton(onClick = onClear) + copyAction() + } } } -private fun playClearSound(context: Context) { +internal fun playClearSound(context: Context) { MediaPlayer.create(context, R.raw.clear_swipe)?.apply { - setVolume(0.55f, 0.55f) + setVolume(ClearSoundVolume, ClearSoundVolume) setOnCompletionListener { player -> player.release() } setOnErrorListener { player, _, _ -> player.release() @@ -444,101 +507,3 @@ private fun playClearSound(context: Context) { start() } } - -private enum class ConverterField { INPUT, OUTPUT } - -@Composable -private fun DirectionSwitcher( - activeField: ConverterField, - onToggle: () -> Unit, - modifier: Modifier = Modifier -) { - val primary = MaterialTheme.colorScheme.primary - val muted = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) - val downTint by animateColorAsState( - targetValue = if (activeField == ConverterField.INPUT) primary else muted, - animationSpec = tween(durationMillis = 220), - label = "downArrowTint" - ) - val upTint by animateColorAsState( - targetValue = if (activeField == ConverterField.OUTPUT) primary else muted, - animationSpec = tween(durationMillis = 220), - label = "upArrowTint" - ) - - Column( - modifier = modifier - .height(46.dp) - .clickable(onClick = onToggle), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .width(2.dp) - .weight(1f) - .background(primary.copy(alpha = 0.65f)) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = downTint, - modifier = Modifier.size(20.dp) - ) - Icon( - imageVector = Icons.Default.KeyboardArrowUp, - contentDescription = null, - tint = upTint, - modifier = Modifier.size(20.dp) - ) - } - Box( - modifier = Modifier - .width(2.dp) - .weight(1f) - .background(primary.copy(alpha = 0.65f)) - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ModeMenu( - route: AppRoute, - selected: Int, - onSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - val options = if (route is Base64Route) { - listOf("No wrap", "No close", "No padding", "URL safe", "CRLF", "Default") - } else { - listOf("String", "Integer") - } - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = modifier - ) { - AssistChip( - onClick = { expanded = true }, - label = { Text(options.getOrElse(selected) { options.first() }) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - modifier = Modifier.menuAnchor() - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEachIndexed { index, option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { expanded = false; onSelected(index) }, - leadingIcon = if (index == selected) ({ - Icon( - Icons.Default.Check, - null - ) - }) else null - ) - } - } - } -} diff --git a/app/src/main/java/ru/svolf/convertx/presentation/compose/HistoryScreen.kt b/app/src/main/java/ru/svolf/convertx/presentation/compose/HistoryScreen.kt index 4c2e596..9f7b3b9 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/compose/HistoryScreen.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/compose/HistoryScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback @@ -38,6 +39,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import ru.svolf.convertx.R import ru.svolf.convertx.data.HistoryRecord @@ -86,8 +89,6 @@ private fun HistorySwipeItem( val hapticFeedback = LocalHapticFeedback.current val currentOnDelete by rememberUpdatedState(onDelete) val currentOnOpen by rememberUpdatedState(onOpen) - var thresholdReached = false - Box( modifier = Modifier .fillMaxWidth() @@ -110,69 +111,86 @@ private fun HistorySwipeItem( modifier = Modifier .fillMaxWidth() .graphicsLayer { translationX = swipeOffset.value } - .pointerInput(maxSwipeDistance) { - val maxSwipePx = maxSwipeDistance.toPx() - detectHorizontalDragGestures( - onHorizontalDrag = { change, dragAmount -> - val nextOffset = (swipeOffset.value + dragAmount) - .coerceIn(-maxSwipePx, 0f) - if (nextOffset != swipeOffset.value) { - change.consume() - scope.launch { swipeOffset.snapTo(nextOffset) } - val isThresholdReached = nextOffset <= -maxSwipePx - if (isThresholdReached && !thresholdReached) { - thresholdReached = true - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } else if (!isThresholdReached) { - thresholdReached = false - } - } - }, - onDragEnd = { - scope.launch { - if (swipeOffset.value <= -maxSwipePx) { - currentOnDelete() - } else { - swipeOffset.animateTo(0f, tween(durationMillis = 180)) - } - } - thresholdReached = false - }, - onDragCancel = { - scope.launch { - swipeOffset.animateTo(0f, tween(durationMillis = 180)) - } - thresholdReached = false - } - ) - } + .historySwipeGesture( + maxSwipeDistance = maxSwipeDistance, + swipeOffset = swipeOffset, + scope = scope, + hapticFeedback = hapticFeedback, + onDelete = currentOnDelete + ) .clickable { currentOnOpen() }, shape = shape ) { - Column(Modifier.padding(12.dp)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - record.input, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - Text( - decoderName(record.decoder), - style = MaterialTheme.typography.labelMedium - ) + HistoryCardContent(record) + } + } +} + +private fun Modifier.historySwipeGesture( + maxSwipeDistance: Dp, + swipeOffset: Animatable, + scope: CoroutineScope, + hapticFeedback: HapticFeedback, + onDelete: () -> Unit +): Modifier = pointerInput(maxSwipeDistance) { + val maxSwipePx = maxSwipeDistance.toPx() + var thresholdReached = false + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + val nextOffset = (swipeOffset.value + dragAmount).coerceIn(-maxSwipePx, 0f) + if (nextOffset != swipeOffset.value) { + change.consume() + scope.launch { swipeOffset.snapTo(nextOffset) } + val reached = nextOffset <= -maxSwipePx + if (reached && !thresholdReached) { + thresholdReached = true + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } else if (!reached) { + thresholdReached = false } - Text(record.output, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) - .format(Date(record.id)), - style = MaterialTheme.typography.labelSmall - ) } + }, + onDragEnd = { + scope.launch { + if (swipeOffset.value <= -maxSwipePx) { + onDelete() + } else { + swipeOffset.animateTo(0f, tween(durationMillis = 180)) + } + } + thresholdReached = false + }, + onDragCancel = { + scope.launch { swipeOffset.animateTo(0f, tween(durationMillis = 180)) } + thresholdReached = false + } + ) +} + +@Composable +private fun HistoryCardContent(record: HistoryRecord) { + Column(Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + record.input, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + decoderName(record.decoder), + style = MaterialTheme.typography.labelMedium + ) } + Text(record.output, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(Date(record.id)), + style = MaterialTheme.typography.labelSmall + ) } } diff --git a/app/src/main/java/ru/svolf/convertx/presentation/compose/PaletteScreen.kt b/app/src/main/java/ru/svolf/convertx/presentation/compose/PaletteScreen.kt index b553219..44c01c1 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/compose/PaletteScreen.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/compose/PaletteScreen.kt @@ -39,6 +39,8 @@ import kotlinx.coroutines.launch import ru.svolf.convertx.R import ru.svolf.convertx.data.entity.Palette +private const val PreviewColorIndex = 5 + @Composable internal fun PaletteScreen(palettes: List, snackbar: SnackbarHostState) { var selected by rememberSaveable { mutableIntStateOf(0) } @@ -60,7 +62,7 @@ internal fun PaletteScreen(palettes: List, snackbar: SnackbarHostState) modifier = Modifier .size(64.dp) .clickable { selected = index }, - color = parseColor(palette.colors.getOrNull(5)?.hex), + color = parseColor(palette.colors.getOrNull(PreviewColorIndex)?.hex), shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), tonalElevation = if (selected == index) 8.dp else 1.dp ) {} diff --git a/app/src/main/java/ru/svolf/convertx/presentation/compose/TextToolScreen.kt b/app/src/main/java/ru/svolf/convertx/presentation/compose/TextToolScreen.kt new file mode 100644 index 0000000..0796540 --- /dev/null +++ b/app/src/main/java/ru/svolf/convertx/presentation/compose/TextToolScreen.kt @@ -0,0 +1,174 @@ +package ru.svolf.convertx.presentation.compose + +import android.content.ClipData +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AdsClick +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import ru.svolf.convertx.R +import ru.svolf.convertx.presentation.navigation.TextToolRoute +import ru.svolf.convertx.presentation.viewmodel.TextToolUiState +import ru.svolf.convertx.presentation.viewmodel.TextToolViewModel + +@Composable +internal fun TextToolScreen( + route: TextToolRoute, + state: TextToolUiState, + fontSize: Int, + viewModel: TextToolViewModel, + snackbar: SnackbarHostState, + setToolbarActions: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit) -> Unit +) { + val textStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, fontSize = fontSize.sp + ) + val copiedMessage = stringResource(R.string.copied2clipboard) + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + LaunchedEffect(state.output, setToolbarActions) { + setToolbarActions { + TextToolToolbarActions( + route = route, + output = state.output, + copiedMessage = copiedMessage, + snackbar = snackbar, + onInsertTimestamp = { + val currentTimestamp = System.currentTimeMillis().toString() + viewModel.onInputChanged(currentTimestamp) + } + ) + } + } + + DisposableEffect(Unit) { + onDispose { + setToolbarActions {} + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SwipeToClearTextField( + value = state.input, + onValueChange = viewModel::onInputChanged, + onSwipeToClear = { viewModel.onInputChanged("") }, + isActive = true, + onFocused = { }, + modifier = Modifier.fillMaxWidth(), + minLines = 5, + label = inputHint(route), + textStyle = textStyle, + highlightPulse = 0L, + copyAction = { + CopyButton(state.input, copiedMessage, snackbar) + } + ) + + if (state.output.isNotEmpty()) { + TextToolOutputCard(state.output, textStyle) + } + + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } +} + +@Composable +internal fun TextToolToolbarActions( + route: TextToolRoute, + output: String, + copiedMessage: String, + snackbar: SnackbarHostState, + onInsertTimestamp: () -> Unit +) { + val clipboard = LocalClipboard.current + val scope = androidx.compose.runtime.rememberCoroutineScope() + androidx.compose.material3.Card( + modifier = Modifier.height(46.dp), + shape = androidx.compose.foundation.shape.CircleShape, + elevation = androidx.compose.material3.CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = androidx.compose.material3.CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + androidx.compose.foundation.layout.Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + if (route.tool == ru.svolf.convertx.presentation.navigation.TextTool.TIMESTAMP) { + IconButton(onClick = onInsertTimestamp) { + Icon( + imageVector = Icons.Default.AdsClick, + contentDescription = "Insert Timestamp" + ) + } + } + + IconButton( + onClick = { + if (output.isNotEmpty()) { + scope.launch { + val clipData = ClipData.newPlainText(null, output) + clipboard.setClipEntry(ClipEntry(clipData)) + snackbar.showSnackbar(copiedMessage) + } + } + } + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.copy2clipboard) + ) + } + } + } +} + +@Composable +internal fun TextToolOutputCard(output: String, textStyle: androidx.compose.ui.text.TextStyle) { + androidx.compose.material3.OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + androidx.compose.foundation.text.selection.SelectionContainer { + Text( + text = output, + modifier = Modifier.padding(16.dp), + style = textStyle, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/app/src/main/java/ru/svolf/convertx/presentation/navigation/Backdrop.kt b/app/src/main/java/ru/svolf/convertx/presentation/navigation/Backdrop.kt index 9e364de..e2b291f 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/navigation/Backdrop.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/navigation/Backdrop.kt @@ -42,66 +42,95 @@ internal fun Backdrop( .background(MaterialTheme.colorScheme.background) ) { toolbarContent() + BackdropLayers(Modifier.weight(1f), isOpen, onClose, backContent, frontContent) + } +} - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .clipToBounds() - ) { - var backHeightPx by remember { mutableIntStateOf(0) } - val density = androidx.compose.ui.platform.LocalDensity.current - val maxHeightPx = with(density) { maxHeight.toPx() } - val targetOffset = if (isOpen) { - with(density) { min(backHeightPx.toFloat(), maxHeightPx).toDp() } - } else { - 0.dp - } - val offset by animateDpAsState( - targetValue = targetOffset, - animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), - label = "backdropOffset" - ) - val frontShape = if (isOpen) { - RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) - } else { - RectangleShape - } +@Composable +private fun BackdropLayers( + modifier: Modifier, + isOpen: Boolean, + onClose: () -> Unit, + backContent: @Composable () -> Unit, + frontContent: @Composable () -> Unit +) { + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .clipToBounds() + ) { + var backHeightPx by remember { mutableIntStateOf(0) } + val density = androidx.compose.ui.platform.LocalDensity.current + val maxHeightPx = with(density) { maxHeight.toPx() } + val targetOffset = if (isOpen) { + with(density) { min(backHeightPx.toFloat(), maxHeightPx).toDp() } + } else { + 0.dp + } + val offset by animateDpAsState( + targetValue = targetOffset, + animationSpec = tween(durationMillis = 350, easing = FastOutSlowInEasing), + label = "backdropOffset" + ) + val frontShape = if (isOpen) { + RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + } else { + RectangleShape + } - Box( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .onGloballyPositioned { coordinates -> - if (backHeightPx != coordinates.size.height) { - backHeightPx = coordinates.size.height - } - } - .zIndex(0f) - ) { - backContent() - } + BackdropBackLayer(backHeightPx, { backHeightPx = it }, backContent) + BackdropFrontLayer(offset, frontShape, frontContent) + if (isOpen) BackdropScrim(offset, onClose) + } +} - Box( - modifier = Modifier - .fillMaxSize() - .offset(y = offset) - .clip(frontShape) - .zIndex(1f) - ) { - frontContent() +@Composable +private fun BackdropBackLayer( + backHeightPx: Int, + onHeightChanged: (Int) -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .onGloballyPositioned { coordinates -> + if (backHeightPx != coordinates.size.height) onHeightChanged(coordinates.size.height) } + .zIndex(0f) + ) { + content() + } +} - if (isOpen) { - Box( - modifier = Modifier - .fillMaxSize() - .offset(y = offset) - .zIndex(2f) - .clickable(onClick = onClose) - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.20f)) - ) - } - } +@Composable +private fun BackdropFrontLayer( + offset: androidx.compose.ui.unit.Dp, + shape: androidx.compose.ui.graphics.Shape, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .offset(y = offset) + .clip(shape) + .zIndex(1f) + ) { + content() } } + +@Composable +private fun BackdropScrim( + offset: androidx.compose.ui.unit.Dp, + onClose: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .offset(y = offset) + .zIndex(2f) + .clickable(onClick = onClose) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.20f)) + ) +} diff --git a/app/src/main/java/ru/svolf/convertx/presentation/navigation/ConvertXNavigation.kt b/app/src/main/java/ru/svolf/convertx/presentation/navigation/ConvertXNavigation.kt index 11c2b97..253848d 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/navigation/ConvertXNavigation.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/navigation/ConvertXNavigation.kt @@ -1,32 +1,16 @@ package ru.svolf.convertx.presentation.navigation -import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,7 +18,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -45,8 +28,6 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import ru.svolf.convertx.R import ru.svolf.convertx.di.AppComponent import ru.svolf.convertx.presentation.compose.AboutScreen @@ -57,12 +38,13 @@ import ru.svolf.convertx.presentation.compose.OtherToolsScreen import ru.svolf.convertx.presentation.compose.PaletteScreen import ru.svolf.convertx.presentation.compose.RegexScreen import ru.svolf.convertx.presentation.compose.SettingsScreen +import ru.svolf.convertx.presentation.compose.TextToolScreen import ru.svolf.convertx.presentation.viewmodel.ConverterViewModel import ru.svolf.convertx.presentation.viewmodel.HistoryViewModel import ru.svolf.convertx.presentation.viewmodel.PaletteViewModel import ru.svolf.convertx.presentation.viewmodel.RegexViewModel import ru.svolf.convertx.presentation.viewmodel.SettingsViewModel -import kotlin.time.Duration.Companion.seconds +import ru.svolf.convertx.presentation.viewmodel.TextToolViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,161 +61,79 @@ fun ConvertXNavigation(component: AppComponent) { viewModel(factory = component.settingsViewModelFactory()) val settings by settingsViewModel.state.collectAsStateWithLifecycle() val pressAgainMessage = stringResource(R.string.press_back_once_more) - - LaunchedEffect(exitArmed) { - if (exitArmed) { - delay(2.seconds) - exitArmed = false - } - } - - BackHandler { - when { - backdropOpen -> backdropOpen = false - backStack.size > 1 -> backStack.removeLastOrNull() - settings.twiceBackToExit && !exitArmed -> { - exitArmed = true - scope.launch { snackbar.showSnackbar(pressAgainMessage) } - } - - else -> activity?.finish() - } + var toolbarActions by remember { + mutableStateOf<@Composable androidx.compose.foundation.layout.RowScope.() -> Unit>({}) } - if (showExitDialog) { - AlertDialog( - onDismissRequest = { showExitDialog = false }, - text = { Text(stringResource(R.string.dr_close_app)) }, - confirmButton = { - TextButton(onClick = { activity?.finish() }) { Text(stringResource(R.string.yes)) } - }, - dismissButton = { - TextButton(onClick = { - showExitDialog = false - }) { Text(stringResource(R.string.no)) } - } - ) - } + NavigationBackHandler( + backdropOpen = backdropOpen, + onCloseBackdrop = { backdropOpen = false }, + backStack = backStack, + twiceBackToExit = settings.twiceBackToExit, + exitArmed = exitArmed, + onArmExit = { exitArmed = true }, + onDisarmExit = { exitArmed = false }, + snackbar = snackbar, + pressAgainMessage = pressAgainMessage, + activity = activity, + scope = scope + ) + if (showExitDialog) ExitDialog(activity, onDismiss = { showExitDialog = false }) Backdrop( isOpen = backdropOpen, onClose = { backdropOpen = false }, - toolbarContent = { - TopAppBar( - modifier = Modifier.padding(horizontal = 8.dp), - title = { - Text( - text = routeTitle(currentRoute), - style = MaterialTheme.typography.titleMedium - ) - }, - navigationIcon = { - Card( - modifier = Modifier - .size(46.dp), - shape = CircleShape, - elevation = CardDefaults.cardElevation( - defaultElevation = 4.dp - ), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ) - ) { - IconButton(onClick = { backdropOpen = !backdropOpen }) { - Icon( - imageVector = if (backdropOpen) Icons.Default.Close else Icons.Default.Menu, - contentDescription = stringResource( - if (backdropOpen) R.string.dr_close_app else R.string.dr_other1 - ) - ) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = MaterialTheme.colorScheme.background, - navigationIconContentColor = MaterialTheme.colorScheme.onBackground, - titleContentColor = MaterialTheme.colorScheme.onBackground, - actionIconContentColor = MaterialTheme.colorScheme.onBackground - ) - ) + toolbarContent = { + NavigationToolbar(currentRoute, backdropOpen, toolbarActions) { backdropOpen = !backdropOpen } }, backContent = { - BackdropContent( - onRouteSelected = { route -> - backdropOpen = false - if (route == null) { - showExitDialog = true - } else { - backStack.clear() - backStack.add(UnicodeRoute) - if (route != UnicodeRoute) backStack.add(route) - } - } - ) - }, - frontContent = { - Scaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), - snackbarHost = { SnackbarHost(snackbar, Modifier.navigationBarsPadding()) } - ) { padding -> - NavDisplay( - backStack = backStack, - modifier = Modifier - .fillMaxSize() - .padding(padding), - onBack = { backStack.removeLastOrNull() }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ), - entryProvider = entryProvider { - entry { - converterEntry( - component, - UnicodeRoute, - settings.fontSize, - snackbar - ) - } - entry { key -> - converterEntry( - component, - key, - settings.fontSize, - snackbar - ) - } - entry { key -> - converterEntry( - component, - key, - settings.fontSize, - snackbar - ) - } - entry { key -> - converterEntry( - component, - key, - settings.fontSize, - snackbar - ) - } - entry { regexEntry(component) } - entry { paletteEntry(component, snackbar) } - entry { historyEntry(component, backStack) } - entry { settingsEntry(component) } - entry { OtherToolsScreen { backStack.add(it) } } - entry { AboutScreen(onChangelog = { backStack.add(ChangelogRoute) }) } - entry { ChangelogScreen() } - } - ) + BackdropContent { route -> + selectRoute(route, backStack, { backdropOpen = false }, { showExitDialog = true }) } + }, + frontContent = { + NavigationFront(component, backStack, settings.fontSize, snackbar) { toolbarActions = it } } ) } +@Composable +private fun NavigationFront( + component: AppComponent, + backStack: MutableList, + fontSize: Int, + snackbar: SnackbarHostState, + setToolbarActions: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit) -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackbar, Modifier.navigationBarsPadding()) } + ) { padding -> + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize().padding(padding), + onBack = { backStack.removeLastOrNull() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { converterEntry(component, UnicodeRoute, fontSize, snackbar) } + entry { converterEntry(component, it, fontSize, snackbar) } + entry { converterEntry(component, it, fontSize, snackbar) } + entry { textToolEntry(component, it, fontSize, snackbar, setToolbarActions) } + entry { regexEntry(component) } + entry { paletteEntry(component, snackbar) } + entry { historyEntry(component, backStack) } + entry { settingsEntry(component) } + entry { OtherToolsScreen { backStack.add(it) } } + entry { AboutScreen(onChangelog = { backStack.add(ChangelogRoute) }) } + entry { ChangelogScreen() } + } + ) + } +} + @Composable private fun converterEntry( component: AppComponent, @@ -247,6 +147,20 @@ private fun converterEntry( ConverterScreen(route, state, fontSize, viewModel, snackbar) } +@Composable +private fun textToolEntry( + component: AppComponent, + route: TextToolRoute, + fontSize: Int, + snackbar: SnackbarHostState, + setToolbarActions: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit) -> Unit +) { + val viewModel: TextToolViewModel = + viewModel(factory = component.textToolViewModelFactory().forRoute(route)) + val state by viewModel.state.collectAsStateWithLifecycle() + TextToolScreen(route, state, fontSize, viewModel, snackbar, setToolbarActions) +} + @Composable private fun historyEntry(component: AppComponent, backStack: MutableList) { val viewModel: HistoryViewModel = viewModel(factory = component.historyViewModelFactory()) diff --git a/app/src/main/java/ru/svolf/convertx/presentation/navigation/RouteCatalog.kt b/app/src/main/java/ru/svolf/convertx/presentation/navigation/MenuItem.kt similarity index 85% rename from app/src/main/java/ru/svolf/convertx/presentation/navigation/RouteCatalog.kt rename to app/src/main/java/ru/svolf/convertx/presentation/navigation/MenuItem.kt index cc8451f..dbac99c 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/navigation/RouteCatalog.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/navigation/MenuItem.kt @@ -46,10 +46,13 @@ internal fun routeTitle(route: AppRoute): String = when (route) { is SettingsRoute -> stringResource(R.string.settings) is AboutRoute -> stringResource(R.string.dr_about) is ChangelogRoute -> stringResource(R.string.changelog) - is TextToolRoute -> when (route.tool) { - TextTool.ADLER32 -> stringResource(R.string.checksum_adler32) - TextTool.CRC32 -> stringResource(R.string.checksum_crc) - TextTool.XML -> stringResource(R.string.unescape_xml) - TextTool.TIMESTAMP -> stringResource(R.string.timestamp_converter) - } + is TextToolRoute -> textToolTitle(route.tool) +} + +@Composable +private fun textToolTitle(tool: TextTool): String = when (tool) { + TextTool.ADLER32 -> stringResource(R.string.checksum_adler32) + TextTool.CRC32 -> stringResource(R.string.checksum_crc) + TextTool.XML -> stringResource(R.string.unescape_xml) + TextTool.TIMESTAMP -> stringResource(R.string.timestamp_converter) } diff --git a/app/src/main/java/ru/svolf/convertx/presentation/navigation/NavigationChrome.kt b/app/src/main/java/ru/svolf/convertx/presentation/navigation/NavigationChrome.kt new file mode 100644 index 0000000..2e9c76f --- /dev/null +++ b/app/src/main/java/ru/svolf/convertx/presentation/navigation/NavigationChrome.kt @@ -0,0 +1,135 @@ +package ru.svolf.convertx.presentation.navigation + +import android.app.Activity +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ru.svolf.convertx.R +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun NavigationBackHandler( + backdropOpen: Boolean, + onCloseBackdrop: () -> Unit, + backStack: MutableList, + twiceBackToExit: Boolean, + exitArmed: Boolean, + onArmExit: () -> Unit, + onDisarmExit: () -> Unit, + snackbar: SnackbarHostState, + pressAgainMessage: String, + activity: Activity?, + scope: CoroutineScope +) { + LaunchedEffect(exitArmed) { + if (exitArmed) { + delay(2.seconds) + onDisarmExit() + } + } + BackHandler { + when { + backdropOpen -> onCloseBackdrop() + backStack.size > 1 -> backStack.removeLastOrNull() + twiceBackToExit && !exitArmed -> { + onArmExit() + scope.launch { snackbar.showSnackbar(pressAgainMessage) } + } + + else -> activity?.finish() + } + } +} + +@Composable +internal fun ExitDialog(activity: Activity?, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(stringResource(R.string.dr_close_app)) }, + confirmButton = { + TextButton(onClick = { activity?.finish() }) { Text(stringResource(R.string.yes)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.no)) } } + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun NavigationToolbar( + currentRoute: AppRoute, + backdropOpen: Boolean, + actions: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit = {}, + onMenuClick: () -> Unit +) { + TopAppBar( + modifier = Modifier.padding(horizontal = 8.dp), + title = { Text(routeTitle(currentRoute), style = MaterialTheme.typography.titleMedium) }, + navigationIcon = { + Card( + modifier = Modifier.size(46.dp), + shape = CircleShape, + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + IconButton(onClick = onMenuClick) { + Icon( + imageVector = if (backdropOpen) Icons.Default.Close else Icons.Default.Menu, + contentDescription = stringResource( + if (backdropOpen) R.string.dr_close_app else R.string.dr_other1 + ) + ) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.background, + navigationIconContentColor = MaterialTheme.colorScheme.onBackground, + titleContentColor = MaterialTheme.colorScheme.onBackground, + actionIconContentColor = MaterialTheme.colorScheme.onBackground + ) + ) +} + +internal fun selectRoute( + route: AppRoute?, + backStack: MutableList, + closeBackdrop: () -> Unit, + showExitDialog: () -> Unit +) { + closeBackdrop() + if (route == null) { + showExitDialog() + } else { + backStack.clear() + backStack.add(UnicodeRoute) + if (route != UnicodeRoute) backStack.add(route) + } +} diff --git a/app/src/main/java/ru/svolf/convertx/presentation/ui/ConvertXTheme.kt b/app/src/main/java/ru/svolf/convertx/presentation/ui/ConvertXTheme.kt index bfd2f20..6611e07 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/ui/ConvertXTheme.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/ui/ConvertXTheme.kt @@ -1,7 +1,9 @@ package ru.svolf.convertx.presentation.ui import android.app.Activity +import android.content.Context import android.os.Build +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme @@ -22,69 +24,96 @@ import ru.svolf.convertx.R import ru.svolf.convertx.data.SettingsState import ru.svolf.convertx.data.ThemeMode +private val DarkBackgroundColor = Color(0xFF0A2023) +private val DarkSurfaceColor = Color(0xFF14282A) +private val DarkSurfaceLowestColor = Color(0xFF071719) +private val DarkSurfaceLowColor = Color(0xFF0D2022) +private val DarkSurfaceHighColor = Color(0xFF25282C) +private val DarkSurfaceHighestColor = Color(0xFF302E35) +private val DarkOnBackgroundColor = Color(0xFFE5F0F1) +private val LightBackgroundColor = Color(0xFFF1F3F4) +private val LightOnBackgroundColor = Color(0xFF1B1B1F) + @Composable fun ConvertXTheme(settings: SettingsState, content: @Composable () -> Unit) { val dark = settings.theme == ThemeMode.DARK val context = LocalContext.current - val colors = if (dark) { - val base = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicDarkColorScheme(context) - } else { - darkColorScheme( - primary = colorResource(R.color.md_theme_dark_primary), - onPrimary = colorResource(R.color.md_theme_dark_onPrimary), - primaryContainer = colorResource(R.color.md_theme_dark_primaryContainer), - onPrimaryContainer = colorResource(R.color.md_theme_dark_onPrimaryContainer), - background = colorResource(R.color.md_theme_dark_background), - onBackground = colorResource(R.color.md_theme_dark_onBackground), - surface = colorResource(R.color.md_theme_dark_surface), - onSurface = colorResource(R.color.md_theme_dark_onSurface) - ) - } - base.copy( - background = Color(0xFF0A2023), - surface = Color(0xFF14282A), - surfaceContainerLowest = Color(0xFF071719), - surfaceContainerLow = Color(0xFF0D2022), - surfaceContainer = Color(0xFF14282A), - surfaceContainerHigh = Color(0xFF25282C), - surfaceContainerHighest = Color(0xFF302E35), - onBackground = Color(0xFFE5F0F1), - onSurface = Color(0xFFE5F0F1) + val colors = themeColors(dark, context) + val typography = appTypography() + ConfigureSystemBars(dark) + MaterialTheme(colorScheme = colors, typography = typography, content = content) +} + +@Composable +private fun themeColors(dark: Boolean, context: Context): ColorScheme = + if (dark) darkThemeColors(context) else lightThemeColors(context) + +@Composable +private fun darkThemeColors(context: Context): ColorScheme { + val base = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(context) + } else { + darkColorScheme( + primary = colorResource(R.color.md_theme_dark_primary), + onPrimary = colorResource(R.color.md_theme_dark_onPrimary), + primaryContainer = colorResource(R.color.md_theme_dark_primaryContainer), + onPrimaryContainer = colorResource(R.color.md_theme_dark_onPrimaryContainer), + background = colorResource(R.color.md_theme_dark_background), + onBackground = colorResource(R.color.md_theme_dark_onBackground), + surface = colorResource(R.color.md_theme_dark_surface), + onSurface = colorResource(R.color.md_theme_dark_onSurface) ) + } + return base.copy( + background = DarkBackgroundColor, + surface = DarkSurfaceColor, + surfaceContainerLowest = DarkSurfaceLowestColor, + surfaceContainerLow = DarkSurfaceLowColor, + surfaceContainer = DarkSurfaceColor, + surfaceContainerHigh = DarkSurfaceHighColor, + surfaceContainerHighest = DarkSurfaceHighestColor, + onBackground = DarkOnBackgroundColor, + onSurface = DarkOnBackgroundColor + ) +} + +@Composable +private fun lightThemeColors(context: Context): ColorScheme { + val base = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(context) } else { - val base = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicLightColorScheme(context) - } else { - lightColorScheme( - primary = colorResource(R.color.md_theme_light_primary), - onPrimary = colorResource(R.color.md_theme_light_onPrimary), - primaryContainer = colorResource(R.color.md_theme_light_primaryContainer), - onPrimaryContainer = colorResource(R.color.md_theme_light_onPrimaryContainer), - background = colorResource(R.color.md_theme_light_background), - onBackground = colorResource(R.color.md_theme_light_onBackground), - surface = colorResource(R.color.md_theme_light_surface), - onSurface = colorResource(R.color.md_theme_light_onSurface) - ) - } - base.copy( - background = Color(0xFFF1F3F4), - surface = Color.White, - surfaceContainerLowest = Color.White, - surfaceContainerLow = Color.White, - surfaceContainer = Color.White, - surfaceContainerHigh = Color.White, - surfaceContainerHighest = Color.White, - onBackground = Color(0xFF1B1B1F), - onSurface = Color(0xFF1B1B1F) + lightColorScheme( + primary = colorResource(R.color.md_theme_light_primary), + onPrimary = colorResource(R.color.md_theme_light_onPrimary), + primaryContainer = colorResource(R.color.md_theme_light_primaryContainer), + onPrimaryContainer = colorResource(R.color.md_theme_light_onPrimaryContainer), + background = colorResource(R.color.md_theme_light_background), + onBackground = colorResource(R.color.md_theme_light_onBackground), + surface = colorResource(R.color.md_theme_light_surface), + onSurface = colorResource(R.color.md_theme_light_onSurface) ) } + return base.copy( + background = LightBackgroundColor, + surface = Color.White, + surfaceContainerLowest = Color.White, + surfaceContainerLow = Color.White, + surfaceContainer = Color.White, + surfaceContainerHigh = Color.White, + surfaceContainerHighest = Color.White, + onBackground = LightOnBackgroundColor, + onSurface = LightOnBackgroundColor + ) +} + +@Composable +private fun appTypography(): Typography { val googleSans = FontFamily( Font(R.font.googlesans_regular, FontWeight.Normal), Font(R.font.googlesans_medium, FontWeight.Medium), Font(R.font.googlesans_bold, FontWeight.Bold) ) - val typography = Typography().let { base -> + return Typography().let { base -> base.copy( bodyLarge = base.bodyLarge.copy(fontFamily = googleSans), bodyMedium = base.bodyMedium.copy(fontFamily = googleSans), @@ -92,7 +121,10 @@ fun ConvertXTheme(settings: SettingsState, content: @Composable () -> Unit) { headlineSmall = base.headlineSmall.copy(fontFamily = googleSans) ) } +} +@Composable +private fun ConfigureSystemBars(dark: Boolean) { val view = LocalView.current if (!view.isInEditMode) { SideEffect { @@ -102,6 +134,4 @@ fun ConvertXTheme(settings: SettingsState, content: @Composable () -> Unit) { controller.isAppearanceLightNavigationBars = !dark } } - - MaterialTheme(colorScheme = colors, typography = typography, content = content) } diff --git a/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModel.kt b/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModel.kt index 8265041..9cd7b20 100644 --- a/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModel.kt +++ b/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/ConverterViewModel.kt @@ -21,6 +21,8 @@ import ru.svolf.convertx.presentation.navigation.Base64Route import ru.svolf.convertx.presentation.navigation.HexRoute import javax.inject.Inject +private const val ConversionDebounceMs = 2_000L + data class ConverterUiState( val input: String = "", val output: String = "", @@ -104,7 +106,7 @@ class ConverterViewModel( private fun schedule(block: suspend () -> Unit) { conversionJob?.cancel() conversionJob = viewModelScope.launch { - delay(2_000) + delay(ConversionDebounceMs) if (_state.value.input.isNotBlank() || _state.value.output.isNotBlank()) block() } } @@ -143,7 +145,9 @@ class ConverterViewModel( ) ) } - } catch (error: Throwable) { + } catch (error: IllegalArgumentException) { + _state.update { it.copy(busy = false, error = error.message ?: "Conversion failed") } + } catch (error: StringIndexOutOfBoundsException) { _state.update { it.copy(busy = false, error = error.message ?: "Conversion failed") } } } diff --git a/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/TextToolViewModel.kt b/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/TextToolViewModel.kt new file mode 100644 index 0000000..aab2f8d --- /dev/null +++ b/app/src/main/java/ru/svolf/convertx/presentation/viewmodel/TextToolViewModel.kt @@ -0,0 +1,77 @@ +package ru.svolf.convertx.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.svolf.convertx.data.ConverterEngine +import ru.svolf.convertx.presentation.navigation.TextToolRoute +import javax.inject.Inject + +data class TextToolUiState( + val input: String = "", + val output: String = "", + val error: String? = null, + val busy: Boolean = false +) + +class TextToolViewModel( + private val route: TextToolRoute, + private val converter: ConverterEngine +) : ViewModel() { + private val _state = MutableStateFlow(TextToolUiState()) + val state: StateFlow = _state.asStateFlow() + private var conversionJob: Job? = null + + fun onInputChanged(value: String) { + _state.update { it.copy(input = value, error = null) } + schedule { runConversion(value) } + } + + private fun schedule(block: suspend () -> Unit) { + conversionJob?.cancel() + conversionJob = viewModelScope.launch { + delay(DEBOUNCE_DELAY) + if (_state.value.input.isNotBlank()) block() + else _state.update { it.copy(output = "") } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun runConversion(value: String) { + _state.update { it.copy(busy = true, error = null) } + try { + val result = withContext(Dispatchers.Default) { + converter.convert(route, value, true, 0) + } + _state.update { + it.copy(output = result, busy = false) + } + } catch (error: Exception) { + _state.update { it.copy(busy = false, error = error.message ?: "Conversion failed") } + } + } + + companion object { + private const val DEBOUNCE_DELAY = 500L + } + + class Factory @Inject constructor( + private val converter: ConverterEngine + ) { + fun forRoute(route: TextToolRoute): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + TextToolViewModel(route, converter) as T + } + } +} diff --git a/app/src/main/java/ru/svolf/convertx/utils/DecoderConst.kt b/app/src/main/java/ru/svolf/convertx/utils/DecoderConst.kt index 403f215..0f74dfe 100644 --- a/app/src/main/java/ru/svolf/convertx/utils/DecoderConst.kt +++ b/app/src/main/java/ru/svolf/convertx/utils/DecoderConst.kt @@ -8,4 +8,4 @@ object DecoderConst { const val UNICODE = 0 const val BASE64 = 1 const val HEX = 2 -} \ No newline at end of file +} diff --git a/app/src/main/java/ru/svolf/convertx/utils/_Int.kt b/app/src/main/java/ru/svolf/convertx/utils/_Int.kt index e7d6d71..439d580 100644 --- a/app/src/main/java/ru/svolf/convertx/utils/_Int.kt +++ b/app/src/main/java/ru/svolf/convertx/utils/_Int.kt @@ -6,4 +6,4 @@ import android.content.res.Resources.getSystem * Created by SVolf on 18.02.2023, 13:51 * This file is a part of "AnonFiles" project */ -val Int.dpi: Int get() = (this / getSystem().displayMetrics.density).toInt() \ No newline at end of file +val Int.dpi: Int get() = (this / getSystem().displayMetrics.density).toInt() diff --git a/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt b/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt index 4dafef2..97ca937 100644 --- a/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt +++ b/app/src/main/java/ru/svolf/convertx/utils/algorhitms/Decoder.kt @@ -15,6 +15,11 @@ import kotlin.coroutines.suspendCoroutine * Created by Snow Volf on 21.02.2017, 8:42 */ object Decoder { + private const val HEX_RADIX = 16 + private const val UNICODE_HEX_DIGITS = 4 + private const val HEX_SHIFT = 4 + private const val HEX_LETTER_OFFSET = 10 + /** * Расшифровка Unicode */ @@ -31,12 +36,15 @@ object Decoder { if (aChar == 'u') { // Читаем массив var value = 0 - for (i in 0..3) { + for (i in 0 until UNICODE_HEX_DIGITS) { aChar = tracer[x++] value = when (aChar) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> (value shl 4) + aChar.code - '0'.code - 'a', 'b', 'c', 'd', 'e', 'f' -> (value shl 4) + 10 + aChar.code - 'a'.code - 'A', 'B', 'C', 'D', 'E', 'F' -> (value shl 4) + 10 + aChar.code - 'A'.code + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> + (value shl HEX_SHIFT) + aChar.code - '0'.code + 'a', 'b', 'c', 'd', 'e', 'f' -> + (value shl HEX_SHIFT) + HEX_LETTER_OFFSET + aChar.code - 'a'.code + 'A', 'B', 'C', 'D', 'E', 'F' -> + (value shl HEX_SHIFT) + HEX_LETTER_OFFSET + aChar.code - 'A'.code else -> throw IllegalArgumentException( "Malformed \\uxxxx encoding." ) @@ -159,12 +167,10 @@ object Decoder { */ suspend fun hexToInt(hexadecimal: String): String { return suspendCoroutine { - try { - val test = hexadecimal.toInt(16).toString().replaceFirst("0x", "") - it.resume(test) - } catch (ex: Throwable) { - it.resume("Error. Out of range") - } + val result = runCatching { + hexadecimal.toInt(HEX_RADIX).toString().replaceFirst("0x", "") + }.getOrElse { "Error. Out of range" } + it.resume(result) } } @@ -173,12 +179,11 @@ object Decoder { */ suspend fun intToHex(decimal: String): String { return suspendCoroutine { - try { + val result = runCatching { val xdigit = Integer.toHexString(decimal.toInt()) - it.resume("0x$xdigit") - } catch (ex: Throwable) { - it.resume("Error. Out of range") - } + "0x$xdigit" + }.getOrElse { "Error. Out of range" } + it.resume(result) } } diff --git a/app/src/test/java/android/util/Base64.kt b/app/src/test/java/android/util/Base64.kt index 5f3f242..3b3f830 100644 --- a/app/src/test/java/android/util/Base64.kt +++ b/app/src/test/java/android/util/Base64.kt @@ -8,18 +8,12 @@ import java.util.Base64 as JavaBase64 */ class Base64 private constructor() { companion object { - @JvmField - val DEFAULT: Int = 0 - @JvmField - val NO_PADDING: Int = 1 - @JvmField - val NO_WRAP: Int = 2 - @JvmField - val CRLF: Int = 4 - @JvmField - val URL_SAFE: Int = 8 - @JvmField - val NO_CLOSE: Int = 16 + const val DEFAULT: Int = 0 + const val NO_PADDING: Int = 1 + const val NO_WRAP: Int = 2 + const val CRLF: Int = 4 + const val URL_SAFE: Int = 8 + const val NO_CLOSE: Int = 16 @JvmStatic fun encode(input: ByteArray, flags: Int): ByteArray { diff --git a/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt b/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt index d242fce..29706ec 100644 --- a/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt +++ b/app/src/test/java/ru/svolf/convertx/data/SettingsRepositoryTest.kt @@ -69,7 +69,10 @@ class SettingsRepositoryTest { throw java.io.IOException("read failed") } - override suspend fun updateData(transform: suspend (androidx.datastore.preferences.core.Preferences) -> androidx.datastore.preferences.core.Preferences) = + override suspend fun updateData( + transform: suspend (androidx.datastore.preferences.core.Preferences) -> + androidx.datastore.preferences.core.Preferences + ) = error("not used") } ) diff --git a/build.gradle b/build.gradle index fdfa87c..74c3d94 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.detekt) apply false } allprojects { diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..a7c25e8 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,18 @@ +config: + validation: true + warningsAsErrors: false + +naming: + FunctionNaming: + ignoreAnnotated: + - Composable + TopLevelPropertyNaming: + constantPattern: '[A-Z][A-Za-z0-9]*' + +style: + MagicNumber: + ignorePropertyDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + UnusedPrivateFunction: + ignoreAnnotated: + - Preview diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 15175e1..01e684e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ espresso = "3.7.0" junitJupiter = "5.12.2" junitPlatform = "1.12.2" coroutines = "1.10.1" +detekt = "2.0.0-alpha.6" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } @@ -64,3 +65,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +detekt = { id = "dev.detekt", version.ref = "detekt" }