Skip to content

Commit d8f2382

Browse files
authored
Merge pull request #3 from SnowVolf/codex/add-detekt
feat: redesign TextToolScreen and resolve detekt issues
2 parents f6ccac2 + 48cae84 commit d8f2382

25 files changed

Lines changed: 1367 additions & 742 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
.externalNativeBuild
1010
/apk/
1111
/app/release/
12+
.kotlin/

‎app/build.gradle‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ plugins {
44
alias(libs.plugins.kotlin.serialization)
55
alias(libs.plugins.ksp)
66
alias(libs.plugins.kotlin.compose)
7+
alias(libs.plugins.detekt)
8+
}
9+
10+
detekt {
11+
toolVersion = libs.versions.detekt.get()
12+
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
13+
buildUponDefaultConfig = true
14+
allRules = false
15+
ignoreFailures = false
16+
basePath = rootProject.projectDir
17+
}
18+
19+
tasks.withType(dev.detekt.gradle.Detekt).configureEach {
20+
exclude("**/build/**")
21+
exclude("**/generated/**")
22+
exclude("**/ksp/**")
23+
24+
reports {
25+
checkstyle.required.set(true)
26+
html.required.set(true)
27+
markdown.required.set(true)
28+
sarif.required.set(true)
29+
}
730
}
831

932
def Properties versionProps = new Properties()

‎app/src/main/java/ru/svolf/convertx/data/ConverterEngine.kt‎

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,36 +23,48 @@ class ConverterEngine @Inject constructor() {
2323

2424
suspend fun convert(route: AppRoute, value: String, fromInput: Boolean, mode: Int): String =
2525
when (route) {
26-
UnicodeRoute -> if (fromInput) {
27-
value.map { "\\u${it.code.toString(16).padStart(4, '0')}" }.joinToString("")
28-
} else Decoder.decodeUnicode(value)
29-
30-
is Base64Route -> {
31-
val flags = base64Flags[mode.coerceIn(base64Flags.indices)]
32-
if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64(
33-
value,
34-
flags
35-
)
36-
}
37-
38-
is HexRoute -> if (mode == 1) {
39-
if (fromInput) Decoder.intToHex(value)
40-
else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X"))
41-
} else if (fromInput) {
42-
Decoder.toHexString(value)
43-
} else Decoder.decodeHexString(value.removePrefix("0x"))
44-
45-
is TextToolRoute -> when (route.tool) {
46-
TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString()
47-
TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString()
48-
TextTool.XML -> Decoder.unescapeXml(value)
49-
TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong())
50-
}
51-
26+
UnicodeRoute -> convertUnicode(value, fromInput)
27+
is Base64Route -> convertBase64(value, fromInput, mode)
28+
is HexRoute -> convertHex(value, fromInput, mode)
29+
is TextToolRoute -> convertTextTool(value, route.tool)
5230
else -> value
5331
}
5432

