package com.vgmlr.stave
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.KeyboardArrowDown
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.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.res.colorResource
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.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
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 kotlinx.coroutines.delay
import java.util.Locale
import kotlin.math.roundToInt
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 StaveSettings : ComponentActivity() {
private lateinit var themeManager: StaveThemeManager
private val fontSizeOptions = (22..36).map { "%.1f".format(Locale.US, it * 0.5) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT))
themeManager = StaveThemeManager(this)
setContent {
StaveTheme(themeManager) {
SettingsScreen()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val context = LocalContext.current
val defBg = remember { hexOf(context.getColor(R.color.background_color)) }
val defText = remember { hexOf(context.getColor(R.color.text_color)) }
val defAccent = remember { hexOf(context.getColor(R.color.accent_color)) }
var bgColor by remember { mutableStateOf(themeManager.hexBgColor) }
var textColor by remember { mutableStateOf(themeManager.hexTextColor) }
var accentColor by remember { mutableStateOf(themeManager.hexAccentColor) }
var fontSize by remember { mutableStateOf(themeManager.fontSize.floatValue.toString()) }
var isMonospace by remember { mutableStateOf(themeManager.isMonospace.value) }
var newestFirst by remember { mutableStateOf(themeManager.isNewestFirst.value) }
LaunchedEffect(bgColor, textColor, accentColor, fontSize, isMonospace, newestFirst) {
delay(500)
themeManager.saveTheme(
bgColor.takeIf { isHex(it) } ?: defBg,
textColor.takeIf { isHex(it) } ?: defText,
accentColor.takeIf { isHex(it) } ?: defAccent,
isMonospace,
fontSize,
newestFirst
)
}
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))
WgSection("Aesthetic") {
WgColorRow("Text", textColor, defText) { textColor = it }
WgDivider()
WgColorRow("Accent", accentColor, defAccent) { accentColor = it }
WgDivider()
WgColorRow("Ground", bgColor, defBg) { bgColor = it }
WgDivider()
WgRow("Font Size") { WgDropdown(fontSize, fontSizeOptions) { fontSize = it } }
WgDivider()
WgRow("Monospace") {
WgRadio(
value = if (isMonospace) "True" else "False",
options = listOf("True", "False")
) { isMonospace = it == "True" }
}
WgDivider()
WgRow("List Order") {
WgRadio(
value = if (newestFirst) "Newer" else "Older",
options = listOf("Newer", "Older")
) { newestFirst = it == "Newer" }
}
}
Spacer(Modifier.height(48.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 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 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 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)
)
}
}
}