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