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)
}