Pith - wedge_android
wedge_android/app/src/main/java/com/vgmlr/wedge/WedgeEditor.kt [46.9 kb]
Modified: 18:56:46 154 026 (20 Aug 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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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.outlined.Backspace
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Percent
import androidx.compose.material.icons.filled.StrikethroughS
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
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.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
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.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.input.PlatformImeOptions
import androidx.compose.ui.text.style.TextDecoration
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

private val overlayEnter = expandVertically(tween(200), Alignment.Top) +
        slideInVertically(tween(200)) { -it }
private val overlayExit = shrinkVertically(tween(160), Alignment.Top) +
        slideOutVertically(tween(160)) { -it }

@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun NoteEditor(
    vm: MainViewModel,
    prefs: PreferenceManager,
    onSettings: () -> Unit,
    onSync: () -> Unit,
    onData: () -> 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 nextTimeBgColor by prefs.nextTimeBg.collectAsState("#486860")
    val bgColorHex by prefs.bgColor.collectAsState(initial = "#131313")
    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 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() }
    var editorFocused by remember { mutableStateOf(false) }
    
    val headerFormat by prefs.headerFormat.collectAsState(initial = WedgeConfig.HEADER_FORMAT_DEFAULT)
    val nextTimeOffset = remember(textState.text, headerFormat) {
        WedgeEditorEngine.findNextTimeHighlight(textState.text, headerFormat)
    }

    var showDatePicker by remember { mutableStateOf(false) }
    var showCalendarOverlay by remember { mutableStateOf(false) }
    var selectedDate by remember { mutableLongStateOf(0L) }
    var calendarOtcDayStr by remember { mutableStateOf("") }
    var calendarTimeStr by remember { mutableStateOf("00:00") }
    var calendarTextStr by remember { mutableStateOf("Event") }
    val calendarFocusRequester = remember { FocusRequester() }

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

    LaunchedEffect(showCalendarOverlay) {
        if (showCalendarOverlay) {
            delay(100)
            calendarFocusRequester.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)

    val bkEncrypt by prefs.backupEncrypt.collectAsState(initial = false)
    val bkPhrase by prefs.backupPhrase.collectAsState(initial = "")
    val quickCryptEnabled = bkEncrypt && bkPhrase.isNotBlank()
    
    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 refreshAlarms(content: String): String {
        val now = java.util.Calendar.getInstance()
        val currentMinutes = now.get(java.util.Calendar.HOUR_OF_DAY) * 60 + now.get(java.util.Calendar.MINUTE)
        val todayOtcDay = WedgeDate.getOtcParts(now.timeInMillis).first

        val lines = content.split("\n")
        var currentHeaderOtcDay: Long? = null
        val generalHeaderRegex = WedgeHeader.matchRegex(headerFormat)

        val updatedLines = lines.map { line ->
            val headerMatch = generalHeaderRegex.find(line.trim())
            if (headerMatch != null) {
                currentHeaderOtcDay = WedgeHeader.otcDayFromMatch(headerMatch)?.toLong()
                line
            } else {
                val match = Regex("""^(\d{2}):(\d{2})\s*(.*)(?<!\s)(?:\((-\d+(\.\d+)?|0)?\)|\[(-\d+(\.\d+)?|0)?])$""").find(line.trim())
                if (match != null) {
                    val h = match.groupValues[1].toInt()
                    val m = match.groupValues[2].toInt()
                    val desc = match.groupValues[3]
                    val isAlarm = match.value.endsWith(")")

                    val alarmMinutes = h * 60 + m
                    val headerDay = currentHeaderOtcDay ?: todayOtcDay

                    val valueInside = if (headerDay < todayOtcDay) {
                        "0"
                    } else if (headerDay == todayOtcDay) {
                        val diffMinutes = alarmMinutes - currentMinutes
                        if (diffMinutes <= 0) {
                            "0"
                        } else {
                            val hours = (diffMinutes / 60.0).coerceAtLeast(0.1)
                            "-${String.format(java.util.Locale.US, "%.1f", hours)}"
                        }
                    } else {
                        val diffDays = headerDay - todayOtcDay
                        val diffMinutes = (diffDays * 24 * 60) + alarmMinutes - currentMinutes
                        val hours = (diffMinutes / 60.0).coerceAtLeast(0.1)
                        "-${String.format(java.util.Locale.US, "%.1f", hours)}"
                    }

                    val leadingSpaces = line.takeWhile { it.isWhitespace() }
                    val open = if (isAlarm) "(" else "["
                    val close = if (isAlarm) ")" else "]"
                    "${leadingSpaces}${match.groupValues[1]}:${match.groupValues[2]}  ${desc.trim()}$open$valueInside$close"
                } else {
                    line
                }
            }
        }
        return updatedLines.joinToString("\n")
    }

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

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

            if (isDifferent) {
                if (bumpTimestamp) {
                    vm.dao.save(NoteEntity(id = 1, content = content))
                    withContext(Dispatchers.Main) { vm.noteActivity() }
                } else {
                    vm.dao.updateContentPreservingTimestamp(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)
            if (refreshed != initialNote.content) {
                saveNote(refreshed, updateWidget = true, bumpTimestamp = false)
            } else {
                lastSavedText = refreshed
            }
        }
        isLoaded = true
    }

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

    LaunchedEffect(Unit) {
        vm.syncEvent.collect { stamp ->
            if (stamp == 0L || !isLoaded) return@collect
            val content = withContext(Dispatchers.IO) { vm.dao.getNoteSync()?.content ?: "" }
            if (content != textState.text) {
                val refreshed = refreshDateMath(content)
                textState = TextFieldValue(refreshed, TextRange(0))
                lastSavedText = content
                if (refreshed != content) {
                    saveNote(refreshed, updateWidget = true, bumpTimestamp = false)
                }
            }
        }
    }

    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)
                        scope.launch {
                            saveNote(refreshed, updateWidget = true, bumpTimestamp = false)
                        }
                    }
                }
            }
        }
        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 && containerHeight > 0) {
                            val cursorRect = layout.getCursorRect(offset)
                            val cursorBottom = cursorRect.bottom + with(density) { 16.dp.toPx() }
                            val lineHeight = cursorRect.height
                            val visibleHeight = containerHeight - kHeight
                            val viewportBottom = scrollState.value + visibleHeight
                            if (cursorBottom > viewportBottom) {
                                val targetScroll = (cursorBottom + 3 * lineHeight - visibleHeight).toInt()
                                scrollState.scrollTo(targetScroll.coerceAtLeast(0))
                            }
                        }
                    } catch (_: Exception) {}
                }
            }
        }
    }

    val isKeyboardOpen = WindowInsets.ime.getBottom(density) > 0
    LaunchedEffect(isKeyboardOpen) {
        if (!isKeyboardOpen) {
            focusManager.clearFocus()
        }
    }

    Scaffold(
        topBar = {
            EditorTopBar(
                menuExpanded = menuExpanded,
                onMenuExpandedChange = { menuExpanded = it },
                hasSelection = !textState.selection.collapsed,
                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))
                        }
                    }
                },
                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))
                    }
                },
                onUndo = {
                    if (undoHistory.isNotEmpty()) {
                        textState = undoHistory.first()
                        undoHistory = undoHistory.drop(1)
                    }
                },
                onRemoveDay = {
                    menuExpanded = false
                    val newText = WedgeEditorEngine.removeTopDay(textState.text, headerFormat)
                    if (newText != textState.text) {
                        undoHistory = (listOf(textState) + undoHistory).take(2)
                        textState = textState.copy(
                            text = newText,
                            selection = TextRange(0)
                        )
                    }
                },
                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
                    focusManager.clearFocus()
                    onData()
                },
                onExport = {
                    menuExpanded = false
                    val otcTimestamp = WedgeDate.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
                },
                onSync = {
                    menuExpanded = false
                    focusManager.clearFocus()
                    onSync()
                }
            )
        },
        contentWindowInsets = WindowInsets(0, 0, 0, 0)
    ) { p ->
        Box(modifier = Modifier
            .fillMaxSize()) {
            Column(
                modifier = Modifier
                    .padding(p)
                    .fillMaxSize()
                    .background(parseColor(bgColorHex))
            ) {
                AnimatedVisibility(
                    visible = showCalc,
                    enter = overlayEnter,
                    exit = overlayExit
                ) {
                    CalculatorOverlay(
                        calcValue = calcValue,
                        onCalcValueChange = { calcValue = it },
                        onDismiss = { showCalc = false },
                        focusRequester = calcFocusRequester
                    )
                }
                AnimatedVisibility(
                    visible = showEncrypt,
                    enter = overlayEnter,
                    exit = overlayExit
                ) {
                    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 = ""
                                }
                            }
                        }
                    )
                }
                if (showDatePicker) {
                    WedgeCalendarPicker(
                        onDateSelected = { ts ->
                            selectedDate = ts
                            calendarOtcDayStr = WedgeDate.getOtcParts(ts).first.toString()
                            calendarTimeStr = ""
                            calendarTextStr = ""
                            showCalendarOverlay = true
                            showDatePicker = false
                        },
                        onDismiss = { showDatePicker = false }
                    )
                }
                AnimatedVisibility(
                    visible = showCalendarOverlay,
                    enter = overlayEnter,
                    exit = overlayExit
                ) {
                    CalendarOverlay(
                        daySelected = calendarOtcDayStr,
                        onDaySelectedChange = {},
                        timeValue = calendarTimeStr,
                        onTimeValueChange = { calendarTimeStr = it },
                        textValue = calendarTextStr,
                        onTextValueChange = { calendarTextStr = it },
                        onDismiss = { showCalendarOverlay = false },
                        onEnterPressed = {

                            val updatedText = WedgeCalendar.insertCalendarEntry(
                                currentText = textState.text,
                                timestamp = selectedDate,
                                timeStr = calendarTimeStr.ifEmpty { "00:00" },
                                entryText = calendarTextStr.ifEmpty { "Event" },
                                headerFormat = headerFormat
                            )
                            val rawTime = calendarTimeStr.ifEmpty { "00:00" }
                            val finalTime = if (!rawTime.contains(":") && rawTime.length == 4) {
                                "${rawTime.substring(0, 2)}:${rawTime.substring(2)}"
                            } else if (!rawTime.contains(":") && rawTime.length == 3) {
                                "0${rawTime.substring(0, 1)}:${rawTime.substring(1)}"
                            } else { rawTime }
                            val finalTxt = calendarTextStr.ifEmpty { "Event" }
                            val standardPattern = "$finalTime  $finalTxt"
                            val omittedPattern = "       $finalTxt"
                            val standardIndex = updatedText.indexOf(standardPattern)
                            val newEntryIndex = if (standardIndex != -1) standardIndex else updatedText.indexOf(omittedPattern)
                            val newSelection = if (newEntryIndex != -1) {
                                val matchLength = if (standardIndex != -1) standardPattern.length else omittedPattern.length
                                TextRange(newEntryIndex + matchLength)
                            } else {
                                textState.selection
                            }

                            textState = textState.copy(
                                text = updatedText,
                                selection = newSelection
                            )
                            showCalendarOverlay = false
                        },
                        focusRequester = calendarFocusRequester
                    )
                }
                Box(
                    modifier = Modifier
                        .weight(1f)
                        .fillMaxWidth()
                        .onGloballyPositioned { containerHeight = it.size.height }
                        .verticalScroll(scrollState)
                        .navigationBarsPadding()
                        .imePadding()
                ) {
                    BasicTextField(
                        value = textState,
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(16.dp)
                            .fillMaxSize()
                            .onFocusChanged { editorFocused = it.isFocused }
                            .pointerInput(Unit) {
                                awaitEachGesture {
                                    val down = awaitFirstDown(false, PointerEventPass.Initial)
                                    val layout = textLayoutResult ?: return@awaitEachGesture
                                    val ln = layout.getLineForVerticalPosition(down.position.y)
                                    if (down.position.x > layout.getLineRight(ln)) return@awaitEachGesture
                                    val url = urlAt(
                                        textState.text,
                                        layout.getOffsetForPosition(down.position)
                                    ) ?: return@awaitEachGesture
                                    down.consume()
                                    while (true) {
                                        val c = awaitPointerEvent(PointerEventPass.Initial)
                                            .changes.firstOrNull { it.id == down.id }
                                            ?: return@awaitEachGesture
                                        c.consume()
                                        if (!c.pressed) {
                                            runCatching { uriHandler.openUri(url) }
                                            return@awaitEachGesture
                                        }
                                        if ((c.position - down.position).getDistance() >
                                            viewConfiguration.touchSlop
                                        ) return@awaitEachGesture
                                    }
                                }
                            }
                            .drawBehind {
                                val layout = textLayoutResult ?: return@drawBehind
                                val offset = nextTimeOffset ?: return@drawBehind
                                if (offset < layout.layoutInput.text.text.length) {
                                    try {
                                        val line = layout.getLineForOffset(offset)
                                        val top = layout.getLineTop(line)
                                        val bottom = layout.getLineBottom(line)
                                        val left = layout.getLineLeft(line)
                                        val right = layout.getLineRight(line)
                                        drawRect(
                                            color = parseColor(nextTimeBgColor),
                                            topLeft = Offset(left, top),
                                            size = Size(right - left, bottom - top)
                                        )
                                    } catch (_: Exception) {
                                    }
                                }
                            },
                        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 },


                        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
                                        )
                                    }
                                }
                                WedgeRegex.COMMENT_LINE.findAll(text.text).forEach { match ->
                                    addStyle(
                                        style = SpanStyle(
                                            color = editorStyle.color.copy(alpha = 0.6f)
                                        ),
                                        start = match.range.first,
                                        end = match.range.last + 1
                                    )
                                }
                                WedgeRegex.URL.findAll(text.text).forEach { match ->
                                    addStyle(
                                        style = SpanStyle(color = urlColor),
                                        start = match.range.first,
                                        end = match.range.last + 1
                                    )
                                }
                            }
                            TransformedText(annotated, OffsetMapping.Identity)
                        }
                    )
                }
            }
            
            val buttonSize = 54.dp
            val cornerRadius = 14.dp
            val paddingRight = 20.dp
            val paddingBottom = if (isKeyboardOpen) 20.dp else 54.dp
            val buttonOpacity by androidx.compose.animation.core.animateFloatAsState(
                targetValue = if (isKeyboardOpen) 0.3f else 0.8f,
                label = "buttonOpacity"
            )
            val iconSize = 32.dp
            val gapBetweenButtons = 13.dp
            var fabExpanded by remember { mutableStateOf(false) }
            
            Column(
                modifier = Modifier
                    .align(Alignment.BottomEnd)
                    .imePadding()
                    .padding(end = paddingRight, bottom = paddingBottom),
                horizontalAlignment = Alignment.End
            ) {
                AnimatedVisibility(
                    visible = fabExpanded,
                    enter = expandVertically(
                        animationSpec = tween(220),
                        expandFrom = Alignment.Bottom
                    ) + slideInVertically(
                        animationSpec = tween(220),
                        initialOffsetY = { it }
                    ) + fadeIn(tween(180)),
                    exit = shrinkVertically(
                        animationSpec = tween(200),
                        shrinkTowards = Alignment.Bottom
                    ) + slideOutVertically(
                        animationSpec = tween(200),
                        targetOffsetY = { it }
                    ) + fadeOut(tween(140))
                ) {
                    Column(
                        modifier = Modifier.padding(bottom = gapBetweenButtons),
                        verticalArrangement = Arrangement.spacedBy(gapBetweenButtons),
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Box(
                            modifier = Modifier
                                .size(buttonSize)
                                .graphicsLayer(alpha = buttonOpacity)
                                .clip(RoundedCornerShape(cornerRadius))
                                .background(colorResource(id = R.color.primary_color))
                                .focusProperties { canFocus = false }
                                .clickable {
                                    if (editorFocused) textState = appendStrike(textState)
                                }
                                .padding(8.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                imageVector = Icons.Default.StrikethroughS,
                                contentDescription = "Strikethrough",
                                modifier = Modifier.size(38.dp).padding(top = 4.dp),
                                tint = colorResource(id = R.color.title_color)
                            )
                        }
                        Box(
                            modifier = Modifier
                                .size(buttonSize)
                                .graphicsLayer(alpha = buttonOpacity)
                                .clip(RoundedCornerShape(cornerRadius))
                                .background(colorResource(id = R.color.primary_color))
                                .focusProperties { canFocus = false }
                                .clickable(enabled = editorFocused) { textState = deleteLine(textState) }
                                .padding(10.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                imageVector = Icons.AutoMirrored.Outlined.Backspace,
                                contentDescription = "Delete Line",
                                modifier = Modifier.size(30.dp).padding(end = 1.dp),
                                tint = colorResource(id = R.color.title_color)
                            )
                        }
                        Box(
                            modifier = Modifier
                                .size(buttonSize)
                                .graphicsLayer(alpha = buttonOpacity)
                                .clip(RoundedCornerShape(cornerRadius))
                                .background(colorResource(id = R.color.primary_color))
                                .clickable { showDatePicker = true }
                                .padding(10.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                imageVector = Icons.Outlined.CalendarMonth,
                                contentDescription = "Calendar",
                                modifier = Modifier.size(iconSize),
                                tint = colorResource(id = R.color.title_color)
                            )
                        }
                        Box(
                            modifier = Modifier
                                .size(buttonSize)
                                .graphicsLayer(alpha = buttonOpacity)
                                .clip(RoundedCornerShape(cornerRadius))
                                .background(colorResource(id = R.color.primary_color))
                                .clickable { showCalc = !showCalc }
                                .padding(10.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                imageVector = Icons.Default.Percent,
                                contentDescription = "Calculator",
                                modifier = Modifier.size(iconSize),
                                tint = colorResource(id = R.color.title_color)
                            )
                        }
                    }
                }
                Row(verticalAlignment = Alignment.CenterVertically) {
                    // crypt slides sideways out of / into the menu button
                    AnimatedVisibility(
                        visible = fabExpanded,
                        enter = expandHorizontally(
                            animationSpec = tween(220),
                            expandFrom = Alignment.End
                        ) + slideInHorizontally(
                            animationSpec = tween(220),
                            initialOffsetX = { it }
                        ) + fadeIn(tween(180)),
                        exit = shrinkHorizontally(
                            animationSpec = tween(200),
                            shrinkTowards = Alignment.End
                        ) + slideOutHorizontally(
                            animationSpec = tween(200),
                            targetOffsetX = { it }
                        ) + fadeOut(tween(140))
                    ) {
                        Box(
                            modifier = Modifier
                                .padding(end = gapBetweenButtons)
                                .size(buttonSize)
                                .graphicsLayer(alpha = buttonOpacity)
                                .clip(RoundedCornerShape(cornerRadius))
                                .background(colorResource(id = R.color.primary_color))
                                .focusProperties { canFocus = false }
                                .clickable(enabled = quickCryptEnabled) {
                                    if (isEncrypted) {
                                        showCalc = false
                                        isDecryptMode = true
                                        showEncrypt = true
                                    } else {
                                        val encrypted = WedgeSecurity.encrypt(textState.text, bkPhrase)
                                        focusManager.clearFocus()
                                        textState = textState.copy(
                                            text = encrypted,
                                            selection = TextRange(0)
                                        )
                                    }
                                }
                                .padding(10.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            Icon(
                                imageVector = if (isEncrypted) Icons.Outlined.LockOpen else Icons.Outlined.Lock,
                                contentDescription = if (isEncrypted) "Decrypt" else "Encrypt",
                                modifier = Modifier.size(iconSize),
                                tint = colorResource(id = R.color.title_color)
                                    .copy(alpha = if (quickCryptEnabled) 1f else 0.3f)
                            )
                        }
                    }
                    Box(
                        modifier = Modifier
                            .size(buttonSize)
                            .graphicsLayer(alpha = buttonOpacity)
                            .clip(RoundedCornerShape(cornerRadius))
                            .background(colorResource(id = R.color.primary_color))
                            .focusProperties { canFocus = false }
                            .clickable { fabExpanded = !fabExpanded }
                            .padding(10.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Icon(
                            imageVector = Icons.Default.Create,
                            contentDescription = if (fabExpanded) "Collapse" else "Expand",
                            modifier = Modifier.size(iconSize),
                            tint = colorResource(id = R.color.title_color)
                        )
                    }
                }
            }
        }
    }
    VersionDialog(showVersionDialog, { showVersionDialog = false }, uriHandler)
}

