package com.peachpasswords.ui.vault import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString 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.text.withStyle 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.ui.components.PeachEmptyOrEndState import com.peachpasswords.ui.components.PeachTopBar import com.peachpasswords.ui.components.SecureFlagEffect import com.peachpasswords.ui.theme.PeachSpacing 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 that applies [SpanStyle] to the current selection, a * full-width Inter body field, and an 800ms-debounced auto-save with a "saved." * pulse. FLAG_SECURE blocks screenshots while open (same as EntryDetail). * * Span support is intentionally basic (no markdown/persistence of styles): the * toolbar styles the live [AnnotatedString] selection; only the plain text is * persisted via the ViewModel. */ @Composable fun NotesModeScreen( onBack: () -> Unit, vm: NotesModeViewModel = hiltViewModel(), ) { val entry by vm.entry.collectAsState() val notes by vm.notes.collectAsState() val activeNoteId by vm.activeNoteId.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, ) return } val e = entry var title by remember(e?.id) { mutableStateOf(e?.title ?: "") } var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) } ShellBackground { Column( Modifier .fillMaxSize() .statusBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { Surface( modifier = Modifier.fillMaxWidth(), color = peachColors.S1, contentColor = peachColors.T0, shape = RoundedCornerShape(PeachSpacing.lg), tonalElevation = 1.dp, ) { Row( Modifier .fillMaxWidth() .height(56.dp) .padding(horizontal = PeachSpacing.sm), verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0, modifier = Modifier.size(40.dp).clip(CircleShape) .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp), ) Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) { 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 = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp), modifier = Modifier.padding(end = PeachSpacing.sm).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: browser-compatible inline and block actions. Row( Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm), horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs), verticalAlignment = Alignment.CenterVertically, ) { FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) } FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) } FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) } Spacer(Modifier.width(4.dp)) FormatButton("H") { body = applyLinePrefix(body, "## ") } FormatButton("\"") { body = applyLinePrefix(body, "> ") } FormatButton("•") { body = applyLinePrefix(body, "- ") } FormatButton("1.") { body = applyLinePrefix(body, "1. ") } } // Content area. Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) { if (body.text.isEmpty()) { Text( "Start writing…", color = peachColors.T2, style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp), ) } BasicTextField( value = body, onValueChange = { body = it; vm.onContentChanged(it.text) }, textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp), cursorBrush = SolidColor(accent), modifier = Modifier.fillMaxSize(), ) } } } } @Composable private fun NotesToc( notes: List, onBack: () -> Unit, onOpenNote: (String) -> Unit, onNewNote: () -> Unit, ) { var searchVisible by remember { mutableStateOf(false) } var query by remember { mutableStateOf("") } val filtered = remember(notes, query) { filterNotesForToc(notes, query) } val accent = LocalPeachAccent.current ShellBackground { Column( Modifier .fillMaxSize() .statusBarsPadding() .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md), ) { if (searchVisible) { Surface( modifier = Modifier.fillMaxWidth(), color = peachColors.S1, shape = RoundedCornerShape(PeachSpacing.lg), 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 { PeachTopBar( title = "notes.", leading = { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back to vault", tint = peachColors.T0, modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm)) .clickable(onClick = onBack), ) }, actions = { Icon( Icons.Outlined.Search, contentDescription = "search notes", tint = peachColors.T0, modifier = Modifier.size(28.dp).clip(CircleShape) .clickable { searchVisible = true } .padding(5.dp), ) }, ) } Spacer(Modifier.size(PeachSpacing.lg)) LazyColumn( Modifier.weight(1f), 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, onClick = { onOpenNote(note.id) }) } } } Row( Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md) .clip(RoundedCornerShape(8.dp)) .background(peachColors.S2) .clickable(onClick = onNewNote) .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp)) Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono) } } } } @Composable private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) { Column( Modifier.fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(peachColors.S1) .clickable(onClick = onClick) .padding(horizontal = 14.dp, vertical = 12.dp), 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 = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty() Text( preview.ifBlank { "No body text" }, color = peachColors.T2, style = PeachType.bodyMono, maxLines = 2, ) } } /** * Apply [style] to the current selection of [value], preserving the text and * existing spans. If the selection is collapsed, the value is returned unchanged * (basic span support — styling needs a non-empty selection). */ private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue { val sel = value.selection if (sel.collapsed) return value val start = sel.min val end = sel.max val existing = value.annotatedString val merged: AnnotatedString = buildAnnotatedString { append(existing) addStyle(style, start, end) } return value.copy(annotatedString = merged) } 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) || note.notes.orEmpty().lowercase().contains(q) } } @Composable private fun FormatButton( label: String, fontWeight: FontWeight? = null, fontStyle: FontStyle? = null, underline: 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 = if (underline) TextDecoration.Underline else null, ), ) } }