Pith - kerf
kerf/app/src/main/java/com/vgmlr/kerf/EditScreen.kt [24.5 kb]
Modified: 23:09:03 55 026 (13 May 026)
17 Days Ago
package com.vgmlr.kerf

import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.outlined.DataArray
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.graphics.SolidColor
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONObject

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun EditScreen(id: Int, vm: NoteViewModel, onBack: () -> Unit) {
    var title by remember { mutableStateOf("") }
    var textValue by remember { mutableStateOf(TextFieldValue("")) }
    var lastSavedText by remember { mutableStateOf("") }
    var lastBackspaceTime by remember { mutableLongStateOf(0L) }

    val dbNote by vm.observeNote(id).collectAsState(null)
    val kerfColors = LocalKerfColors.current!!
    val clip = LocalClipboardManager.current
    val context = LocalContext.current
    val uriHandler = LocalUriHandler.current
    val scope = rememberCoroutineScope()
    val density = LocalDensity.current
    val isImeVisible = WindowInsets.isImeVisible

    val scroll = rememberScrollState()
    val bringIntoViewRequester = remember { BringIntoViewRequester() }
    var layoutRes by remember { mutableStateOf<TextLayoutResult?>(null) }

    var showMenu by remember { mutableStateOf(false) }
    var showDataDialog by remember { mutableStateOf(false) }

    val isReplyMode = dbNote?.replies != null

    LaunchedEffect(isImeVisible) {
        if (isImeVisible && !isReplyMode) {
            delay(350)
            layoutRes?.let { res ->
                val index = textValue.selection.start.coerceIn(0, res.layoutInput.text.length)
                val cursorRect = res.getCursorRect(index)
                val offsetPx = with(density) { 40.dp.toPx() }
                val extendedRect = cursorRect.copy(bottom = cursorRect.bottom + offsetPx)
                bringIntoViewRequester.bringIntoView(extendedRect)
            }
        }
    }

    val repliesJson = remember(dbNote?.replies) {
        if (dbNote?.replies != null) JSONObject(dbNote!!.replies!!) else null
    }

    val exportLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.CreateDocument("text/plain")
    ) { uri ->
        uri?.let {
            try {
                context.contentResolver.openOutputStream(it)?.use { os ->
                    os.write(textValue.text.toByteArray())
                }
            } catch (_: Exception) {}
        }
    }

    BackHandler {
        vm.updateNoteImmediate(id, title, textValue.text)
        onBack()
    }

    LaunchedEffect(dbNote) {
        dbNote?.let { note ->
            if (lastSavedText.isEmpty() && textValue.text.isEmpty()) {
                title = note.title
                textValue = TextFieldValue(note.content)
                lastSavedText = note.content
            } else if (note.content != lastSavedText) {
                if (textValue.text != note.content) {
                    textValue = textValue.copy(text = note.content)
                }
                lastSavedText = note.content
            }
        }
    }

    Scaffold(
        containerColor = MaterialTheme.colorScheme.background,
        topBar = {
            Column {
                TopAppBar(
                    title = { },
                    navigationIcon = {
                        IconButton(onClick = {
                            vm.updateNoteImmediate(id, title, textValue.text)
                            onBack()
                        }) {
                            Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = MaterialTheme.colorScheme.onPrimary)
                        }
                    },
                    actions = {
                        IconButton(onClick = {
                            val currentText = textValue.text
                            val count = KerfUtils.countNumberedItems(currentText)
                            val nextNum = count + 1
                            val prefix = if (currentText.isNotEmpty() && !currentText.endsWith("\n")) "\n\n" else "\n"
                            val updatedText = "$currentText$prefix$nextNum. "
                            textValue = TextFieldValue(updatedText, TextRange(updatedText.length))
                            lastSavedText = updatedText
                            vm.updateNote(id, title, updatedText)
                            scope.launch {
                                delay(100)
                                scroll.animateScrollTo(scroll.maxValue)
                            }
                        }) {
                            Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.onPrimary)
                        }
                        IconButton(onClick = {
                            val header = "Kerf: ${title.ifBlank { "Untitled" }}\n\n"
                            clip.setText(AnnotatedString(header + textValue.text))
                            vm.updateLastCopied(id)
                        }) {
                            Icon(Icons.Default.ContentCopy, null, tint = MaterialTheme.colorScheme.onPrimary)
                        }
                        IconButton(onClick = {
                            val header = "Kerf: ${title.ifBlank { "Untitled" }}\n\n"
                            shareNote(context, header + textValue.text)
                            vm.updateLastShared(id)
                        }) {
                            Icon(Icons.Default.Share, null, tint = MaterialTheme.colorScheme.onPrimary)
                        }
                        Box {
                            IconButton(onClick = { showMenu = true }) {
                                Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onPrimary)
                            }
                            DropdownMenu(
                                expanded = showMenu,
                                onDismissRequest = { showMenu = false },
                                modifier = Modifier.background(kerfColors.menuBackground).padding(horizontal = 6.dp)
                            ) {
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Outlined.ArrowCircleDown, null, tint = kerfColors.menuText) },
                                    text = { Text(if (isReplyMode) "Remove" else "Reply", color = kerfColors.menuText) },
                                    onClick = { vm.toggleReplyMode(id); showMenu = false }
                                )
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Outlined.ArrowCircleRight, null, tint = kerfColors.menuText) },
                                    text = { Text("Share", color = kerfColors.menuText) },
                                    enabled = isReplyMode,
                                    onClick = {
                                        KerfUtils.shareReplies(context, title, dbNote?.replies)
                                        showMenu = false
                                    }
                                )
                                HorizontalDivider(
                                    modifier = Modifier.padding(vertical = 8.dp),
                                    color = kerfColors.menuText.copy(0.4f),
                                    thickness = 1.dp
                                )
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Outlined.PushPin, null, tint = kerfColors.menuText) },
                                    text = { Text(if (dbNote?.isPinned == true) "Unpin" else "Pin", color = kerfColors.menuText) },
                                    onClick = { vm.togglePin(id); showMenu = false }
                                )
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Default.IosShare, null, tint = kerfColors.menuText) },
                                    text = { Text("Export", color = kerfColors.menuText) },
                                    onClick = {
                                        val fileName = "${title.ifBlank { "Untitled" }.replace(" ", "_")}.kerf"
                                        exportLauncher.launch(fileName)
                                        showMenu = false
                                    }
                                )
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Outlined.DataArray, null, tint = kerfColors.menuText) },
                                    text = { Text("Data", color = kerfColors.menuText) },
                                    onClick = { showDataDialog = true; showMenu = false }
                                )
                                HorizontalDivider(
                                    modifier = Modifier.padding(vertical = 8.dp),
                                    color = kerfColors.menuText.copy(0.4f),
                                    thickness = 1.dp
                                )
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Outlined.Clear, null, tint = kerfColors.menuText) },
                                    text = { Text("Clear", color = kerfColors.menuText) },
                                    onClick = {
                                        val clearedContent = KerfUtils.DEFAULT_PREFIX
                                        textValue = TextFieldValue(clearedContent)
                                        lastSavedText = clearedContent
                                        vm.clearNoteAndSharedDate(id)
                                        showMenu = false
                                    }
                                )
                                DropdownMenuItem(
                                    leadingIcon = { Icon(Icons.Outlined.Delete, null, tint = kerfColors.menuText) },
                                    text = { Text("Delete", color = kerfColors.menuText) },
                                    onClick = { vm.deleteNote(id); onBack(); showMenu = false }
                                )
                            }
                        }
                    },
                    colors = TopAppBarDefaults.topAppBarColors(containerColor = kerfColors.appTopbar)
                )
                Box(Modifier.fillMaxWidth().background(kerfColors.noteHeader)) {
                    TextField(
                        value = title,
                        onValueChange = {
                            if (it.length <= 50) {
                                title = it
                                vm.updateNote(id, it, textValue.text)
                            }
                        },
                        modifier = Modifier.fillMaxWidth().padding(end = 48.dp),
                        singleLine = true,
                        textStyle = LocalTextStyle.current.copy(
                            color = MaterialTheme.colorScheme.onSurface,
                            fontWeight = FontWeight.Bold,
                            fontSize = KerfDimens.TextSizeTitle
                        ),
                        colors = TextFieldDefaults.colors(
                            focusedContainerColor = Color.Transparent,
                            unfocusedContainerColor = Color.Transparent,
                            focusedIndicatorColor = Color.Transparent,
                            unfocusedIndicatorColor = Color.Transparent
                        )
                    )
                    Row(Modifier.align(Alignment.CenterEnd).padding(end = 18.dp), verticalAlignment = Alignment.CenterVertically) {
                        val count = remember(textValue.text) { KerfUtils.countNumberedItems(textValue.text) }
                        Text(text = "$count / ${textValue.text.length}", color = MaterialTheme.colorScheme.onPrimary)
                        if (dbNote?.isPinned == true) {
                            Icon(Icons.Outlined.PushPin, null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(start = 12.dp).size(20.dp))
                        }
                    }
                }
            }
        }
    ) { p ->
        Box(Modifier.fillMaxSize().padding(top = p.calculateTopPadding()).navigationBarsPadding().imePadding()) {
            Column(Modifier.fillMaxSize().verticalScroll(scroll).padding(bottom = 100.dp)) {
                if (!isReplyMode) {
                    Box(Modifier.padding(KerfDimens.PaddingLarge)) {
                        BasicTextField(
                            value = textValue,
                            onValueChange = { newValue ->
                                val textChanged = textValue.text != newValue.text
                                val selectionChanged = textValue.selection != newValue.selection
                                textValue = newValue
                                lastSavedText = newValue.text
                                if (textChanged) vm.updateNote(id, title, newValue.text)
                                if (textChanged || selectionChanged) {
                                    layoutRes?.let { res ->
                                        val index = newValue.selection.start.coerceIn(0, res.layoutInput.text.length)
                                        val cursorRect = res.getCursorRect(index)
                                        scope.launch { bringIntoViewRequester.bringIntoView(cursorRect) }
                                    }
                                }
                            },
                            onTextLayout = { layoutRes = it },
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(end = 95.dp)
                                .bringIntoViewRequester(bringIntoViewRequester)
                                .onPreviewKeyEvent { event ->
                                    if (event.key == Key.Backspace && event.type == KeyEventType.KeyDown) {
                                        val now = System.currentTimeMillis()
                                        if (now - lastBackspaceTime < 70) {
                                            true
                                        } else {
                                            lastBackspaceTime = now
                                            false
                                        }
                                    } else {
                                        false
                                    }
                                },
                            textStyle = LocalTextStyle.current.copy(
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                                lineBreak = LineBreak.Paragraph,
                                fontSize = KerfDimens.TextSizeLarge,
                                lineHeight = 24.sp
                            ),
                            cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
                        )

                        IconOverlay(
                            content = textValue.text,
                            res = layoutRes,
                            onOpenUrl = { url: String -> try { uriHandler.openUri(url) } catch(_: Exception) {} },
                            onDeleteLine = { lineIdx: Int ->
                                val original = textValue.text
                                val updated = KerfUtils.deleteAndReindex(original, lineIdx)
                                textValue = TextFieldValue(updated)
                                lastSavedText = updated
                                vm.deleteLineWithReplySync(id, title, original, lineIdx)
                            }
                        )
                    }
                } else {
                    val lines = textValue.text.lines()
                    Column(Modifier.padding(vertical = KerfDimens.PaddingLarge)) {
                        var activeItemNum: Int? = null
                        lines.forEachIndexed { index, line ->
                            if (line.isBlank()) return@forEachIndexed

                            val urlMatch = KerfUtils.UrlRegex.find(line)
                            val itemMatch = KerfUtils.ItemRegex.find(line.trim())
                            val itemNum = itemMatch?.groupValues?.get(1)?.toIntOrNull()

                            if (itemNum != null) activeItemNum = itemNum

                            Box(Modifier.fillMaxWidth().padding(horizontal = KerfDimens.PaddingLarge)) {
                                Text(
                                    text = line,
                                    modifier = Modifier.padding(end = 95.dp),
                                    style = LocalTextStyle.current.copy(
                                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                                        lineBreak = LineBreak.Paragraph,
                                        fontSize = KerfDimens.TextSizeLarge,
                                        lineHeight = 24.sp
                                    )
                                )

                                if (itemNum != null || urlMatch != null) {
                                    Box(modifier = Modifier.matchParentSize()) {
                                        LineActions(
                                            modifier = Modifier.align(Alignment.TopEnd).offset(y = (-4).dp),
                                            hasUrl = urlMatch != null,
                                            onOpenUrl = { urlMatch?.let { try { uriHandler.openUri(it.value) } catch(_: Exception) {} } },
                                            onDelete = {
                                                val original = textValue.text
                                                val updated = KerfUtils.deleteAndReindex(original, index)
                                                textValue = TextFieldValue(updated)
                                                lastSavedText = updated
                                                vm.deleteLineWithReplySync(id, title, original, index)
                                            }
                                        )
                                    }
                                }
                            }

                            activeItemNum?.let { num ->
                                val nextNonBlank = lines.drop(index + 1).firstOrNull { it.isNotBlank() }
                                val nextIsNewItem = nextNonBlank?.let { KerfUtils.ItemRegex.find(it.trim()) } != null

                                if (nextIsNewItem || nextNonBlank == null) {
                                    val initialReply = repliesJson?.optString(num.toString(), "") ?: ""
                                    Spacer(Modifier.height(8.dp))
                                    ReplyField(
                                        itemNum = num,
                                        initialText = initialReply,
                                        onUpdate = { vm.updateReply(id, num, it) }
                                    )
                                    Spacer(Modifier.height(16.dp))
                                    activeItemNum = null
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    if (showDataDialog) {
        DataDialog(
            createdAt = dbNote?.createdAt,
            updatedAt = dbNote?.updatedAt,
            lastShared = dbNote?.lastShared,
            onDismiss = { showDataDialog = false }
        )
    }
}

@Composable
fun LineActions(modifier: Modifier, hasUrl: Boolean, onOpenUrl: () -> Unit, onDelete: () -> Unit) {
    Row(modifier, verticalAlignment = Alignment.CenterVertically) {
        if (hasUrl) {
            IconButton(onClick = onOpenUrl, modifier = Modifier.size(32.dp)) {
                Icon(Icons.AutoMirrored.Filled.OpenInNew, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onPrimary.copy(0.5f))
            }
        } else {
            Spacer(Modifier.size(32.dp))
        }
        Spacer(Modifier.width(17.dp))
        IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
            Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onPrimary.copy(0.5f))
        }
    }
}

@Composable
fun ReplyField(itemNum: Int, initialText: String, onUpdate: (String) -> Unit) {
    var textValue by remember(itemNum) {
        mutableStateOf(TextFieldValue(initialText.ifEmpty { "$itemNum. " }))
    }
    var lastBackspaceTime by remember { mutableLongStateOf(0L) }

    LaunchedEffect(initialText) {
        if (textValue.text != initialText && initialText.isNotEmpty()) {
            textValue = textValue.copy(text = initialText)
        }
    }

    Box(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)) {
        BasicTextField(
            value = textValue,
            onValueChange = { newValue ->
                var v = newValue
                val prefix = "$itemNum. "
                if (!v.text.startsWith(prefix)) {
                    v = v.copy(text = prefix, selection = TextRange(prefix.length))
                }
                textValue = v
                onUpdate(v.text)
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 8.dp)
                .onPreviewKeyEvent { event ->
                    if (event.key == Key.Backspace && event.type == KeyEventType.KeyDown) {
                        val now = System.currentTimeMillis()
                        if (now - lastBackspaceTime < 70) {
                            true
                        } else {
                            lastBackspaceTime = now
                            false
                        }
                    } else {
                        false
                    }
                },
            textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = KerfDimens.TextSizeLarge, lineHeight = 24.sp),
            cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
        )
    }
}

@Composable
fun IconOverlay(content: String, res: TextLayoutResult?, onOpenUrl: (String) -> Unit, onDeleteLine: (Int) -> Unit) {
    if (res == null) return
    val dens = LocalDensity.current
    val lines = content.lines()
    var totalOffset = 0
    val processedLines = mutableSetOf<Int>()

    Box(Modifier.fillMaxSize()) {
        for (i in lines.indices) {
            val lineText = lines[i]
            val hasUrl = KerfUtils.UrlRegex.find(lineText) != null
            val hasNumber = KerfUtils.ItemRegex.find(lineText.trim()) != null
            if (hasUrl || hasNumber) {
                val visualLine = res.getLineForOffset(totalOffset)
                if (visualLine < res.lineCount && visualLine !in processedLines) {
                    processedLines.add(visualLine)
                    val top = with(dens) { res.getLineTop(visualLine).toDp() }
                    LineActions(
                        modifier = Modifier.align(Alignment.TopEnd).offset(y = top - 4.dp),
                        hasUrl = hasUrl,
                        onOpenUrl = { KerfUtils.UrlRegex.find(lineText)?.value?.let(onOpenUrl) },
                        onDelete = { onDeleteLine(i) }
                    )
                }
            }
            totalOffset += lineText.length + 1
        }
    }
}
Updates
Shim - Android 70.026.1
Wedge - Linux 68.026.1
Wedge - Android 68.026.1
Taper - Linux 64.026.1
Ayh Extension - Chrome 63.026.1
Dev
TVShow (227) 'CSA'
TVShow (228) 'APT'
TVProgram (83) 'BXT'
Miter Update(s)
Shim (Dictation)

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