package com.vgmlr.shim
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Locale
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.VisualTransformation
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.IntOffset
import kotlin.math.roundToInt
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import android.graphics.Color as AndroidColor
private val CBg = Color(0xFF0E0F11)
private val CCard = Color(0xFF272A30)
private val CText = Color(0xFFE8EAED)
private val CMuted = Color(0xFF868D97)
private val CChip = Color(0xFF3A4048)
private val CDanger = Color(0xFF8B0000)
private val ROW_H = 52.dp
private val FS = 15.sp
class ShimSettings : ComponentActivity() {
private lateinit var themeManager: ShimThemeManager
private lateinit var db: ShimDatabase
private val fontSizeOptions = (22..36).map { "%.1f".format(Locale.US, it * 0.5) }
private val lineHeightOptions = (10..20).map { "%.1f".format(Locale.US, it * 0.1) }
private val textSpacingOptions = (0..10).map { "%.1f".format(Locale.US, it * 0.1) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT))
themeManager = ShimThemeManager(this)
db = ShimDatabase(this)
setContent {
ShimTheme(themeManager) {
SettingsScreen()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val defBg = remember { hexOf(context.getColor(R.color.background_color)) }
val defText = remember { hexOf(context.getColor(R.color.text_color)) }
val defHash = remember { hexOf(context.getColor(R.color.hash_color)) }
var bgColor by remember { mutableStateOf(themeManager.hexBgColor) }
var textColor by remember { mutableStateOf(themeManager.hexTextColor) }
var hashColor by remember { mutableStateOf(themeManager.hexHashColor) }
var fontSize by remember { mutableStateOf(themeManager.fontSize.floatValue.toString()) }
var lineHeight by remember { mutableStateOf(themeManager.lineHeight.floatValue.toString()) }
var lineSpacing by remember { mutableStateOf(themeManager.lineSpacing.floatValue.toString()) }
var isMonospace by remember { mutableStateOf(themeManager.isMonospace.value) }
val backup = remember { ShimBackUp.loadSettings(context) }
var bkInterval by remember { mutableIntStateOf(backup.intervalMinutes) }
var bkLocation by remember { mutableStateOf(backup.location) }
var bkEncrypt by remember { mutableStateOf(backup.encrypt) }
var bkPhrase by remember { mutableStateOf(backup.phrase) }
val bkPhraseMissing = bkEncrypt && bkPhrase.isBlank()
var restoreStatus by remember { mutableStateOf("") }
var showDeleteDialog by remember { mutableStateOf(false) }
var showRestoreDialog by remember { mutableStateOf(false) }
var shims by remember { mutableStateOf(db.getAllShims()) }
val tagCount = remember(shims) {
shims.flatMap { it.shimhash.split(" ").filter { t -> t.isNotBlank() } }
.distinct().size.toString()
}
val modified = remember(shims) { shims.firstOrNull()?.shimtime ?: "none" }
val archived = remember(backup.stamp) {
if (backup.stamp == 0L) "none"
else ShimOTCClock.getOTCTime(
LocalDateTime.ofInstant(Instant.ofEpochMilli(backup.stamp), ZoneId.systemDefault())
)
}
val folderPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri: Uri? ->
uri?.let {
try {
contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
bkLocation = it.toString()
} catch (_: Exception) { }
}
}
LaunchedEffect(bgColor, textColor, hashColor, fontSize, lineHeight, lineSpacing, isMonospace) {
delay(500)
if (!isHex(bgColor) || !isHex(textColor) || !isHex(hashColor)) return@LaunchedEffect
themeManager.saveTheme(bgColor, textColor, hashColor, isMonospace, fontSize, lineHeight, lineSpacing)
}
LaunchedEffect(bkInterval, bkLocation, bkEncrypt, bkPhrase) {
if (bkPhraseMissing) return@LaunchedEffect
delay(500)
ShimBackUp.saveSettings(context, bkInterval, bkLocation, bkEncrypt, bkPhrase)
ShimBackUp.schedule(context, bkInterval)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Settings",
fontSize = 18.sp,
color = colorResource(id = R.color.title_color)
)
},
navigationIcon = {
IconButton(onClick = { finish() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = colorResource(id = R.color.title_color)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF486860))
)
},
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = CBg
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.background(CBg)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.height(24.dp))
// data
WgCard {
WgRow("Shims") { WgValue(shims.size.toString(), mono = true) }
WgDivider()
WgRow("Tags") { WgValue(tagCount, mono = true) }
WgDivider()
WgRow("Modified") { WgValue(modified, mono = true) }
WgDivider()
WgRow("Archived") { WgValue(archived, mono = true) }
}
Spacer(Modifier.height(24.dp))
// aesthetic
WgSection("Aesthetic") {
WgColorRow("Text", textColor, defText) { textColor = it }
WgDivider()
WgColorRow("Tag", hashColor, defHash) { hashColor = it }
WgDivider()
WgColorRow("Ground", bgColor, defBg) { bgColor = it }
WgDivider()
WgRow("Font Size") {
WgDropdown(fontSize, fontSizeOptions) { fontSize = it }
}
WgDivider()
WgRow("Text Spacing") {
WgDropdown(lineSpacing, textSpacingOptions) { lineSpacing = it }
}
WgDivider()
WgRow("Line Height") {
WgDropdown(lineHeight, lineHeightOptions) { lineHeight = it }
}
WgDivider()
WgRow("Monospace") {
WgRadio(
value = if (isMonospace) "True" else "False",
options = listOf("True", "False")
) { isMonospace = it == "True" }
}
}
// archive
WgSection("Archive") {
WgRow("Interval") {
WgDropdown(
value = ShimBackUp.intervalLabel(bkInterval),
options = ShimBackUp.INTERVALS.map { ShimBackUp.intervalLabel(it) }
) { picked ->
bkInterval = ShimBackUp.INTERVALS
.firstOrNull { ShimBackUp.intervalLabel(it) == picked } ?: bkInterval
}
}
WgDivider()
WgRow("Location") {
val locationName = remember(bkLocation) {
if (bkLocation.isEmpty()) "" else try {
DocumentFile.fromTreeUri(context, bkLocation.toUri())?.name
?: bkLocation
} catch (_: Exception) {
bkLocation
}
}
val truncated =
if (locationName.length > 25) locationName.take(22) + "..." else locationName
WgPick(truncated) { folderPicker.launch(null) }
}
WgDivider()
WgRow("Encrypt") {
WgRadio(
value = if (bkEncrypt) "True" else "False",
options = listOf("True", "False")
) { bkEncrypt = it == "True" }
}
WgDivider()
WgRow("Phrase") {
WgField(
value = bkPhrase,
onValueChange = { bkPhrase = it },
placeholder = if (bkEncrypt) "Required" else "",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
}
WgDivider()
WgRow("Archive") {
WgAction(
text = restoreStatus.ifEmpty { "Restore" },
enabled = !bkPhraseMissing
) { showRestoreDialog = true }
}
}
// delete
WgSection("Delete") {
WgRow("Shims") {
WgAction("Purge", danger = true) { showDeleteDialog = true }
}
}
Spacer(Modifier.height(48.dp))
}
if (showDeleteDialog) {
WgConfirmDialog(
message = "Delete Shims?",
confirmLabel = "Yep",
dismissLabel = "Nope",
onDismiss = { showDeleteDialog = false },
onConfirm = {
db.deleteAllShims()
shims = db.getAllShims()
showDeleteDialog = false
}
)
}
if (showRestoreDialog) {
WgConfirmDialog(
message = "Restore Shims?",
confirmLabel = "Yea",
dismissLabel = "Nay",
onDismiss = { showRestoreDialog = false },
onConfirm = {
showRestoreDialog = false
scope.launch {
restoreStatus =
if (ShimBackUp.restore(context)) "Restored" else "Failed"
shims = db.getAllShims()
delay(2000)
restoreStatus = ""
}
}
)
}
}
}
private fun isHex(value: String): Boolean =
runCatching { (if (value.startsWith("#")) value else "#$value").toColorInt() }.isSuccess
private fun hexOf(argb: Int): String = "#%06X".format(0xFFFFFF and argb)
private fun parseColor(value: String): Color = runCatching {
Color((if (value.startsWith("#")) value else "#$value").toColorInt())
}.getOrDefault(Color.Gray)
@Composable
private fun WgSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Text(
text = title.uppercase(),
color = CMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
)
WgCard(content)
Spacer(Modifier.height(24.dp))
}
@Composable
private fun WgCard(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(CCard),
content = content
)
}
@Composable
private fun WgRow(label: String, content: @Composable RowScope.() -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = ROW_H)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = CText.copy(alpha = 0.8f),
fontSize = FS,
maxLines = 1,
modifier = Modifier.widthIn(min = 92.dp)
)
Spacer(Modifier.width(12.dp))
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
@Composable
private fun WgDivider() {
HorizontalDivider(thickness = 1.dp, color = CBg)
}
@Composable
private fun WgValue(
text: String,
modifier: Modifier = Modifier,
mono: Boolean = false,
color: Color = CText
) {
Text(
text = text,
color = color,
fontSize = FS,
fontFamily = if (mono) FontFamily.Monospace else FontFamily.Default,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
@Composable
private fun WgPick(value: String, onClick: () -> Unit) {
Text(
text = value.ifBlank { "app/" },
color = if (value.isBlank()) CMuted else CText,
fontSize = FS,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
)
}
@Composable
private fun WgField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default
) {
val style = TextStyle(
color = CText,
fontSize = FS,
textAlign = TextAlign.End,
fontFamily = FontFamily.Monospace
)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
singleLine = true,
textStyle = style,
cursorBrush = SolidColor(CText),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
decorationBox = { inner ->
Box(contentAlignment = Alignment.CenterEnd) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(placeholder, style = style.copy(color = CMuted), maxLines = 1)
}
inner()
}
}
)
}
}
@Composable
private fun WgColorRow(
label: String,
value: String,
default: String,
onValueChange: (String) -> Unit
) {
var showPicker by remember { mutableStateOf(false) }
WgRow(label) {
WgField(
value = value,
onValueChange = onValueChange,
placeholder = default,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(12.dp))
Box(
Modifier
.size(20.dp)
.clip(CircleShape)
.background(parseColor(value.ifBlank { default }))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { showPicker = true }
)
}
if (showPicker) {
ColorPickerDialog(
value.ifBlank { default },
{ onValueChange(it); showPicker = false },
{ showPicker = false }
)
}
}
@Composable
private fun WgDropdown(
value: String,
options: List<String>,
onSelect: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { expanded = true },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = value,
color = CText,
fontSize = FS,
fontFamily = FontFamily.Monospace,
maxLines = 1
)
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = CMuted,
modifier = Modifier.size(18.dp)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
containerColor = Color(0xFF1F2226),
shape = RoundedCornerShape(10.dp),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
options.forEach { opt ->
DropdownMenuItem(
text = {
Text(
text = opt,
color = if (opt == value) CMuted else CText,
fontSize = FS,
fontFamily = FontFamily.Monospace
)
},
onClick = { onSelect(opt); expanded = false },
contentPadding = PaddingValues(horizontal = 14.dp)
)
}
}
}
}
@Composable
private fun WgChip(
label: String,
selected: Boolean,
enabled: Boolean = true,
danger: Boolean = false,
shape: Shape = RoundedCornerShape(7.dp),
border: Boolean = true,
leftLine: Color? = null,
onClick: () -> Unit
) {
val filled = selected && enabled
val fill = if (danger) CDanger else CChip
Box(
modifier = Modifier
.height(28.dp)
.clip(shape)
.background(if (filled) fill else Color.Transparent)
.then(
if (leftLine != null) Modifier.drawBehind {
val w = 1.dp.toPx()
drawLine(leftLine, Offset(w / 2f, 0f), Offset(w / 2f, size.height), w)
} else Modifier
)
.then(
if (border) Modifier.border(
width = 1.dp,
color = if (filled) fill else CMuted.copy(alpha = 0.30f),
shape = shape
) else Modifier
)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = when {
!enabled -> CMuted.copy(alpha = 0.4f)
selected -> CText
else -> CMuted
},
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1
)
}
}
@Composable
private fun WgAction(
text: String,
enabled: Boolean = true,
danger: Boolean = false,
onClick: () -> Unit
) {
WgChip(label = text, selected = true, enabled = enabled, danger = danger, onClick = onClick)
}
@Composable
private fun WgRadio(
value: String,
options: List<String>,
onSelect: (String) -> Unit
) {
val shape = RoundedCornerShape(7.dp)
val outline = CMuted.copy(alpha = 0.30f)
Row(
modifier = Modifier
.height(28.dp)
.clip(shape)
.border(1.dp, outline, shape),
verticalAlignment = Alignment.CenterVertically
) {
options.forEachIndexed { i, opt ->
WgChip(
label = opt,
selected = opt == value,
shape = RectangleShape,
border = false,
leftLine = if (i > 0) outline else null,
onClick = { onSelect(opt) }
)
}
}
}
@Composable
private fun WgConfirmDialog(
message: String,
confirmLabel: String,
dismissLabel: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val btnShape = RoundedCornerShape(8.dp)
val btnColors = ButtonDefaults.buttonColors(
containerColor = CChip,
contentColor = CText
)
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(14.dp),
containerColor = CCard,
confirmButton = {
Button(
onClick = onConfirm,
modifier = Modifier.height(44.dp),
colors = btnColors,
shape = btnShape
) { Text(confirmLabel, color = CText, fontSize = 15.sp) }
},
dismissButton = {
Button(
onClick = onDismiss,
modifier = Modifier.height(44.dp),
colors = btnColors,
shape = btnShape
) { Text(dismissLabel, color = CText, fontSize = 15.sp) }
},
text = { Text(message, color = CText, fontSize = 16.sp) }
)
}
@Composable
private fun ColorPickerDialog(initial: String, onPick: (String) -> Unit, onDismiss: () -> Unit) {
val start = parseColor(initial)
var r by remember { mutableFloatStateOf(start.red) }
var g by remember { mutableFloatStateOf(start.green) }
var b by remember { mutableFloatStateOf(start.blue) }
val hex = "#%02X%02X%02X".format((r * 255).toInt(), (g * 255).toInt(), (b * 255).toInt())
val btnShape = RoundedCornerShape(8.dp)
val btnColors = ButtonDefaults.buttonColors(
containerColor = CChip,
contentColor = CText
)
val sliderColor = Color(0xFF9AA1AA)
val sliderBg = Color(0xFF23262A)
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(14.dp),
containerColor = CCard,
confirmButton = {
Button(
onClick = { onPick(hex) },
modifier = Modifier.height(44.dp),
colors = btnColors,
shape = btnShape
) { Text("This", color = CText, fontSize = 15.sp) }
},
dismissButton = {
Button(
onClick = onDismiss,
modifier = Modifier.height(44.dp),
colors = btnColors,
shape = btnShape
) { Text("Abort", color = CText, fontSize = 15.sp) }
},
text = {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Box(
Modifier
.size(52.dp)
.background(Color(r, g, b), RoundedCornerShape(8.dp))
)
Spacer(Modifier.width(15.dp))
Text(hex, color = CText, fontFamily = FontFamily.Monospace, fontSize = 17.sp)
}
Spacer(Modifier.height(22.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(62.dp, Alignment.CenterHorizontally)
) {
VerticalRgbSlider(r, { r = it }, sliderColor, trackColor = sliderBg)
VerticalRgbSlider(g, { g = it }, sliderColor, trackColor = sliderBg)
VerticalRgbSlider(b, { b = it }, sliderColor, trackColor = sliderBg)
}
}
}
)
}
@Composable
private fun VerticalRgbSlider(
value: Float,
onValueChange: (Float) -> Unit,
color: Color,
modifier: Modifier = Modifier,
trackColor: Color = color,
height: Dp = 130.dp,
trackWidth: Dp = 10.dp,
thumbSize: Dp = 22.dp
) {
val thumbPx = with(LocalDensity.current) { thumbSize.toPx() }
var heightPx by remember { mutableFloatStateOf(0f) }
val travel = (heightPx - thumbPx).coerceAtLeast(1f)
val setFromY: (Float) -> Unit = { y ->
val pos = (y - thumbPx / 2f).coerceIn(0f, travel)
onValueChange(1f - pos / travel)
}
Box(
modifier = modifier
.height(height)
.width(thumbSize)
.onGloballyPositioned { heightPx = it.size.height.toFloat() }
.pointerInput(Unit) { detectTapGestures { setFromY(it.y) } }
.pointerInput(Unit) {
detectVerticalDragGestures { change, _ -> setFromY(change.position.y) }
}
) {
Box(
Modifier
.width(trackWidth)
.fillMaxHeight()
.align(Alignment.Center)
.background(trackColor, CircleShape)
)
Box(
Modifier
.size(thumbSize)
.offset { IntOffset(0, ((1f - value) * travel).roundToInt()) }
.background(color, CircleShape)
)
}
}
}