package com.peachpasswords.ui.vault import android.view.WindowManager 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.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.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.fragment.app.FragmentActivity import androidx.hilt.navigation.compose.hiltViewModel 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 val context = LocalContext.current // FLAG_SECURE only while shown. androidx.compose.runtime.DisposableEffect(Unit) { val window = (context as? FragmentActivity)?.window window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } // "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()) { // Top bar: back · editable title · "saved." indicator Row( Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp), 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 = 4.dp)) { 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 = 8.dp).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.fillMaxSize(), 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(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), 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(horizontal = 16.dp, vertical = 12.dp)) { 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()) { Row( Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (searchVisible) { 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 = 12.dp), 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 { Icon( Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back to vault", tint = peachColors.T0, modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp), ) Text( "notes.", color = peachColors.T0, style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp), modifier = Modifier.weight(1f).padding(horizontal = 8.dp), ) Icon( Icons.Outlined.Search, contentDescription = "search notes", tint = peachColors.T0, modifier = Modifier.size(40.dp).clip(CircleShape) .clickable { searchVisible = true } .padding(9.dp), ) } } LazyColumn( Modifier.weight(1f).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (filtered.isEmpty()) { item { Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) { Text( if (query.isBlank()) "No notes yet.\nStart writing." else "No notes match.", color = peachColors.T1, style = PeachType.bodyMono, ) } } } else { items(filtered, key = { it.id }) { note -> NoteTocRow(note = note, onClick = { onOpenNote(note.id) }) } } } Row( Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp) .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, ), ) } }