package com.vgmlr.shim
import android.content.Intent
import android.graphics.Color as AndroidColor
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.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.material.icons.filled.Clear
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.outlined.Archive
import androidx.compose.material.icons.outlined.InvertColors
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.LineHeightStyle
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 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.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.colorResource
private val CBg = Color(0xFF0E0F11)
private val CCard = Color(0xFF272A30).copy(alpha = 0.8f)
private val CCardNo = Color(0xFF272A30)
private val CText = Color(0xFFE8EAED)
private val CMuted = Color(0xFF868D97)
private val CChip = Color(0xFF3A4048)
private val CDanger = Color(0xFF8B0000)
private val CHead = Color(0xFF33373E).copy(alpha = 0.8f)
private val COutline = CMuted.copy(alpha = 0.30f)
private val CTitle = Color(0xFF272A30)
private val ROW_H = 46.dp
private val HEAD_H = 46.dp
private val FS = 15.sp
private val HEAD_INK = CText.copy(alpha = 0.75f)
private val HEAD_ICON = 16.dp
private val HEAD_ICON_GAP = 10.dp
private val HEAD_TEXT_DY = 1.dp
private val HEAD_FS = 12.sp
private val SETTINGS_TOP_GAP = 22.dp
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),
navigationBarStyle = 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 defBg = hexOf(colorResource(id = R.color.background_color).toArgb())
val defText = hexOf(colorResource(id = R.color.text_color).toArgb())
var bgColor by remember { mutableStateOf(themeManager.hexBgColor) }
var textColor by remember { mutableStateOf(themeManager.hexTextColor) }
var tagBg by remember { mutableStateOf(themeManager.hexTagBgColor) }
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) }
var showDates by remember { mutableStateOf(themeManager.showDates.value) }
var shimSpacing by remember { mutableStateOf(themeManager.shimSpacing.intValue.toString()) }
var bkLocation by remember { mutableStateOf(ShimSave.location(context)) }
var bkFormat by remember { mutableStateOf(ShimSave.format(context)) }
var armPurge by remember { mutableStateOf(false) }
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,
tagBg,
fontSize,
lineHeight,
lineSpacing,
isMonospace,
shimSpacing,
showDates
) {
delay(500)
if (!isHex(bgColor) || !isHex(textColor) || !isHex(tagBg)) return@LaunchedEffect
themeManager.saveTheme(
bgColor,
textColor,
tagBg,
isMonospace,
fontSize,
lineHeight,
lineSpacing,
shimSpacing,
showDates
)
}
LaunchedEffect(bkLocation, bkFormat) {
ShimSave.saveSettings(context, bkLocation, bkFormat)
}
LaunchedEffect(armPurge) {
if (!armPurge) return@LaunchedEffect
delay(ARM_HOLD)
armPurge = false
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = CBg,
topBar = { SettingsTopBar("Settings") { finish() } }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = padding.calculateBottomPadding())
.background(CBg)
.imePadding()
.navigationBarsPadding()
.padding(start = LIST_PAD_START, end = LIST_PAD_END)
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.height(padding.calculateTopPadding() + SETTINGS_TOP_GAP))
// archive
WgSection("Archive", icon = Icons.Outlined.Archive) {
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("Format") {
WgDropdown(
value = ShimSave.formatLabel(bkFormat),
options = ShimSave.FORMATS.map { ShimSave.formatLabel(it) }
) { picked ->
bkFormat = ShimSave.FORMATS
.firstOrNull { ShimSave.formatLabel(it) == picked } ?: bkFormat
}
}
}
// aesthetic
WgSection("Aesthetic", icon = Icons.Outlined.InvertColors) {
WgColorRow("Text", textColor, defText) { textColor = it }
WgDivider()
WgColorRow("Tags", tagBg, TAG_BG_DEFAULT) { tagBg = 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" }
}
WgDivider()
WgRow("Show Time-Stamps") {
WgRadio(
value = if (showDates) "True" else "False",
options = listOf("True", "False")
) { showDates = it == "True" }
}
WgDivider()
WgRow("List Spacing") {
WgField(
value = shimSpacing,
onValueChange = {
if (it.length <= 2 && it.all(Char::isDigit)) shimSpacing = it
},
placeholder = SPACING_DEFAULT.toString(),
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
// delete
WgSection("Delete", icon = Icons.Filled.Clear) {
WgRow("Obliterate Shims") {
WgAction(
if (armPurge) "Confirm" else "Purge",
danger = true,
icon = if (armPurge) Icons.Outlined.WarningAmber else null
) {
if (armPurge) {
db.deleteAllShims()
armPurge = false
} else armPurge = true
}
}
}
Spacer(Modifier.height(8.dp))
}
}
}
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 SettingsTopBar(title: String, onBack: () -> Unit) {
val topInset = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = topInset + BAR_GAP, start = BAR_EDGE, end = BAR_EDGE_ACTIONS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(BAR_GAP)
) {
ShimButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onClick = onBack)
ShimTitle(title, Modifier.weight(1f), color = CTitle)
}
}
@Composable
private fun WgSection(
title: String,
icon: ImageVector? = null,
content: @Composable ColumnScope.() -> Unit
) {
val shape = RoundedCornerShape(ROUND_END)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.border(EDGE, shape)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HEAD_H)
.background(CHead)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
icon,
contentDescription = null,
tint = HEAD_INK,
modifier = Modifier.size(HEAD_ICON)
)
Spacer(Modifier.width(HEAD_ICON_GAP))
}
Text(
text = title,
color = HEAD_INK,
fontWeight = FontWeight.Medium,
letterSpacing = 1.4.sp,
style = TextStyle(
fontSize = HEAD_FS,
lineHeight = HEAD_FS,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both
)
),
modifier = Modifier.offset(y = HEAD_TEXT_DY)
)
}
WgDivider()
WgCard(content)
}
Spacer(Modifier.height(24.dp))
}
@Composable
private fun WgCard(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier
.fillMaxWidth()
.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.95f),
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 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(ROUND_END),
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(6.dp),
border: Boolean = true,
leftLine: Color? = null,
rightLine: Color? = null,
icon: ImageVector? = null,
onClick: () -> Unit
) {
val filled = selected && enabled
val fill = if (danger) CDanger else CChip
val ink = when {
!enabled -> CMuted.copy(alpha = 0.4f)
selected -> CText
else -> CMuted
}
Box(
modifier = Modifier
.height(28.dp)
.clip(shape)
.background(if (filled) fill else Color.Transparent)
.then(
if (leftLine != null || rightLine != null) Modifier.drawBehind {
val w = 1.dp.toPx()
leftLine?.let { drawLine(it, Offset(w / 2f, 0f), Offset(w / 2f, size.height), w) }
rightLine?.let {
val x = size.width - w / 2f
drawLine(it, Offset(x, 0f), Offset(x, size.height), w)
}
} else Modifier
)
.then(
if (border) Modifier.border(
width = 1.dp,
color = COutline,
shape = shape
) else Modifier
)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(icon, contentDescription = null, tint = ink, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
}
Text(
text = label,
color = ink,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1
)
}
}
}
@Composable
private fun WgAction(
text: String,
enabled: Boolean = true,
danger: Boolean = false,
icon: ImageVector? = null,
onClick: () -> Unit
) {
WgChip(
label = text,
selected = true,
enabled = enabled,
danger = danger,
icon = icon,
onClick = onClick
)
}
@Composable
private fun WgRadio(
value: String,
options: List<String>,
onSelect: (String) -> Unit
) {
val shape = RoundedCornerShape(6.dp)
val outline = COutline
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 && options[i - 1] != value) outline else null,
rightLine = if (i < options.lastIndex && opt == value) outline else null,
onClick = { onSelect(opt) }
)
}
}
}
@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(ROUND_END)
val btnColors = ButtonDefaults.buttonColors(
containerColor = CChip,
contentColor = CText
)
val sliderColor = Color(0xFF9AA1AA)
val sliderBg = Color(0xFF23262A)
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.border(EDGE, RoundedCornerShape(ROUND_END)),
shape = RoundedCornerShape(ROUND_END),
containerColor = CCardNo,
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(ROUND_END))
)
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)
.border(EDGE, CircleShape)
)
Box(
Modifier
.size(thumbSize)
.offset { IntOffset(0, ((1f - value) * travel).roundToInt()) }
.background(color, CircleShape)
)
}
}
}