package com.peachpasswords.ui.vault
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.collectAsState
import androidx.hilt.navigation.compose.hiltViewModel
import com.peachpasswords.haptics.PeachHaptics
import com.peachpasswords.ui.components.PeachEmptyOrEndState
import com.peachpasswords.ui.PeachNav
import com.peachpasswords.ui.components.SecureFlagEffect
import com.peachpasswords.ui.theme.PeachSpacing
import com.peachpasswords.ui.theme.PeachRadius
import com.peachpasswords.vault.VaultEntry
import com.peachpasswords.ui.theme.LocalPeachAccent
import com.peachpasswords.ui.theme.peachColors
import com.peachpasswords.ui.theme.PeachType
import com.peachpasswords.ui.theme.ShellBackground
/**
* E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U
* formatting toolbar, a full-width Inter body, and an 800ms-debounced auto-save
* with a "saved." pulse. FLAG_SECURE blocks screenshots while open (same as
* EntryDetail).
*
* The body opens rendered (see NoteHtml.kt) and switches to the editor on tap,
* because a note written in the browser is HTML and Compose has no text field
* that can edit styled text without dropping the styles. Formatting typed here
* is saved back as the browser's own tag vocabulary.
*/
@Composable
fun NotesModeScreen(
onBack: () -> Unit,
onNavigate: (PeachNav) -> Unit,
vm: NotesModeViewModel = hiltViewModel(),
) {
val entry by vm.entry.collectAsState()
val notes by vm.notes.collectAsState()
val activeNoteId by vm.activeNoteId.collectAsState()
val isMultiSelect by vm.isMultiSelectMode.collectAsState()
val selectedIds by vm.selectedIds.collectAsState()
val savedPulse by vm.savedPulse.collectAsState()
val accent = LocalPeachAccent.current
SecureFlagEffect()
// "saved." indicator fades in on each save pulse, holds, then fades out.
var showSaved by remember { mutableStateOf(false) }
LaunchedEffect(savedPulse) {
if (savedPulse == 0L) return@LaunchedEffect
showSaved = true
kotlinx.coroutines.delay(1300)
showSaved = false
}
val savedAlpha by animateFloatAsState(
targetValue = if (showSaved) 1f else 0f,
animationSpec = tween(300),
label = "saved-fade",
)
if (activeNoteId == null) {
NotesToc(
notes = notes,
onBack = onBack,
onOpenNote = vm::openNote,
onNewNote = vm::createNote,
isMultiSelect = isMultiSelect,
selectedIds = selectedIds,
onEnterMultiSelect = vm::enterMultiSelect,
onToggleSelected = vm::toggleSelected,
onDeleteSelected = vm::batchDelete,
onCancelMultiSelect = vm::exitMultiSelect,
onNavigate = onNavigate,
onLock = vm::lock,
)
return
}
val e = entry
val stored = e?.notes.orEmpty()
var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }
var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }
// A note stays plain text until the user asks for formatting, so typing
// "- eggs" into a shopping list never turns it into a
behind their back.
var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }
var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }
fun finishNote(onDone: () -> Unit) = vm.finishNote(title, noteStorageText(body.text, isHtml), onDone)
BackHandler { finishNote(vm::showToc) }
ShellBackground {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(vertical = PeachSpacing.md),
) {
PeachVaultIdentity(
"notes",
Modifier.padding(horizontal = PeachSpacing.lg),
) { finishNote { onNavigate(PeachNav.VaultList) } }
Column(
Modifier.weight(1f).fillMaxWidth()
.padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.xs)
.clip(RoundedCornerShape(PeachRadius.StackCard))
.background(peachColors.SurfaceCard)
.border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),
) {
FieldShell("title", stacked = true, divider = true) {
Box(Modifier.weight(1f)) {
if (title.isEmpty()) {
Text(
"untitled note",
color = peachColors.T2,
style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),
)
}
BasicTextField(
value = title,
onValueChange = { title = it; vm.onTitleChanged(it) },
singleLine = true,
textStyle = TextStyle(
color = peachColors.T0,
fontFamily = PeachType.Display,
fontStyle = FontStyle.Italic,
fontSize = 22.sp,
),
cursorBrush = SolidColor(accent),
modifier = Modifier.fillMaxWidth(),
)
}
Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))
Icon(
Icons.Outlined.DeleteOutline,
contentDescription = "delete note",
tint = peachColors.T2,
modifier = Modifier.size(36.dp).clip(CircleShape)
.clickable { vm.delete {} }
.padding(8.dp),
)
}
if (e == null) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)
}
return@Column
}
// Formatting toolbar: every action writes the markup that saves back
// as the browser's own tags, so formatting survives a phone edit.
if (editing) {
Row(
Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
fun format(next: TextFieldValue) {
body = next
isHtml = true
vm.onContentChanged(noteStorageText(next.text, isHtml = true))
}
FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }
FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }
FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }
FormatButton("S", strikeThrough = true) { format(wrapSelection(body, "~~")) }
Box(Modifier.width(1.dp).height(24.dp).background(peachColors.T3))
FormatButton("H₂") { format(applyLinePrefix(body, "## ")) }
FormatButton("❞") { format(applyLinePrefix(body, "> ")) }
FormatButton("•") { format(applyLinePrefix(body, "- ")) }
FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }
FormatButton("—") { format(applyLinePrefix(body, "---")) }
}
Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))
}
// Content area is the final layer of the single note card.
Box(Modifier.weight(1f).fillMaxWidth().padding(PeachSpacing.lg)) {
if (body.text.isEmpty()) {
Text(
"Start writing…",
color = peachColors.T2,
style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),
)
}
if (editing) {
BasicTextField(
value = body,
onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },
textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(accent),
modifier = Modifier.fillMaxSize(),
)
} else {
Text(
noteRendered(body.text, isHtml),
color = peachColors.T0,
style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),
modifier = Modifier.fillMaxSize().clickable { editing = true },
)
}
}
}
PeachDeckBottomBar(
onSearch = { finishNote(vm::showToc) },
onAdd = { finishNote(vm::createNote) },
onNotes = { finishNote(onBack) },
onSettings = { finishNote { onNavigate(PeachNav.Settings) } },
onLock = { finishNote(vm::lock) },
onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },
notesSelected = true,
)
}
}
}
@Composable
private fun NotesToc(
notes: List,
onBack: () -> Unit,
onOpenNote: (String) -> Unit,
onNewNote: () -> Unit,
isMultiSelect: Boolean,
selectedIds: Set,
onEnterMultiSelect: (String) -> Unit,
onToggleSelected: (String) -> Unit,
onDeleteSelected: () -> Unit,
onCancelMultiSelect: () -> Unit,
onNavigate: (PeachNav) -> Unit,
onLock: () -> Unit,
) {
var searchVisible by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
val filtered = remember(notes, query) { filterNotesForToc(notes, query) }
val accent = LocalPeachAccent.current
BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)
ShellBackground {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(vertical = PeachSpacing.md),
) {
if (isMultiSelect) {
MultiSelectTopBar(
count = selectedIds.size,
onDelete = onDeleteSelected,
onCancel = onCancelMultiSelect,
includeStatusBarPadding = false,
)
} else if (searchVisible) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg),
color = peachColors.S1,
shape = RoundedCornerShape(PeachRadius.StackCard),
border = androidx.compose.foundation.BorderStroke(1.dp, peachColors.T3),
tonalElevation = 1.dp,
) {
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = PeachSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
textStyle = TextStyle(
color = peachColors.T0,
fontFamily = PeachType.Mono,
fontSize = 14.sp,
),
cursorBrush = SolidColor(accent),
modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),
decorationBox = { inner ->
Box {
if (query.isEmpty()) {
Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)
}
inner()
}
},
)
Icon(
Icons.Outlined.Close,
contentDescription = "clear search",
tint = peachColors.T0,
modifier = Modifier.size(40.dp).clip(CircleShape)
.clickable {
query = ""
searchVisible = false
}
.padding(9.dp),
)
}
}
} else PeachVaultIdentity(
"notes",
Modifier.padding(horizontal = PeachSpacing.lg),
) { onNavigate(PeachNav.VaultList) }
Spacer(Modifier.size(PeachSpacing.lg))
LazyColumn(
Modifier.weight(1f).padding(horizontal = PeachSpacing.lg),
verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),
) {
if (filtered.isEmpty()) {
item {
Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {
PeachEmptyOrEndState(
message = if (query.isBlank()) "No notes yet." else "No notes match.",
detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",
)
}
}
} else {
items(filtered, key = { it.id }) { note ->
NoteTocRow(
note = note,
multiSelect = isMultiSelect,
selected = note.id in selectedIds,
onClick = {
if (isMultiSelect) onToggleSelected(note.id)
else onOpenNote(note.id)
},
onLongClick = {
if (isMultiSelect) onToggleSelected(note.id)
else onEnterMultiSelect(note.id)
},
)
}
}
}
if (!isMultiSelect) {
PeachDeckBottomBar(
onSearch = { searchVisible = true },
onAdd = onNewNote,
onNotes = onBack,
onSettings = { onNavigate(PeachNav.Settings) },
onLock = onLock,
onSwipeUp = onBack,
notesSelected = true,
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NoteTocRow(
note: VaultEntry,
multiSelect: Boolean,
selected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val context = LocalContext.current
val accent = LocalPeachAccent.current
Row(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(PeachRadius.StackCard))
.background(if (selected) accent.copy(alpha = 0.14f) else peachColors.SurfaceCard)
.border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))
.combinedClickable(
onClick = onClick,
onLongClickLabel = "Select note",
onLongClick = {
PeachHaptics.onLongPress(context)
onLongClick()
},
)
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
if (multiSelect) MultiSelectCheckbox(checked = selected)
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(
note.title.ifBlank { "Untitled" },
color = peachColors.T0,
style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),
)
val preview = notePreviewLine(note.notes.orEmpty())
Text(
preview.ifBlank { "No body text" },
color = peachColors.T2,
style = PeachType.bodyMono,
maxLines = 2,
)
}
}
}
/**
* Wrap the current selection in [marker] (the inline markup NoteHtml serialises
* back to a tag). Collapsed selections are returned unchanged — there is nothing
* to mark.
*/
internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {
val sel = value.selection
if (sel.collapsed) return value
val text = value.text
val next = text.substring(0, sel.min) + marker +
text.substring(sel.min, sel.max) + marker +
text.substring(sel.max)
return value.copy(
text = next,
selection = TextRange(sel.min, sel.max + 2 * marker.length),
)
}
internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {
val text = value.text
val sel = value.selection
val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }
val endExclusive = if (sel.collapsed) {
text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }
} else {
text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }
}
val block = text.substring(start, endExclusive)
val transformed = block
.split('\n')
.joinToString("\n") { line ->
if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line
}
val nextText = text.replaceRange(start, endExclusive, transformed)
val delta = nextText.length - text.length
return value.copy(
text = nextText,
selection = TextRange(
sel.start.coerceIn(0, nextText.length),
(sel.end + delta).coerceIn(0, nextText.length),
),
)
}
internal fun filterNotesForToc(notes: List, query: String): List {
val q = query.trim().lowercase()
if (q.isBlank()) return notes
return notes.filter { note ->
note.title.lowercase().contains(q) ||
notePlainText(note.notes.orEmpty()).lowercase().contains(q)
}
}
@Composable
private fun FormatButton(
label: String,
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
underline: Boolean = false,
strikeThrough: Boolean = false,
onClick: () -> Unit,
) {
Box(
Modifier
.size(30.dp)
.clip(RoundedCornerShape(6.dp))
.background(peachColors.S2)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = peachColors.T0,
style = TextStyle(
fontFamily = PeachType.Body,
fontSize = 14.sp,
fontWeight = fontWeight,
fontStyle = fontStyle,
textDecoration = when {
underline && strikeThrough -> TextDecoration.combine(
listOf(TextDecoration.Underline, TextDecoration.LineThrough),
)
underline -> TextDecoration.Underline
strikeThrough -> TextDecoration.LineThrough
else -> null
},
),
)
}
}