Pith - wedge_android
wedge_android/app/src/main/java/com/vgmlr/wedge/WedgeSettings.kt [35.4 kb]
Modified: 00:00:28 153 026 (19 Aug 026)
5 Days Ago
package com.vgmlr.wedge

import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
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.Create
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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 androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import java.io.File
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

internal val CBg = Color(0xFF0E0F11)
internal val CCard = Color(0xFF272A30)
internal val CText = Color(0xFFE8EAED)
internal val CMuted = Color(0xFF868D97)
internal val CChip = Color(0xFF3A4048)

internal val ROW_H = 54.dp
internal val FS = 15.sp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsView(vm: MainViewModel, prefs: PreferenceManager, onBack: () -> Unit) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    val aTxt by prefs.textColor.collectAsState(WedgeConfig.TEXT_COLOR_DEFAULT)
    val aBg by prefs.bgColor.collectAsState(WedgeConfig.BG_COLOR_DEFAULT)
    val aWTxt by prefs.widgetTextColor.collectAsState(WedgeConfig.WIDGET_TEXT_COLOR_DEFAULT)
    val aWBg by prefs.widgetBgColor.collectAsState(WedgeConfig.WIDGET_BG_COLOR_DEFAULT)
    val aESize by prefs.editorFontSize.collectAsState(WedgeConfig.FONT_SIZE_DEFAULT)
    val aWSize by prefs.widgetFontSize.collectAsState(WedgeConfig.WIDGET_FONT_SIZE_DEFAULT)
    val aLHeight by prefs.lineHeight.collectAsState(WedgeConfig.LINE_HEIGHT_DEFAULT)
    val aBColor by prefs.boldColor.collectAsState(WedgeConfig.BOLD_COLOR_DEFAULT)
    val aLink by prefs.linkColor.collectAsState(WedgeConfig.LINK_COLOR_DEFAULT)
    val aWBCol by prefs.widgetBoldColor.collectAsState(WedgeConfig.WIDGET_BOLD_COLOR_DEFAULT)
    val aNextBg by prefs.nextTimeBg.collectAsState(WedgeConfig.CALENDAR_COLOR_DEFAULT)
    val aWNextBg by prefs.widgetNextTimeBg.collectAsState(WedgeConfig.WIDGET_CALENDAR_COLOR_DEFAULT)
    val aIncognito by prefs.incognitoMode.collectAsState(false)

    val aHeaderFmt by prefs.headerFormat.collectAsState(WedgeConfig.HEADER_FORMAT_DEFAULT)

    val aAlarmUri by prefs.alarmSoundUri.collectAsState("")
    val aAlarmDuration by prefs.alarmDurationMinutes.collectAsState(5)
    val aVibration by prefs.vibrationEnabled.collectAsState(false)

    val aBkInterval by prefs.backupInterval.collectAsState(WedgeConfig.BACKUP_INTERVAL_DEFAULT)
    val aBkLocation by prefs.backupLocation.collectAsState("")
    val aBkEncrypt by prefs.backupEncrypt.collectAsState(false)
    val aBkPhrase by prefs.backupPhrase.collectAsState("")
    val aBkStamp by prefs.backupStamp.collectAsState(0L)

    var tTxt by remember(aTxt) { mutableStateOf(aTxt) }
    var tBg by remember(aBg) { mutableStateOf(aBg) }
    var tWTxt by remember(aWTxt) { mutableStateOf(aWTxt) }
    var tWBg by remember(aWBg) { mutableStateOf(aWBg) }
    var tBCol by remember(aBColor) { mutableStateOf(aBColor) }
    var tLink by remember(aLink) { mutableStateOf(aLink) }
    var tWBCol by remember(aWBCol) { mutableStateOf(aWBCol) }
    var tNextBg by remember(aNextBg) { mutableStateOf(aNextBg) }
    var tWNextBg by remember(aWNextBg) { mutableStateOf(aWNextBg) }
    var tIncognito by remember(aIncognito) { mutableStateOf(aIncognito) }

    var tHeaderFmt by remember(aHeaderFmt) { mutableStateOf(aHeaderFmt) }

    var tAlarmUri by remember(aAlarmUri) { mutableStateOf(aAlarmUri) }
    var tAlarmDuration by remember(aAlarmDuration) { mutableIntStateOf(aAlarmDuration) }
    var tVibration by remember(aVibration) { mutableStateOf(aVibration) }

    var tBkInterval by remember(aBkInterval) { mutableIntStateOf(aBkInterval) }
    var tBkLocation by remember(aBkLocation) { mutableStateOf(aBkLocation) }
    var tBkEncrypt by remember(aBkEncrypt) { mutableStateOf(aBkEncrypt) }
    var tBkPhrase by remember(aBkPhrase) { mutableStateOf(aBkPhrase) }
    val bkPhraseMissing = tBkEncrypt && tBkPhrase.isBlank()

    var showRestore by remember { mutableStateOf(false) }

    var tESize by remember(aESize) { mutableStateOf(aESize.toString()) }
    var tWSize by remember(aWSize) { mutableStateOf(aWSize.toString()) }
    var tLHeight by remember(aLHeight) { mutableStateOf(aLHeight.toString()) }

    LaunchedEffect(tTxt, tBg, tWTxt, tWBg, tBCol, tWBCol, tNextBg, tWNextBg) {
        val hasColorChanges = tTxt != aTxt || tBg != aBg || tWTxt != aWTxt || tWBg != aWBg ||
                tBCol != aBColor || tWBCol != aWBCol || tNextBg != aNextBg || tWNextBg != aWNextBg
        if (hasColorChanges) {
            delay(1200)
            prefs.setColors(tTxt, tBg, tWTxt, tWBg, tBCol, tWBCol, tNextBg, tWNextBg)
            NoteWidgetProvider.triggerUpdate(context)
        }
    }

    LaunchedEffect(tLink) {
        if (tLink != aLink) {
            delay(1200)
            prefs.setLinkColor(tLink)
        }
    }

    LaunchedEffect(tESize, tWSize, tLHeight) {
        val finalE =
            tESize.toFloatOrNull()?.coerceIn(WedgeConfig.FONT_SIZE_MIN, WedgeConfig.FONT_SIZE_MAX)
                ?: WedgeConfig.FONT_SIZE_DEFAULT
        val finalW =
            tWSize.toFloatOrNull()?.coerceIn(WedgeConfig.FONT_SIZE_MIN, WedgeConfig.FONT_SIZE_MAX)
                ?: WedgeConfig.WIDGET_FONT_SIZE_DEFAULT
        val finalLH = tLHeight.toFloatOrNull() ?: WedgeConfig.LINE_HEIGHT_DEFAULT
        if (finalE != aESize || finalW != aWSize || finalLH != aLHeight) {
            prefs.setFontSettings(finalE, finalW, finalLH)
            NoteWidgetProvider.triggerUpdate(context)
        }
    }

    LaunchedEffect(tIncognito) {
        if (tIncognito != aIncognito) {
            prefs.setIncognito(tIncognito)
            NoteWidgetProvider.triggerUpdate(context)
        }
    }

    LaunchedEffect(tHeaderFmt) {
        if (tHeaderFmt != aHeaderFmt) {
            delay(1500)
            prefs.setHeaderFormat(tHeaderFmt.ifBlank { WedgeConfig.HEADER_FORMAT_DEFAULT })
            NoteWidgetProvider.triggerUpdate(context)
        }
    }

    LaunchedEffect(tAlarmUri, tAlarmDuration, tVibration) {
        if (tAlarmUri != aAlarmUri || tAlarmDuration != aAlarmDuration || tVibration != aVibration) {
            prefs.setAlarmSettings(tAlarmUri, tAlarmDuration, tVibration)
            NoteWidgetProvider.triggerUpdate(context)
        }
    }

    LaunchedEffect(tBkInterval, tBkLocation, tBkEncrypt, tBkPhrase) {
        if (tBkInterval != aBkInterval || tBkLocation != aBkLocation ||
            tBkEncrypt != aBkEncrypt || tBkPhrase != aBkPhrase
        ) {
            if (bkPhraseMissing) return@LaunchedEffect
            delay(1200)
            prefs.setBackupSettings(tBkInterval, tBkLocation, tBkEncrypt, tBkPhrase)
            if (tBkInterval != aBkInterval) WedgeBackup.schedule(context, tBkInterval)
        }
    }

    val folderPickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.OpenDocumentTree()
    ) { uri: Uri? ->
        uri?.let {
            try {
                context.contentResolver.takePersistableUriPermission(
                    it,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                )
                tBkLocation = it.toString()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    val audioPickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        uri?.let { pickedUri ->
            try {
                var displayName = "alarm_sound"
                if (pickedUri.scheme == "content") {
                    context.contentResolver.query(pickedUri, null, null, null, null)
                        ?.use { cursor ->
                            val nameIdx =
                                cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                            if (nameIdx != -1 && cursor.moveToFirst()) {
                                displayName = cursor.getString(nameIdx)
                            }
                        }
                } else {
                    pickedUri.path?.substringAfterLast('/')?.let { displayName = it }
                }

                context.filesDir.listFiles()?.forEach { file ->
                    if (file.name.startsWith("custom_alarm_")) {
                        file.delete()
                    }
                }

                val localFile = File(context.filesDir, "custom_alarm_$displayName")
                context.contentResolver.openInputStream(pickedUri)?.use { input ->
                    localFile.outputStream().use { output ->
                        input.copyTo(output)
                    }
                }

                tAlarmUri = Uri.fromFile(localFile).toString()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        "Settings",
                        color = colorResource(id = R.color.title_color),
                        fontSize = 18.sp
                    )
                },
                colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF486860)),
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(
                            Icons.AutoMirrored.Filled.ArrowBack,
                            null,
                            tint = colorResource(id = R.color.title_color)
                        )
                    }
                }
            )
        }
    ) { p ->

        Column(
            Modifier.padding(p).fillMaxSize().imePadding().background(CBg)
                .padding(horizontal = 16.dp).verticalScroll(rememberScrollState())
        ) {

            Spacer(Modifier.height(20.dp))

            // alarm

            WgSection("Alarm") {
                WgRow("Audio") {
                    val alarmDisplayName = remember(tAlarmUri) {
                        if (tAlarmUri.isEmpty()) {
                            ""
                        } else {
                            val uri = tAlarmUri.toUri()
                            var resolvedName = ""
                            if (uri.scheme == "content") {
                                try {
                                    context.contentResolver.query(uri, null, null, null, null)
                                        ?.use { cursor ->
                                            val nameIndex =
                                                cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                                            if (nameIndex != -1 && cursor.moveToFirst()) {
                                                resolvedName = cursor.getString(nameIndex)
                                            }
                                        }
                                } catch (_: Exception) {
                                }
                            }
                            val baseName = resolvedName.ifEmpty {
                                uri.path?.substringAfterLast('/') ?: tAlarmUri
                            }

                            if (baseName.startsWith("custom_alarm_")) {
                                baseName.removePrefix("custom_alarm_")
                            } else {
                                baseName
                            }
                        }
                    }
                    val truncatedName =
                        if (alarmDisplayName.length > 25) alarmDisplayName.take(22) + "..." else alarmDisplayName
                    WgPick(truncatedName, "default.wav") {
                        audioPickerLauncher.launch("audio/*")
                    }
                }

                WgDivider()

                WgRow("Duration") {
                    WgDropdown(
                        value = durationLabel(tAlarmDuration),
                        options = (1..10).map { durationLabel(it) }
                    ) { picked ->
                        tAlarmDuration = picked.substringBefore(' ').toIntOrNull() ?: tAlarmDuration
                    }
                }

                WgDivider()

                WgRow("Vibration") {
                    WgRadio(
                        value = if (tVibration) "True" else "False",
                        options = listOf("True", "False")
                    ) { tVibration = it == "True" }
                }
            }

            // archive

            WgSection("Archive") {
                WgRow("Interval") {
                    WgDropdown(
                        value = WedgeConfig.intervalLabel(tBkInterval),
                        options = WedgeConfig.BACKUP_INTERVALS.map { WedgeConfig.intervalLabel(it) }
                    ) { picked ->
                        tBkInterval = WedgeConfig.BACKUP_INTERVALS
                            .firstOrNull { WedgeConfig.intervalLabel(it) == picked } ?: tBkInterval
                    }
                }

                WgDivider()

                WgRow("Location") {
                    val locationName = remember(tBkLocation) {
                        if (tBkLocation.isEmpty()) "" else try {
                            DocumentFile.fromTreeUri(context, tBkLocation.toUri())?.name
                                ?: tBkLocation
                        } catch (_: Exception) {
                            tBkLocation
                        }
                    }
                    val truncatedLocation =
                        if (locationName.length > 25) locationName.take(22) + "..." else locationName
                    WgPick(truncatedLocation, "app/") {
                        folderPickerLauncher.launch(null)
                    }
                }

                WgDivider()

                WgRow("Encrypt") {
                    WgRadio(
                        value = if (tBkEncrypt) "True" else "False",
                        options = listOf("True", "False")
                    ) { tBkEncrypt = it == "True" }
                }

                WgDivider()

                WgRow("Phrase", edit = true) {
                    WgField(
                        value = tBkPhrase,
                        onValueChange = { tBkPhrase = it },
                        placeholder = if (tBkEncrypt) "Required" else "",
                        modifier = Modifier.weight(1f),
                        visualTransformation = PasswordVisualTransformation(),
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
                    )
                }

                WgDivider()

                WgRow("Archived") {
                    WgValue(
                        if (aBkStamp == 0L) "[none]" else WedgeDate.getOtcMetadata(aBkStamp),
                        mono = true
                    )
                }

                WgDivider()

                WgRow("Archive") {
                    WgAction("Restore", enabled = !bkPhraseMissing) { showRestore = true }
                }
            }

            // calendar

            WgSection("Calendar") {
                WgRow("Header", edit = true) {
                    WgField(
                        value = tHeaderFmt,
                        onValueChange = { tHeaderFmt = it },
                        placeholder = WedgeConfig.HEADER_FORMAT_DEFAULT,
                        modifier = Modifier.weight(1f)
                    )
                }

                WgDivider()

                WgRow("Output") {
                    val headerPreview = remember(tHeaderFmt) {
                        try {
                            WedgeHeader.render(
                                tHeaderFmt.ifBlank { WedgeConfig.HEADER_FORMAT_DEFAULT },
                                System.currentTimeMillis()
                            )
                        } catch (_: Exception) {
                            ""
                        }
                    }
                    WgValue(headerPreview, mono = true)
                }
            }

            // editor

            WgSection("Editor") {
                WgColorRow("Text", tTxt, WedgeConfig.TEXT_COLOR_DEFAULT) { tTxt = it }
                WgDivider()
                WgColorRow("Focus", tBCol, WedgeConfig.BOLD_COLOR_DEFAULT) { tBCol = it }
                WgDivider()
                WgColorRow("Link", tLink, WedgeConfig.LINK_COLOR_DEFAULT) { tLink = it }
                WgDivider()
                WgColorRow("Next", tNextBg, WedgeConfig.CALENDAR_COLOR_DEFAULT) { tNextBg = it }
                WgDivider()
                WgColorRow("Ground", tBg, WedgeConfig.BG_COLOR_DEFAULT) { tBg = it }
                WgDivider()

                WgRow("Font Size") {
                    WgDropdown(tESize, FONT_SIZE_OPTIONS) { tESize = it }
                }

                WgDivider()

                WgRow("Line Height") {
                    WgDropdown(tLHeight, LINE_HEIGHT_OPTIONS) { tLHeight = it }
                }

                WgDivider()

                WgRow("Incognito") {
                    WgRadio(
                        value = if (tIncognito) "True" else "False",
                        options = listOf("True", "False")
                    ) { tIncognito = it == "True" }
                }
            }

            // widget

            WgSection("Widget") {
                WgColorRow("Text", tWTxt, WedgeConfig.WIDGET_TEXT_COLOR_DEFAULT) { tWTxt = it }
                WgDivider()
                WgColorRow("Focus", tWBCol, WedgeConfig.WIDGET_BOLD_COLOR_DEFAULT) { tWBCol = it }
                WgDivider()
                WgColorRow(
                    "Next",
                    tWNextBg,
                    WedgeConfig.WIDGET_CALENDAR_COLOR_DEFAULT
                ) { tWNextBg = it }
                WgDivider()
                WgColorRow("Ground", tWBg, WedgeConfig.WIDGET_BG_COLOR_DEFAULT) { tWBg = it }
                WgDivider()

                WgRow("Font Size") {
                    WgDropdown(tWSize, FONT_SIZE_OPTIONS) { tWSize = it }
                }
            }

            Spacer(Modifier.height(24.dp))
        }

        if (showRestore) {
            ConfirmDialog(
                text = "Restore?",
                onConfirm = {
                    showRestore = false
                    scope.launch { WedgeBackup.restore(context) }
                },
                onDismiss = { showRestore = false }
            )
        }
    }
}

