Pith - stave
stave/app/src/main/kotlin/com/vgmlr/stave/StaveSettings.kt [20.4 kb]
Modified: 20:14:55 158 026 (24 Aug 026)
0 Days Ago
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)
            )
        }
    }
}
Updates
Stave - Android 158.026
Kerf - Android 157.026
Kiln - Android 157.026
Wedge - Android 156.026
Whittle - Linux 155.026

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)
0.00258
257,433 (+43)