33+
private suspend fun convertUnicode(value: String, fromInput: Boolean): String =
34+
if (fromInput) {
35+
value.map { "\\u${it.code.toString(HEX_RADIX).padStart(UNICODE_HEX_DIGITS, '0')}" }
36+
.joinToString("")
37+
} else {
38+
Decoder.decodeUnicode(value)
39+
}
40+
41+
private suspend fun convertBase64(value: String, fromInput: Boolean, mode: Int): String {
42+
val flags = base64Flags[mode.coerceIn(base64Flags.indices)]
43+
return if (fromInput) Decoder.encodeBase64(value, flags) else Decoder.decodeBase64(value, flags)
44+
}
45+
46+
private suspend fun convertHex(value: String, fromInput: Boolean, mode: Int): String =
47+
if (mode == HEX_NUMBER_MODE) {
48+
if (fromInput) Decoder.intToHex(value)
49+
else Decoder.hexToInt(value.removePrefix("0x").removePrefix("0X"))
50+
} else if (fromInput) {
51+
Decoder.toHexString(value)
52+
} else {
53+
Decoder.decodeHexString(value.removePrefix("0x"))
54+
}
55+
56+
private suspend fun convertTextTool(value: String, tool: TextTool): String = when (tool) {
57+
TextTool.ADLER32 -> Adler32().apply { update(value.toByteArray()) }.value.toString()
58+
TextTool.CRC32 -> CRC32().apply { update(value.toByteArray()) }.value.toString()
59+
TextTool.XML -> Decoder.unescapeXml(value)
60+
TextTool.TIMESTAMP -> Decoder.getNormalDate(value.toLong())
61+
}
62+
5563
private companion object {
64+
const val HEX_RADIX = 16
65+
const val HEX_NUMBER_MODE = 1
66+
const val UNICODE_HEX_DIGITS = 4
67+
5668
val base64Flags = intArrayOf(
5769
Base64.NO_WRAP, Base64.NO_CLOSE, Base64.NO_PADDING,
5870
Base64.URL_SAFE, Base64.CRLF, Base64.DEFAULT

‎app/src/main/java/ru/svolf/convertx/data/SettingsRepository.kt‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,22 @@ import kotlinx.coroutines.flow.catch
1212
import kotlinx.coroutines.flow.map
1313
import java.io.IOException
1414

15+
private const val DefaultFontSize = 16
16+
private const val MinFontSize = 12
17+
private const val MaxFontSize = 32
18+
private const val DefaultBase64Mode = 0
19+
private const val MaxBase64Mode = 5
20+
private const val DefaultHexMode = 0
21+
private const val MaxHexMode = 1
22+
1523
enum class ThemeMode { LIGHT, DARK }
1624

1725
data class SettingsState(
1826
val theme: ThemeMode = ThemeMode.LIGHT,
19-
val fontSize: Int = 16,
27+
val fontSize: Int = DefaultFontSize,
2028
val twiceBackToExit: Boolean = true,
21-
val base64Mode: Int = 0,
22-
val hexMode: Int = 0
29+
val base64Mode: Int = DefaultBase64Mode,
30+
val hexMode: Int = DefaultHexMode
2331
)
2432

2533
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
@@ -38,26 +46,33 @@ class SettingsRepository(private val dataStore: DataStore<Preferences>) {
3846
}
3947
.map { preferences ->
4048
SettingsState(
41-
theme = if (preferences[Keys.theme]?.toIntOrNull() == ThemeMode.DARK.ordinal) ThemeMode.DARK else ThemeMode.LIGHT,
49+
theme = if (preferences[Keys.theme]?.toIntOrNull() == ThemeMode.DARK.ordinal) {
50+
ThemeMode.DARK
51+
} else {
52+
ThemeMode.LIGHT
53+
},
4254
fontSize = (preferences[Keys.appFontSize] ?: preferences[Keys.oldFontSize]
43-
?: 16).coerceIn(12, 32),
55+
?: DefaultFontSize).coerceIn(MinFontSize, MaxFontSize),
4456
twiceBackToExit = preferences[Keys.twiceBack] ?: true,
45-
base64Mode = (preferences[Keys.base64Mode] ?: 0).coerceIn(0, 5),
46-
hexMode = (preferences[Keys.hexMode] ?: 0).coerceIn(0, 1)
57+
base64Mode = (preferences[Keys.base64Mode] ?: DefaultBase64Mode)
58+
.coerceIn(DefaultBase64Mode, MaxBase64Mode),
59+
hexMode = (preferences[Keys.hexMode] ?: DefaultHexMode)
60+
.coerceIn(DefaultHexMode, MaxHexMode)
4761
)
4862
}
4963

5064
suspend fun setTheme(theme: ThemeMode) =
5165
dataStore.edit { it[Keys.theme] = theme.ordinal.toString() }
5266

5367
suspend fun setFontSize(size: Int) =
54-
dataStore.edit { it[Keys.appFontSize] = size.coerceIn(12, 32) }
68+
dataStore.edit { it[Keys.appFontSize] = size.coerceIn(MinFontSize, MaxFontSize) }
5569

5670
suspend fun setTwiceBackToExit(enabled: Boolean) =
5771
dataStore.edit { it[Keys.twiceBack] = enabled }
5872

5973
suspend fun setBase64Mode(mode: Int) =
60-
dataStore.edit { it[Keys.base64Mode] = mode.coerceIn(0, 5) }
74+
dataStore.edit { it[Keys.base64Mode] = mode.coerceIn(DefaultBase64Mode, MaxBase64Mode) }
6175

62-
suspend fun setHexMode(mode: Int) = dataStore.edit { it[Keys.hexMode] = mode.coerceIn(0, 1) }
76+
suspend fun setHexMode(mode: Int) =
77+
dataStore.edit { it[Keys.hexMode] = mode.coerceIn(DefaultHexMode, MaxHexMode) }
6378
}