private fun appendStrike(state: TextFieldValue): TextFieldValue {
    val txt = state.text
    val cursor = state.selection.start.coerceIn(0, txt.length)
    val lineStart = txt.lastIndexOf('\n', cursor - 1) + 1
    val lineEnd = txt.indexOf('\n', cursor).let { if (it == -1) txt.length else it }
    val line = txt.substring(lineStart, lineEnd)

    if (line.isBlank() || line.trimEnd().endsWith('~')) return state

    val insertAt = lineStart + line.trimEnd().length
    return state.copy(
        text = txt.substring(0, insertAt) + '~' + txt.substring(insertAt),
        selection = TextRange(if (cursor >= insertAt) cursor + 1 else cursor)
    )
}

private fun deleteLine(state: TextFieldValue): TextFieldValue {
    val txt = state.text
    val sel = state.selection
    val lineStart = txt.lastIndexOf('\n', sel.min - 1).let { if (it == -1) 0 else it + 1 }
    val lineEnd = txt.indexOf('\n', sel.max)
    val (newText, newCursor) = if (lineEnd == -1) {
        val removeStart = if (lineStart > 0) lineStart - 1 else 0
        txt.removeRange(removeStart, txt.length) to removeStart
    } else {
        txt.removeRange(lineStart, lineEnd + 1) to lineStart
    }
    return state.copy(
        text = newText,
        selection = TextRange(newCursor.coerceIn(0, newText.length))
    )
}

private fun urlAt(text: String, offset: Int): String? =
    WedgeRegex.URL.findAll(text).firstOrNull { offset in it.range }?.value
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.00278
256,875 (+129)