@Composable
internal 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
internal fun WgCard(content: @Composable ColumnScope.() -> Unit) {
    Column(
        Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(14.dp))
            .background(CCard),
        content = content
    )
}

@Composable
internal fun WgRow(
    label: String,
    edit: Boolean = false,
    content: @Composable RowScope.() -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(min = ROW_H)
            .padding(horizontal = 16.dp, vertical = 8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(
            modifier = Modifier.widthIn(min = 92.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(
                text = label,
                color = CText.copy(alpha = 0.8f),
                fontSize = FS,
                maxLines = 1
            )
            if (edit) {
                Spacer(Modifier.width(6.dp))
                Icon(
                    imageVector = Icons.Filled.Create,
                    contentDescription = null,
                    tint = CText.copy(alpha = 0.6f),
                    modifier = Modifier.size(14.dp)
                )
            }
        }
        Spacer(Modifier.width(12.dp))
        Row(
            modifier = Modifier.weight(1f),
            horizontalArrangement = Arrangement.End,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }
}

@Composable
internal fun WgDivider() {
    HorizontalDivider(thickness = 1.dp, color = CBg)
}

@Composable
internal fun WgValue(
    text: String,
    modifier: Modifier = Modifier,
    mono: Boolean = false,
    color: Color = CText,
    maxLines: Int = 1
) {
    Text(
        text = text,
        color = color,
        fontSize = FS,
        fontFamily = if (mono) FontFamily.Monospace else FontFamily.Default,
        textAlign = TextAlign.End,
        maxLines = maxLines,
        overflow = TextOverflow.Ellipsis,
        modifier = modifier
    )
}

@Composable
private fun WgPick(value: String, placeholder: String, onClick: () -> Unit) {
    Text(
        text = value.ifBlank { placeholder },
        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
        )
    )
}

@Suppress("SameParameterValue")
@Composable
private fun WgAction(text: String, enabled: Boolean, onClick: () -> Unit) {
    WgChip(label = text, selected = true, enabled = enabled, onClick = onClick)
}

@Composable
internal 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
internal fun WgDropdown(
    value: String,
    options: List<String>,
    disabledOptions: List<String> = emptyList(),
    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 ->
                val disabled = opt in disabledOptions
                DropdownMenuItem(
                    enabled = !disabled,
                    text = {
                        Text(
                            text = opt,
                            color = when {
                                disabled -> CMuted.copy(alpha = 0.4f)
                                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,
    shape: Shape = RoundedCornerShape(7.dp),
    border: Boolean = true,
    leftLine: Color? = null,
    onClick: () -> Unit
) {
    val filled = selected && enabled

    Box(
        modifier = Modifier
            .height(28.dp)
            .clip(shape)
            .background(if (filled) CChip else Color.Transparent)
            // segment separator; after background so it survives the fill
            .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) CChip 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
internal fun WgRadio(
    value: String,
    options: List<String>,
    disabledOptions: List<String> = emptyList(),
    onSelect: (String) -> Unit
) {
    val shape = RoundedCornerShape(7.dp)
    val outline = CMuted.copy(alpha = 0.30f)

    Row(
        modifier = Modifier
            .height(28.dp)
            .clip(shape)
            // outer four sides + rounding; drawn over the segment fills
            .border(1.dp, outline, shape),
        verticalAlignment = Alignment.CenterVertically
    ) {
        options.forEachIndexed { i, opt ->
            WgChip(
                label = opt,
                selected = opt == value,
                enabled = opt !in disabledOptions,
                shape = RectangleShape,
                border = false,
                leftLine = if (i > 0) outline else null,
                onClick = { onSelect(opt) }
            )
        }
    }
}

private val FONT_SIZE_OPTIONS = (22..32).map { (it / 2.0).toString() }
private val LINE_HEIGHT_OPTIONS = (10..20).map { (it / 10.0).toString() }

private fun durationLabel(m: Int): String = if (m == 1) "1 minute" else "$m minutes"

@Composable
fun ConfirmDialog(text: 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("Yep", color = CText, fontSize = 15.sp) }
        },
        dismissButton = {
            Button(
                onClick = onDismiss,
                modifier = Modifier.height(44.dp),
                colors = btnColors,
                shape = btnShape
            ) { Text("Nope", color = CText, fontSize = 15.sp) }
        },
        text = { Text(text, fontFamily = FontFamily.Monospace, fontSize = 17.sp) }
    )
}

@Composable
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 pickerBtnShape = RoundedCornerShape(8.dp)
    val pickerBtnColors = 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 = pickerBtnColors,
                shape = pickerBtnShape
            ) { Text("This", color = CText, fontSize = 15.sp) }
        },
        dismissButton = {
            Button(
                onClick = onDismiss,
                modifier = Modifier.height(44.dp),
                colors = pickerBtnColors,
                shape = pickerBtnShape
            ) { 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(value = r, onValueChange = { r = it }, color = sliderColor, trackColor = sliderBg)
                    VerticalRgbSlider(value = g, onValueChange = { g = it }, color = sliderColor, trackColor = sliderBg)
                    VerticalRgbSlider(value = b, onValueChange = { b = it }, color = 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
Kerf - Android 157.026
Kiln - Android 157.026
Wedge - Android 156.026
Whittle - Linux 155.026
Grit - Firefox/Chrome 154.026

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)
0.00251
257,186 (+440)