Pith - wedge_android
wedge_android/app/src/main/java/com/vgmlr/wedge/WedgeEditor.kt [20.5 kb]
Modified: 19:01:48 69 026 (27 May 026)
3 Days Ago
package com.vgmlr.wedge

import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.PlatformImeOptions
import androidx.compose.ui.text.style.TextDecoration

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun NoteEditor(
    vm: MainViewModel,
    prefs: PreferenceManager,
    onSettings: () -> Unit,
    onShowData: () -> Unit
) {
    val context = LocalContext.current
    val density = LocalDensity.current
    val focusManager = LocalFocusManager.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val ime = WindowInsets.ime
    val keyboardController = LocalSoftwareKeyboardController.current

    val bgColorHex by prefs.bgColor.collectAsState(initial = "#171717")
    val incognitoActive by prefs.incognitoMode.collectAsState(initial = false)
    val editorStyle by vm.editorStyle.collectAsState()
    val scope = rememberCoroutineScope()

    var textState by remember { mutableStateOf(TextFieldValue("")) }
    var lastSavedText by remember { mutableStateOf("") }
    var isLoaded by remember { mutableStateOf(false) }
    var lastDel by remember { mutableLongStateOf(0L) }
    val scrollState = rememberScrollState()
    var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
    var containerHeight by remember { mutableIntStateOf(0) }

    var menuExpanded by remember { mutableStateOf(false) }
    var showVersionDialog by remember { mutableStateOf(false) }
    var showCopyDot by remember { mutableStateOf(false) }
    var showPasteDot by remember { mutableStateOf(false) }
    var undoHistory by remember { mutableStateOf(emptyList<TextFieldValue>()) }
    var undoLineIndex by remember { mutableIntStateOf(-1) }

    var showCalc by remember { mutableStateOf(false) }
    var calcValue by remember { mutableStateOf("") }
    val calcFocusRequester = remember { FocusRequester() }

    val boldColorHex by prefs.boldColor.collectAsState(initial = WedgeConfig.BOLD_COLOR_DEFAULT)
    val focusColor = parseColor(boldColorHex)
    
    LaunchedEffect(showCalc) {
        if (showCalc) {
            delay(100)
            calcFocusRequester.requestFocus()
            keyboardController?.show()
        }
    }

    var showEncrypt by remember { mutableStateOf(false) }
    var passPhrase by remember { mutableStateOf("") }
    val encryptFocusRequester = remember { FocusRequester() }
    var isDecryptMode by remember { mutableStateOf(false) }
    val isEncrypted = WedgeSecurity.isEncrypted(textState.text)
    LaunchedEffect(showEncrypt) {
        if (showEncrypt) {
            delay(100)
            encryptFocusRequester.requestFocus()
            keyboardController?.show()
        }
    }
    
    val uriHandler = LocalUriHandler.current
    val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager

    val exportLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.CreateDocument("text/plain")
    ) { uri ->
        uri?.let {
            context.contentResolver.openOutputStream(it)?.use { stream ->
                stream.write(textState.text.toByteArray())
            }
        }
    }

    fun refreshDateMath(content: String): String {
        return WedgeRegex.DATE_MATH_REFRESH.replace(content) { match ->
            val d = match.groupValues[1].toInt()
            val m = match.groupValues[2]
            WedgeCalendar.getDateMath(d, m)?.let { "$d$m$it" } ?: match.value
        }
    }

    suspend fun saveNote(content: String, updateWidget: Boolean = false) {
        withContext(Dispatchers.IO) {
            val isDifferent = content != lastSavedText
            if (!isDifferent && !updateWidget) return@withContext

            if (isDifferent) {
                vm.dao.save(NoteEntity(id = 1, content = content))
                withContext(Dispatchers.Main) { lastSavedText = content }
            }
            if (updateWidget) {
                NoteWidgetProvider.triggerUpdate(context)
            }
        }
    }

    LaunchedEffect(Unit) {
        val initialNote = vm.dao.getNoteSync()
        if (initialNote != null) {
            val refreshed = refreshDateMath(initialNote.content)
            textState = TextFieldValue(refreshed)
            lastSavedText = refreshed
        }
        isLoaded = true
    }

    LaunchedEffect(Unit) {
        snapshotFlow { textState.text }.collectLatest { text ->
            if (!isLoaded || text == lastSavedText) return@collectLatest
            delay(500)
            saveNote(text, updateWidget = true)
        }
    }

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_PAUSE) {
                focusManager.clearFocus()
                val currentText = textState.text
                scope.launch {
                    withContext(NonCancellable) {
                        if (isLoaded) saveNote(currentText, updateWidget = true)
                    }
                }
            } else if (event == Lifecycle.Event.ON_RESUME) {
                if (isLoaded) {
                    val refreshed = refreshDateMath(textState.text)
                    if (refreshed != textState.text) {
                        textState = textState.copy(text = refreshed)
                        lastSavedText = refreshed
                    }
                }
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
    }

    BackHandler { (context as? ComponentActivity)?.finish() }

    LaunchedEffect(Unit) { focusManager.clearFocus() }

    var lastLen by remember { mutableIntStateOf(0) }
    var lastKHeight by remember { mutableIntStateOf(0) }
    
    LaunchedEffect(density) {
        snapshotFlow {
            val keyboardHeight = ime.getBottom(density)
            Triple(textState.selection, keyboardHeight, textState.text.length)
        }.collectLatest { (selection, kHeight, currentLen) ->
            val delta = currentLen - lastLen
            val keyboardOpening = kHeight > lastKHeight
            lastLen = currentLen
            lastKHeight = kHeight

            if (kHeight > 0 && (delta >= 0 || keyboardOpening)) {
                delay(40)
                textLayoutResult?.let { layout ->
                    try {
                        val offset = selection.start
                        if (offset >= 0) {
                            val cursorRect = layout.getCursorRect(offset)
                            val cursorBottom = cursorRect.bottom + with(density) { 16.dp.toPx() }
                            val buffer = with(density) { 45.dp.toPx() }
                            if (containerHeight > 0) {
                                val visibleHeight = containerHeight - kHeight
                                val viewportBottom = scrollState.value + visibleHeight
                                if (cursorBottom + buffer > viewportBottom) {
                                    val targetScroll = (cursorBottom + buffer - visibleHeight).toInt()
                                    scrollState.animateScrollTo(
                                        value = targetScroll.coerceAtLeast(0),
                                        animationSpec = spring(
                                            dampingRatio = Spring.DampingRatioNoBouncy,
                                            stiffness = Spring.StiffnessMediumLow
                                        )
                                    )
                                }
                            }
                        }
                    } catch (_: Exception) {}
                }
            }
        }
    }

    val isKeyboardOpen = WindowInsets.ime.getBottom(density) > 0
    LaunchedEffect(isKeyboardOpen) {
        if (!isKeyboardOpen) {
            focusManager.clearFocus()
            textState = textState.copy(selection = TextRange.Zero)
        }
    }

    Scaffold(
        topBar = {
            EditorTopBar(
                menuExpanded = menuExpanded,
                onMenuExpandedChange = { menuExpanded = it },
                showCopyDot = showCopyDot,
                showPasteDot = showPasteDot,
                undoEnabled = undoHistory.isNotEmpty(),
                onCopy = {
                    scope.launch {
                        saveNote(textState.text)
                        val sel = textState.selection
                        val start = sel.min.coerceIn(0, textState.text.length)
                        val end = sel.max.coerceIn(0, textState.text.length)
                        if (start != end) {
                            val selectedText = textState.text.substring(start, end)
                            clipboardManager.setPrimaryClip(ClipData.newPlainText("wedge_note", selectedText))
                            showCopyDot = true
                            delay(1000)
                            showCopyDot = false
                        }
                    }
                },
                onPaste = {
                    val clipboardText = clipboardManager.primaryClip?.getItemAt(0)?.text ?: ""
                    if (clipboardText.isNotEmpty()) {
                        val sel = textState.selection
                        val start = sel.min.coerceIn(0, textState.text.length)
                        val end = sel.max.coerceIn(0, textState.text.length)
                        val newText = textState.text.replaceRange(start, end, clipboardText)
                        val newCursorPos = start + clipboardText.length
                        textState = textState.copy(text = newText, selection = TextRange(newCursorPos))
                        scope.launch {
                            showPasteDot = true
                            delay(1000)
                            showPasteDot = false
                        }
                    }
                },
                onUndo = {
                    if (undoHistory.isNotEmpty()) {
                        textState = undoHistory.first()
                        undoHistory = undoHistory.drop(1)
                    }
                },
                onSelectAll = {
                    menuExpanded = false
                    textState = textState.copy(
                        selection = TextRange(0, textState.text.length)
                    )
                },
                onSort = {
                    menuExpanded = false
                    val sel = textState.selection
                    if (!sel.collapsed) {
                        val txt = textState.text
                        val start = txt.lastIndexOf('\n', sel.min - 1).let { if (it == -1) 0 else it + 1 }
                        val end = txt.indexOf('\n', sel.max).let { if (it == -1) txt.length else it }
                        val block = txt.substring(start, end)
                        val lines = block.split('\n')
                        val baseIndent = lines.firstOrNull { it.isNotBlank() }?.takeWhile { it.isWhitespace() }?.length ?: 0
                        val groups = mutableListOf<MutableList<String>>()
                        for (line in lines) {
                            if (groups.isEmpty() || (line.isNotBlank() && line.takeWhile { it.isWhitespace() }.length <= baseIndent)) {
                                groups.add(mutableListOf(line))
                            } else {
                                groups.last().add(line)
                            }
                        }
                        val sorted = groups.sortedWith { g1, g2 -> String.CASE_INSENSITIVE_ORDER.compare(g1[0], g2[0]) }.flatten().joinToString("\n")
                        textState = textState.copy(
                            text = txt.replaceRange(start, end, sorted),
                            selection = TextRange(start, start + sorted.length)
                        )
                    }
                },
                onToggleCalc = {
                    menuExpanded = false
                    showCalc = !showCalc
                },
                showCalc = showCalc,
                onShowData = {
                    menuExpanded = false
                    onShowData()
                },
                onExport = {
                    menuExpanded = false
                    val otcTimestamp = WedgeCalendar.getOtcDate()
                    exportLauncher.launch("wedge_$otcTimestamp.txt")
                },
                onSettings = {
                    menuExpanded = false
                    focusManager.clearFocus()
                    onSettings()
                },
                onShowVersion = {
                    menuExpanded = false
                    showVersionDialog = true
                },
                isEncrypted = isEncrypted,
                onSecurityAction = {
                    menuExpanded = false
                    showCalc = false
                    isDecryptMode = isEncrypted
                    showEncrypt = true
                }
            )
        },
        contentWindowInsets = WindowInsets(0, 0, 0, 0)
    ) { p ->
        Column(modifier = Modifier
            .padding(p)
            .fillMaxSize()
            .background(parseColor(bgColorHex))) {
            if (showCalc) {
                CalculatorOverlay(
                    calcValue = calcValue,
                    onCalcValueChange = { calcValue = it },
                    onDismiss = { showCalc = false },
                    focusRequester = calcFocusRequester
                )
            }
            if (showEncrypt) {
                EncryptionOverlay(
                    passPhrase = passPhrase,
                    onPassPhraseChange = { passPhrase = it },
                    onDismiss = { showEncrypt = false; passPhrase = "" },
                    focusRequester = encryptFocusRequester,
                    isDecrypt = isDecryptMode,
                    onAction = {
                        if (passPhrase.isNotEmpty()) {
                            if (isEncrypted) { 
                                WedgeSecurity.decrypt(textState.text, passPhrase)?.let {
                                    textState = textState.copy(text = it, selection = TextRange(0, it.length))
                                    showEncrypt = false
                                    passPhrase = ""
                                } ?: run { passPhrase = "" }
                            } else {
                                val encrypted = WedgeSecurity.encrypt(textState.text, passPhrase)
                                textState = textState.copy(text = encrypted, selection = TextRange(0, encrypted.length))
                                showEncrypt = false
                                passPhrase = ""
                            }
                        }
                    }
                )
            }
            Box(
                modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth()
                    .onGloballyPositioned { containerHeight = it.size.height }
                    .verticalScroll(scrollState)
                    .imePadding()
                    .navigationBarsPadding()
            ) {
                BasicTextField(
                    value = textState,
                    onValueChange = { newValue ->
                        val result = WedgeEditorEngine.applyTextChange(newValue, textState, undoHistory, undoLineIndex, lastDel)
                        textState = result.text
                        undoHistory = result.undoHistory
                        undoLineIndex = result.undoLineIndex
                        lastDel = result.lastDel
                    },
                    onTextLayout = { textLayoutResult = it },
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp),
                    textStyle = editorStyle,
                    cursorBrush = SolidColor(editorStyle.color),
                    keyboardOptions = KeyboardOptions(
                        platformImeOptions = if (incognitoActive) PlatformImeOptions("privateImeOptions=true,com.google.android.inputmethod.latin.noPersonalizedLearning=true") else null
                    ),
                    visualTransformation = { text ->
                        val annotated = buildAnnotatedString {
                            append(text.text)
                            WedgeRegex.BOLD_LINE.findAll(text.text).forEach { match ->
                                addStyle(
                                    style = SpanStyle(
                                        fontWeight = FontWeight.Bold,
                                        color = focusColor
                                    ),
                                    start = match.range.first,
                                    end = match.range.last + 1
                                )
                            }
                            WedgeRegex.ITALIC_LINE.findAll(text.text).forEach { match ->
                                addStyle(
                                    style = SpanStyle(
                                        fontStyle = FontStyle.Italic
                                    ),
                                    start = match.range.first,
                                    end = match.range.last + 1
                                )
                            }
                            WedgeRegex.UNDERLINE_LINE.findAll(text.text).forEach { match ->
                                val contentGroup = match.groups[1]
                                if (contentGroup != null) {
                                    addStyle(
                                        style = SpanStyle(
                                            textDecoration = TextDecoration.Underline
                                        ),
                                        start = contentGroup.range.first,
                                        end = contentGroup.range.last + 1
                                    )
                                }
                            }
                            WedgeRegex.STRIKE_LINE.findAll(text.text).forEach { match ->
                                val contentMatch = match.groups[1]
                                if (contentMatch != null) {
                                    addStyle(
                                        style = SpanStyle(
                                            textDecoration = TextDecoration.LineThrough
                                        ),
                                        start = contentMatch.range.first,
                                        end = contentMatch.range.last + 1
                                    )
                                }
                            }
                        }
                        TransformedText(annotated, OffsetMapping.Identity)
                    }
                )
            }
        }
    }
    VersionDialog(showVersionDialog, { showVersionDialog = false }, uriHandler)
}
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)