package com.vgmlr.stave
import android.graphics.Color as AndroidColor
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.material.icons.outlined.Palette
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.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
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.drawscope.Stroke
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.VisualTransformation
import androidx.compose.ui.text.style.LineHeightStyle
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 java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
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 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
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),
navigationBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT)
)
window.isNavigationBarContrastEnforced = false
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 = { },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
actions = {
Spacer(Modifier.width(BAR_EDGE))
StaveButton(
Icons.AutoMirrored.Filled.ArrowBack,
"Back"
) { finish() }
Spacer(Modifier.width(BAR_GAP))
StaveBlankButton(
Modifier.weight(1f),
label = "Settings",
labelAlignment = Alignment.CenterStart
)
Spacer(Modifier.width(BAR_EDGE_ACTIONS))
}
)
},
containerColor = CBg
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.background(CBg)
.padding(horizontal = BAR_EDGE)
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.height(4.dp))
WgSection("Display", Icons.Outlined.Palette) {
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,
icon: ImageVector? = null,
content: @Composable ColumnScope.() -> Unit
) {
val shape = RoundedCornerShape(ROUND_SET)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.border(EDGE, shape)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = HEAD_H)
.clip(RoundedCornerShape(topStart = ROUND_SET, topEnd = ROUND_SET))
.background(CHead)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(icon, 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(
shape = RoundedCornerShape(bottomStart = ROUND_SET, bottomEnd = ROUND_SET),
borderColor = Color.Transparent,
content = content
)
}
Spacer(Modifier.height(24.dp))
}
@Composable
private fun WgCard(
shape: Shape = RoundedCornerShape(ROUND_SET),
borderColor: Color = CHead,
content: @Composable ColumnScope.() -> Unit
) {
val radiusPx = with(LocalDensity.current) { ROUND_SET.toPx() }
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(CCard)
.drawBehind {
val w = 1.dp.toPx()
drawRoundRect(
color = borderColor,
topLeft = Offset(w / 2f, -radiusPx),
size = Size(size.width - w, size.height + radiusPx - w / 2f),
cornerRadius = CornerRadius(radiusPx),
style = Stroke(w)
)
},
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 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_SET),
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,
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 || 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 = 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(6.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 && 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_SET)
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_SET)),
shape = RoundedCornerShape(ROUND_SET),
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_SET))
)
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)
)
}
}
}