‎app/src/main/java/ru/svolf/convertx/di/AppComponent.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface AppComponent {
2626
fun paletteViewModelFactory(): PaletteViewModelFactory
2727
fun settingsViewModelFactory(): SettingsViewModelFactory
2828
fun regexViewModelFactory(): RegexViewModelFactory
29+
fun textToolViewModelFactory(): ru.svolf.convertx.presentation.viewmodel.TextToolViewModel.Factory
2930

3031
@Component.Factory
3132
interface Factory {
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package ru.svolf.convertx.presentation.compose
2+
3+
import android.content.ClipData
4+
import androidx.compose.animation.animateColorAsState
5+
import androidx.compose.animation.core.tween
6+
import androidx.compose.foundation.background
7+
import androidx.compose.foundation.clickable
8+
import androidx.compose.foundation.layout.Arrangement
9+
import androidx.compose.foundation.layout.Box
10+
import androidx.compose.foundation.layout.Column
11+
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.fillMaxWidth
13+
import androidx.compose.foundation.layout.height
14+
import androidx.compose.foundation.layout.Spacer
15+
import androidx.compose.foundation.layout.size
16+
import androidx.compose.foundation.layout.width
17+
import androidx.compose.material.icons.Icons
18+
import androidx.compose.material.icons.filled.Check
19+
import androidx.compose.material.icons.filled.Clear
20+
import androidx.compose.material.icons.filled.ContentCopy
21+
import androidx.compose.material.icons.filled.KeyboardArrowDown
22+
import androidx.compose.material.icons.filled.KeyboardArrowUp
23+
import androidx.compose.material3.AssistChip
24+
import androidx.compose.material3.DropdownMenu
25+
import androidx.compose.material3.DropdownMenuItem
26+
import androidx.compose.material3.ExperimentalMaterial3Api
27+
import androidx.compose.material3.ExposedDropdownMenuBox
28+
import androidx.compose.material3.ExposedDropdownMenuDefaults
29+
import androidx.compose.material3.Icon
30+
import androidx.compose.material3.MaterialTheme
31+
import androidx.compose.material3.SnackbarHostState
32+
import androidx.compose.material3.Text
33+
import androidx.compose.material3.TextButton
34+
import androidx.compose.runtime.Composable
35+
import androidx.compose.runtime.getValue
36+
import androidx.compose.runtime.mutableStateOf
37+
import androidx.compose.runtime.remember
38+
import androidx.compose.runtime.rememberCoroutineScope
39+
import androidx.compose.runtime.setValue
40+
import androidx.compose.ui.Alignment
41+
import androidx.compose.ui.Modifier
42+
import androidx.compose.ui.platform.ClipEntry
43+
import androidx.compose.ui.platform.LocalClipboard
44+
import androidx.compose.ui.res.stringResource
45+
import androidx.compose.ui.unit.dp
46+
import kotlinx.coroutines.launch
47+
import ru.svolf.convertx.R
48+
import ru.svolf.convertx.presentation.navigation.AppRoute
49+
import ru.svolf.convertx.presentation.navigation.Base64Route
50+
import ru.svolf.convertx.presentation.navigation.HexRoute
51+
import ru.svolf.convertx.presentation.navigation.TextTool
52+
import ru.svolf.convertx.presentation.navigation.TextToolRoute
53+
import ru.svolf.convertx.presentation.navigation.UnicodeRoute
54+
55+
internal enum class ConverterField { INPUT, OUTPUT }
56+
57+
@Composable
58+
internal fun inputHint(route: AppRoute): String = when (route) {
59+
UnicodeRoute, is Base64Route -> stringResource(R.string.hint_utf)
60+
is HexRoute -> stringResource(R.string.hint_string)
61+
is TextToolRoute -> when (route.tool) {
62+
TextTool.ADLER32 -> stringResource(R.string.hint_adler32)
63+
TextTool.CRC32 -> stringResource(R.string.hint_crc)
64+
TextTool.XML -> stringResource(R.string.hint_encoded_xml)
65+
TextTool.TIMESTAMP -> stringResource(R.string.hint_timestamp)
66+
}
67+
68+
else -> stringResource(R.string.hint_string)
69+
}
70+
71+
@Composable
72+
internal fun outputHint(route: AppRoute): String = when (route) {
73+
UnicodeRoute -> stringResource(R.string.hint_unicode)
74+
is Base64Route -> stringResource(R.string.hint_base64)
75+
is HexRoute -> stringResource(R.string.hint_hex)
76+
else -> stringResource(R.string.copy2clipboard)
77+
}
78+
79+
@Composable
80+
internal fun ConverterControls(
81+
route: AppRoute,
82+
selectedMode: Int,
83+
activeField: ConverterField,
84+
onFieldToggle: () -> Unit,
85+
onModeSelected: (Int) -> Unit
86+
) {
87+
Box(Modifier.fillMaxWidth().height(48.dp)) {
88+
DirectionSwitcher(activeField, onFieldToggle, Modifier.align(Alignment.Center))
89+
if (route is Base64Route || route is ru.svolf.convertx.presentation.navigation.HexRoute) {
90+
ModeMenu(route, selectedMode, onModeSelected, Modifier.align(Alignment.CenterStart))
91+
}
92+
}
93+
}
94+
95+
@Composable
96+
private fun DirectionSwitcher(
97+
activeField: ConverterField,
98+
onToggle: () -> Unit,
99+
modifier: Modifier = Modifier
100+
) {
101+
val primary = MaterialTheme.colorScheme.primary
102+
val muted = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
103+
val downTint by animateColorAsState(
104+
targetValue = if (activeField == ConverterField.INPUT) primary else muted,
105+
animationSpec = tween(durationMillis = 220),
106+
label = "downArrowTint"
107+
)
108+
val upTint by animateColorAsState(
109+
targetValue = if (activeField == ConverterField.OUTPUT) primary else muted,
110+
animationSpec = tween(durationMillis = 220),
111+
label = "upArrowTint"
112+
)
113+
Column(
114+
modifier = modifier.height(46.dp).clickable(onClick = onToggle),
115+
horizontalAlignment = Alignment.CenterHorizontally
116+
) {
117+
Box(Modifier.width(2.dp).weight(1f).background(primary.copy(alpha = 0.65f)))
118+
Row(horizontalArrangement = Arrangement.Center) {
119+
Icon(
120+
imageVector = Icons.Default.KeyboardArrowDown,
121+
contentDescription = null,
122+
tint = downTint,
123+
modifier = Modifier.size(20.dp)
124+
)
125+
Icon(
126+
imageVector = Icons.Default.KeyboardArrowUp,
127+
contentDescription = null,
128+
tint = upTint,
129+
modifier = Modifier.size(20.dp)
130+
)
131+
}
132+
Box(Modifier.width(2.dp).weight(1f).background(primary.copy(alpha = 0.65f)))
133+
}
134+
}
135+
136+
@OptIn(ExperimentalMaterial3Api::class)
137+
@Composable
138+
private fun ModeMenu(
139+
route: AppRoute,
140+
selected: Int,
141+
onSelected: (Int) -> Unit,
142+
modifier: Modifier = Modifier
143+
) {
144+
val options = if (route is Base64Route) {
145+
listOf("No wrap", "No close", "No padding", "URL safe", "CRLF", "Default")
146+
} else {
147+
listOf("String", "Integer")
148+
}
149+
var expanded by remember { mutableStateOf(false) }
150+
ExposedDropdownMenuBox(expanded, { expanded = !expanded }, modifier) {
151+
AssistChip(
152+
onClick = { expanded = true },
153+
label = { Text(options.getOrElse(selected) { options.first() }) },
154+
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
155+
modifier = Modifier.menuAnchor()
156+
)
157+
DropdownMenu(expanded, { expanded = false }) {
158+
options.forEachIndexed { index, option ->
159+
DropdownMenuItem(
160+
text = { Text(option) },
161+
onClick = { expanded = false; onSelected(index) },
162+
leadingIcon = if (index == selected) {
163+
{ Icon(Icons.Default.Check, null) }
164+
} else {
165+
null
166+
}
167+
)
168+
}
169+
}
170+
}
171+
}
172+
173+
@Composable
174+
internal fun ClearButton(onClick: () -> Unit) {
175+
TextButton(modifier = Modifier.height(48.dp), onClick = onClick) {
176+
Icon(Icons.Default.Clear, stringResource(R.string.clear))
177+
Spacer(Modifier.width(8.dp))
178+
Text(stringResource(R.string.clear))
179+
}
180+
}
181+
182+
@Composable
183+
internal fun CopyButton(value: String, message: String, snackbar: SnackbarHostState) {
184+
val clipboard = LocalClipboard.current
185+
val scope = rememberCoroutineScope()
186+
TextButton(
187+
modifier = Modifier.height(48.dp),
188+
onClick = {
189+
scope.launch {
190+
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, value)))
191+
snackbar.showSnackbar(message)
192+
}
193+
}
194+
) {
195+
Icon(Icons.Default.ContentCopy, stringResource(R.string.copy2clipboard))
196+
Spacer(Modifier.width(8.dp))
197+
Text(stringResource(R.string.copy2clipboard))
198+
}
199+
}

0 commit comments

Comments
 (0)