Pith - shim
shim/app/src/main/kotlin/com/vgmlr/shim/ShimSettings.kt [17.6 kb]
Modified: 22:39:16 117 026 (14 Jul 026)
11 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.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.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 androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.Dp

class ShimSettings : ComponentActivity() {
    private lateinit var themeManager: ShimThemeManager
    private lateinit var db: ShimDatabase
    private val btnShape = RoundedCornerShape(8.dp)
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT))
        themeManager = ShimThemeManager(this)
        db = ShimDatabase(this)

        setContent {
            ShimTheme(themeManager) {
                SettingsScreen()
            }
        }
    }

    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun SettingsScreen() {
        val context = LocalContext.current
        val scope = rememberCoroutineScope()
        
        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) }

        val archived = remember(backup.stamp) {
            if (backup.stamp == 0L) "none"
            else OTCClock.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 = Color.White) },
                    navigationIcon = {
                        IconButton(onClick = { finish() }) {
                            Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White)
                        }
                    },
                    
                    colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF486860))
                )
            },
            contentWindowInsets = WindowInsets(0, 0, 0, 0),
            containerColor = themeManager.backgroundColor.value
        ) { padding ->
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding)
                    .imePadding()
                    .background(themeManager.backgroundColor.value)
                    .padding(horizontal = 16.dp)
                    .verticalScroll(rememberScrollState())
            ) {

                SectionHeader("Appearance")
                
                SettingItem("Ground Color", bgColor, true) { bgColor = it }
                SettingItem("Font Color", textColor, true) { textColor = it }
                SettingItem("Hash Tag Color", hashColor, true) { hashColor = it }
                SettingItem("Font Size", fontSize, false) { fontSize = it }
                SettingItem("Line Height", lineHeight, false) { lineHeight = it }
                SettingItem("Letter Spacing", lineSpacing, false) { lineSpacing = it }

                DropdownItem("Monospace", isMonospace.toString(), listOf("true", "false")) {
                    isMonospace = it.toBoolean()
                }

                SectionHeader("Archive", topPad = 6.dp)

                DropdownItem(
                    "Interval",
                    ShimBackUp.intervalLabel(bkInterval),
                    ShimBackUp.INTERVALS.map { ShimBackUp.intervalLabel(it) }
                ) { label ->
                    bkInterval = ShimBackUp.INTERVALS.first { ShimBackUp.intervalLabel(it) == label }
                }

                val locationName = remember(bkLocation) {
                    if (bkLocation.isEmpty()) "" else try {
                        DocumentFile.fromTreeUri(context, bkLocation.toUri())?.name ?: bkLocation
                    } catch (_: Exception) { bkLocation }
                }
                Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
                    Box(modifier = Modifier.width(140.dp)) { SetLabel("Location") }
                    Box(
                        modifier = Modifier.weight(1f).clickable { folderPicker.launch(null) }
                    ) {
                        OutlinedTextField(
                            value = if (locationName.length > 25) locationName.take(22) + "..." else locationName,
                            onValueChange = {},
                            readOnly = true,
                            enabled = false,
                            singleLine = true,
                            placeholder = { Text("app/", color = labelCol().copy(alpha = 0.5f)) },
                            modifier = Modifier.fillMaxWidth(),
                            colors = OutlinedTextFieldDefaults.colors(
                                disabledTextColor = themeManager.textColor.value,
                                disabledPlaceholderColor = themeManager.textColor.value.copy(alpha = 0.5f),
                                disabledBorderColor = themeManager.textColor.value.copy(alpha = 0.2f)
                            )
                        )
                    }
                }
                Spacer(modifier = Modifier.height(12.dp))

                DropdownItem("Encrypt", if (bkEncrypt) "True" else "False", listOf("True", "False")) {
                    bkEncrypt = it == "True"
                }

                Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
                    Box(modifier = Modifier.width(140.dp)) { SetLabel("Phrase") }
                    OutlinedTextField(
                        value = bkPhrase,
                        onValueChange = { bkPhrase = it },
                        modifier = Modifier.weight(1f),
                        singleLine = true,
                        placeholder = { if (bkEncrypt) Text("Required", color = labelCol().copy(alpha = 0.5f)) },
                        colors = fieldCol(),
                        visualTransformation = PasswordVisualTransformation(),
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
                    )
                }
                Spacer(modifier = Modifier.height(20.dp))

                Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
                    Box(modifier = Modifier.width(140.dp)) { SetLabel("Archived") }
                    Text(
                        text = archived,
                        color = themeManager.textColor.value,
                        fontSize = 15.sp,
                        fontFamily = FontFamily.Monospace
                    )
                }
                Spacer(modifier = Modifier.height(20.dp))

                Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
                    Box(modifier = Modifier.width(140.dp)) { SetLabel("Restore") }
                    Button(
                        onClick = {
                            scope.launch {
                                restoreStatus = if (ShimBackUp.restore(context)) "Restored" else "Failed"
                                delay(2000)
                                restoreStatus = ""
                            }
                        },
                        enabled = !bkPhraseMissing,
                        modifier = Modifier.weight(1f).height(48.dp),
                        shape = btnShape,
                        colors = ButtonDefaults.buttonColors(
                            containerColor = Color.Gray,
                            contentColor = Color.Black,
                            disabledContainerColor = Color.Gray.copy(alpha = 0.4f),
                            disabledContentColor = Color.Black.copy(alpha = 0.5f)
                        )
                    ) {
                        Text(
                            text = restoreStatus.ifEmpty { "Restore Archive" },
                            fontSize = 16.sp
                        )
                    }
                }

                SectionHeader("Delete")

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Box(modifier = Modifier.width(140.dp)) { SetLabel("Delete Shims") }
                    Button(
                        onClick = { showDeleteDialog = true },
                        modifier = Modifier.weight(1f).height(48.dp),
                        colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8B0000)),
                        shape = btnShape
                    ) {
                        Text(text = "Delete", color = Color(0xFFEEEEEE), fontSize = 16.sp)
                    }
                }

                Spacer(modifier = Modifier.height(80.dp))

                if (showDeleteDialog) {
                    ConfirmDialog(
                        message = "Delete Shims?",
                        onDismiss = { showDeleteDialog = false },
                        onConfirm = {
                            db.deleteAllShims()
                            showDeleteDialog = false
                        }
                    )
                }
            }
        }
    }

    private fun isHex(value: String): Boolean =
        runCatching { (if (value.startsWith("#")) value else "#$value").toColorInt() }.isSuccess

    @Composable
    private fun labelCol(): Color = themeManager.textColor.value.copy(alpha = 0.6f)

    @Composable
    private fun fieldCol() = OutlinedTextFieldDefaults.colors(
        focusedTextColor = themeManager.textColor.value,
        unfocusedTextColor = themeManager.textColor.value,
        focusedBorderColor = themeManager.textColor.value.copy(alpha = 0.5f),
        unfocusedBorderColor = themeManager.textColor.value.copy(alpha = 0.2f),
        cursorColor = themeManager.textColor.value
    )

    @Composable
    fun SetLabel(text: String) {
        Text(text, color = labelCol(), fontSize = 16.sp, fontWeight = FontWeight.Bold)
    }

    @Composable
    fun SectionHeader(title: String, topPad: Dp = 20.dp) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = topPad, bottom = 20.dp)
                .background(colorResource(id = R.color.search_bg_color)),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.Center
        ) {
            Text(
                text = title,
                modifier = Modifier.padding(vertical = 7.dp),
                color = labelCol(),
                style = MaterialTheme.typography.bodyMedium.copy(letterSpacing = 1.5.sp)
            )
        }
    }

    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun DropdownItem(label: String, value: String, options: List<String>, onSelect: (String) -> Unit) {
        var expanded by remember { mutableStateOf(false) }
        Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
            Box(modifier = Modifier.width(140.dp)) { SetLabel(label) }
            ExposedDropdownMenuBox(
                expanded = expanded,
                onExpandedChange = { expanded = it },
                modifier = Modifier.weight(1f)
            ) {
                OutlinedTextField(
                    value = value,
                    onValueChange = {},
                    readOnly = true,
                    singleLine = true,
                    trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
                    modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
                    colors = fieldCol()
                )
                ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
                    options.forEach { option ->
                        DropdownMenuItem(
                            text = { Text(option) },
                            onClick = { onSelect(option); expanded = false }
                        )
                    }
                }
            }
        }
        Spacer(modifier = Modifier.height(12.dp))
    }

    @Composable
    fun SettingItem(label: String, value: String, isColor: Boolean, onValueChange: (String) -> Unit) {
        val labelCol = labelCol()
        val bCol = themeManager.textColor.value.copy(alpha = 0.2f)

        Column {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Box(modifier = Modifier.width(140.dp)) {
                    Text(
                        label,
                        color = labelCol,
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold
                    )
                }
                if (isColor) {
                    val previewColor = try { Color((if (value.startsWith("#")) value else "#$value").toColorInt()) } catch (_: Exception) { Color.Gray }
                    Box(
                        modifier = Modifier
                            .size(56.dp)
                            .background(previewColor, RoundedCornerShape(4.dp))
                            .border(1.dp, bCol, RoundedCornerShape(4.dp))
                    )
                    Spacer(Modifier.width(12.dp))
                }
                OutlinedTextField(
                    value = value,
                    onValueChange = onValueChange,
                    modifier = Modifier.weight(1f),
                    colors = fieldCol(),
                    placeholder = {
                        Text(
                            when(label) {
                                "Font Size" -> "15.5"
                                "Line Height" -> "1.35"
                                "Letter Spacing" -> "0.2"
                                else -> "#000000"
                            },
                            color = labelCol
                        )
                    },
                    singleLine = true,
                )
            }
            Spacer(modifier = Modifier.height(12.dp))
        }
    }
}
Updates
Wedge - Android 126.026
Wedge - Linux 124.026
Shim - Android 117.026
Miter - 114.026
Kerf - Android 112.026
Dev
TVShow (227) 'CSA'
TVShow (228) 'APT'
TVProgram (83) 'BXT'
Miter Update(s)
Peen (Messaging)

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)