Pith - shim
shim/app/src/main/kotlin/com/vgmlr/shim/ShimSettings.kt [30.2 kb]
Modified: 20:16:35 158 026 (24 Aug 026)
6 Days Ago
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)
            )
        }
    }
}
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.0051
261,399 (+1,813)