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.shape.RoundedCornerShape
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.material.icons.outlined.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONObject
private val ACTION_ICON = 32.dp
private val ACTION_GLYPH_URL = 20.dp
private val ACTION_GLYPH_DEL = 22.dp
private val ACTIONS_LEAD = 4.dp
private val COMMENT_INNER = 12.dp
private const val COMMENT_DIM_ALPHA = 0.5f
private const val NUM_ALPHA = 0.6f
private val NUM_GAP = 6.dp
private val COMMENT_ICON = 14.dp
private val COMMENT_ICON_TOP = 13.dp
private val DEL_ACTIVE_BG = Color(0xFF880000)
private val REPLY_ACTIVE_BG = Color(0xFF6E8A80)
private val DEL_BTN_ICON = BTN_ICON + 2.dp
private val DEL_BTN_PAD = (BTN_SIZE - DEL_BTN_ICON) / 2
private val SAVE_ICON = BTN_ICON + 2.dp
private val SAVE_PAD = (BTN_SIZE - SAVE_ICON) / 2
private val SWEEP_ICON = BTN_ICON + 4.dp
private val SWEEP_PAD = (BTN_SIZE - SWEEP_ICON) / 2
private const val DELETE_TIMEOUT_MS = 3_000L
private val LIST_TOP = 20.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditScreen(id: Int, vm: KerfViewModel, onBack: () -> Unit) {
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
var lastSavedText by remember { mutableStateOf("") }
var focusIndex by remember { mutableIntStateOf(-1) }
val dbNote by vm.observeNote(id).collectAsState(null)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val scroll = rememberScrollState()
var showMenu by remember { mutableStateOf(false) }
var showDataDialog by remember { mutableStateOf(false) }
var deleteMode by remember { mutableStateOf(false) }
var deleteTick by remember { mutableIntStateOf(0) }
val isReplyMode = dbNote?.replies != null
val blocks = remember(content) { KerfUtils.parseBlocks(content) }
@Suppress("UNUSED_VARIABLE")
val count = remember(content) { KerfUtils.countNumberedItems(content) }
fun applyBlocks(next: List<KerfUtils.Block>) {
val updated = KerfUtils.buildContent(next)
content = updated
lastSavedText = updated
vm.updateNote(id, title, updated)
}
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(content.toByteArray())
}
} catch (_: Exception) {}
}
}
BackHandler {
vm.updateNoteImmediate(id, title, content)
onBack()
}
LaunchedEffect(dbNote) {
dbNote?.let { note ->
if (lastSavedText.isEmpty() && content.isEmpty()) {
title = note.title
content = note.content
lastSavedText = note.content
} else if (note.content != lastSavedText) {
content = note.content
lastSavedText = note.content
}
}
}
LaunchedEffect(deleteMode, deleteTick) {
if (!deleteMode) return@LaunchedEffect
delay(DELETE_TIMEOUT_MS)
deleteMode = false
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Column {
TopAppBar(
title = { },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = {
Spacer(Modifier.width(BAR_EDGE))
KerfButton(Icons.AutoMirrored.Filled.ArrowBack, "Back") {
vm.updateNoteImmediate(id, title, content)
onBack()
}
Spacer(Modifier.width(BAR_GAP))
KerfBlankButton(Modifier.weight(1f)) {
BasicTextField(
value = title,
onValueChange = {
if (it.length <= 50) {
title = it
vm.updateNote(id, it, content)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onPrimary,
fontSize = KerfDimens.TextSizeTitle,
textAlign = TextAlign.End
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onPrimary),
decorationBox = { inner ->
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
if (title.isEmpty()) {
Text(
text = "Untitled",
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.4f),
fontSize = KerfDimens.TextSizeTitle,
maxLines = 1
)
}
inner()
}
}
)
}
Spacer(Modifier.width(BAR_GAP))
KerfButton(
Icons.Default.Share,
if (isReplyMode) "Share Replies" else "Share",
iconModifier = if (isReplyMode) {
Modifier.offset(x = 1.dp).graphicsLayer { scaleX = -1f }
} else Modifier
) {
if (isReplyMode) {
KerfUtils.shareReplies(context, title, dbNote?.replies)
} else {
val header = "Kerf: ${title.ifBlank { "Untitled" }}\n\n"
shareNote(context, header + content)
vm.updateLastShared(id)
}
}
Spacer(Modifier.width(BAR_GAP))
KerfButton(
if (deleteMode) Icons.Filled.Clear else Icons.Outlined.Clear,
if (deleteMode) "Done" else "Delete Items",
iconSize = DEL_BTN_ICON,
iconPad = DEL_BTN_PAD,
bg = if (deleteMode) DEL_ACTIVE_BG else null
) { deleteMode = !deleteMode }
Spacer(Modifier.width(BAR_GAP))
Box {
KerfMenuTrigger { showMenu = true }
KerfDeploy(
expanded = showMenu,
onDismiss = { showMenu = false },
items = listOf(
KerfDeployItem(
Icons.AutoMirrored.Filled.ReplyAll,
if (isReplyMode) "Collapse Replies" else "Reply",
bg = if (isReplyMode) REPLY_ACTIVE_BG else null
) {
showMenu = false
vm.toggleReplyMode(id, title, content)
},
KerfDeployItem(
if (dbNote?.isPinned == true) Icons.Filled.PushPin else Icons.Outlined.PushPin,
if (dbNote?.isPinned == true) "Unpin" else "Pin"
) { vm.togglePin(id); showMenu = false },
KerfDeployItem(
Icons.Outlined.Save,
"Export",
iconSize = SAVE_ICON,
iconPad = SAVE_PAD
) {
val fileName = "${title.ifBlank { "Untitled" }.replace(" ", "_")}.kerf"
exportLauncher.launch(fileName)
showMenu = false
},
KerfDeployItem(Icons.Outlined.DataArray, "Data") {
showDataDialog = true; showMenu = false
},
KerfDeployItem(
Icons.Outlined.DeleteSweep,
"Clear",
iconSize = SWEEP_ICON,
iconPad = SWEEP_PAD
) {
val clearedContent = KerfUtils.DEFAULT_PREFIX
content = clearedContent
lastSavedText = clearedContent
vm.clearNoteAndSharedDate(id)
showMenu = false
}
)
)
}
Spacer(Modifier.width(BAR_EDGE_ACTIONS))
}
)
}
}
) { p ->
Box(Modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding()) {
Column(Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(top = p.calculateTopPadding() + LIST_TOP - (TOP_BAR_H - BTN_SIZE) / 2, bottom = 100.dp)) {
blocks.forEachIndexed { index, block ->
val num = index + 1
ItemRow(
num = num,
text = block.header,
hasUrl = KerfUtils.UrlRegex.containsMatchIn(block.header),
deleteMode = deleteMode,
readOnly = isReplyMode,
autoFocus = index == focusIndex,
onFocused = { focusIndex = -1 },
onChange = { applyBlocks(blocks.replaced(index, block.copy(header = it))) },
onOpenUrl = {
KerfUtils.UrlRegex.find(block.header)?.value?.let { KerfUtils.openAddress(context, it) }
},
onDelete = {
val original = content
val updated = KerfUtils.deleteAndReindex(original, block.lineIndex)
content = updated
lastSavedText = updated
vm.deleteLineWithReplySync(id, title, original, block.lineIndex)
deleteTick++
}
)
Spacer(Modifier.height(4.dp))
if (!isReplyMode || block.comment.isNotBlank()) {
Spacer(Modifier.height(2.dp))
CommentRow(
num = num,
text = block.comment,
filled = !isReplyMode,
readOnly = isReplyMode,
onChange = { applyBlocks(blocks.replaced(index, block.copy(comment = it))) }
)
Spacer(Modifier.height(6.dp))
}
if (isReplyMode) {
Spacer(Modifier.height(6.dp))
ReplyField(
itemNum = num,
initialText = repliesJson?.optString(num.toString(), "") ?: "",
onUpdate = { vm.updateReply(id, num, it) }
)
}
Spacer(Modifier.height(16.dp))
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = KerfDimens.PaddingLarge),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = {
val next = blocks + KerfUtils.Block("", "", 0)
applyBlocks(next)
focusIndex = next.lastIndex
scope.launch {
delay(100)
scroll.animateScrollTo(scroll.maxValue)
}
},
modifier = Modifier.size(ACTION_ICON)
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Item",
modifier = Modifier.size(ACTION_GLYPH_DEL + 1.dp),
tint = MaterialTheme.colorScheme.onPrimary.copy(0.5f)
)
}
}
}
}
}
if (showDataDialog) {
DataDialog(
createdAt = dbNote?.createdAt,
updatedAt = dbNote?.updatedAt,
lastShared = dbNote?.lastShared,
onDismiss = { showDataDialog = false }
)
}
}
@Composable
fun LineActions(modifier: Modifier, hasUrl: Boolean, deleteMode: Boolean, onOpenUrl: () -> Unit, onDelete: () -> Unit) {
val active = deleteMode || hasUrl
Box(
modifier
.size(ACTION_ICON)
.clip(RoundedCornerShape(ROUND_END))
.then(if (active) Modifier.clickable(onClick = if (deleteMode) onDelete else onOpenUrl) else Modifier),
contentAlignment = Alignment.Center
) {
if (deleteMode) {
Icon(Icons.Default.Clear, null, modifier = Modifier.size(ACTION_GLYPH_DEL), tint = MaterialTheme.colorScheme.onPrimary.copy(0.5f))
} else if (hasUrl) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, null, modifier = Modifier.size(ACTION_GLYPH_URL), tint = MaterialTheme.colorScheme.onPrimary.copy(0.5f))
}
}
}
private fun <T> List<T>.replaced(index: Int, value: T) = toMutableList().also { it[index] = value }
@Composable
private fun Modifier.backspaceGuard(): Modifier {
var last by remember { mutableLongStateOf(0L) }
return onPreviewKeyEvent { event ->
if (event.key == Key.Backspace && event.type == KeyEventType.KeyDown) {
val now = System.currentTimeMillis()
if (now - last < 70) true else { last = now; false }
} else false
}
}
@Composable
private fun ItemRow(
num: Int,
text: String,
hasUrl: Boolean,
deleteMode: Boolean,
readOnly: Boolean,
autoFocus: Boolean,
onFocused: () -> Unit,
onChange: (String) -> Unit,
onOpenUrl: () -> Unit,
onDelete: () -> Unit
) {
var value by remember { mutableStateOf(TextFieldValue(text, TextRange(text.length))) }
val focus = remember { FocusRequester() }
LaunchedEffect(text) {
if (text != value.text.trim()) value = TextFieldValue(text, TextRange(text.length))
}
LaunchedEffect(autoFocus) {
if (!autoFocus) return@LaunchedEffect
withFrameNanos { }
runCatching { focus.requestFocus() }
onFocused()
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = KerfDimens.PaddingLarge),
verticalAlignment = Alignment.Top
) {
Text(
text = "$num.",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = NUM_ALPHA),
fontSize = KerfDimens.TextSizeLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 6.dp, end = NUM_GAP)
)
BasicTextField(
value = value,
onValueChange = { new ->
val clean = new.text.replace("\n", "")
value = if (clean == new.text) new else new.copy(text = clean, selection = TextRange(clean.length))
onChange(clean)
},
readOnly = readOnly,
modifier = Modifier
.weight(1f)
.padding(top = 6.dp)
.focusRequester(focus)
.backspaceGuard(),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineBreak = LineBreak.Paragraph,
fontSize = KerfDimens.TextSizeLarge,
lineHeight = 24.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false)
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
LineActions(Modifier.padding(start = ACTIONS_LEAD), hasUrl, deleteMode, onOpenUrl, onDelete)
}
}
@Composable
private fun CommentRow(num: Int, text: String, filled: Boolean, readOnly: Boolean, onChange: (String) -> Unit) {
var value by remember { mutableStateOf(TextFieldValue(text, TextRange(text.length))) }
val textAlpha = if (filled) 1f else 0.7f
val measurer = rememberTextMeasurer()
val numW = with(LocalDensity.current) {
measurer.measure("$num.", LocalTextStyle.current.copy(fontSize = KerfDimens.TextSizeLarge, fontWeight = FontWeight.Bold)).size.width.toDp()
}
LaunchedEffect(text) {
if (text != value.text.trimEnd('\n')) value = TextFieldValue(text, TextRange(text.length))
}
Row(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = if (filled) 1f else COMMENT_DIM_ALPHA)),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.padding(start = KerfDimens.PaddingLarge, top = COMMENT_ICON_TOP)
.width(numW + NUM_GAP),
contentAlignment = Alignment.TopStart
) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
null,
modifier = Modifier.size(COMMENT_ICON),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = NUM_ALPHA * textAlpha)
)
}
BasicTextField(
value = value,
onValueChange = { value = it; onChange(it.text) },
readOnly = readOnly,
modifier = Modifier
.weight(1f)
.padding(
end = COMMENT_INNER,
top = 8.dp,
bottom = 8.dp
)
.backspaceGuard(),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = textAlpha),
lineBreak = LineBreak.Paragraph,
fontSize = KerfDimens.TextSizeLarge,
lineHeight = 24.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false)
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
}
}
@Composable
fun ReplyField(itemNum: Int, initialText: String, onUpdate: (String) -> Unit) {
var textValue by remember(itemNum) {
mutableStateOf(TextFieldValue(initialText.ifEmpty { "$itemNum. " }))
}
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)
.backspaceGuard(),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = KerfDimens.TextSizeLarge,
lineHeight = 24.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false)
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
}
}