1package com.peachpasswords.ui.vault23import androidx.activity.compose.BackHandler4import androidx.compose.animation.core.animateFloatAsState5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.clickable8import androidx.compose.foundation.combinedClickable9import androidx.compose.foundation.ExperimentalFoundationApi10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.Spacer15import androidx.compose.foundation.layout.fillMaxSize16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.height18import androidx.compose.foundation.layout.padding19import androidx.compose.foundation.layout.size20import androidx.compose.foundation.layout.statusBarsPadding21import androidx.compose.foundation.layout.width22import androidx.compose.foundation.lazy.LazyColumn23import androidx.compose.foundation.lazy.items24import androidx.compose.foundation.shape.CircleShape25import androidx.compose.foundation.shape.RoundedCornerShape26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.material.icons.Icons28import androidx.compose.material.icons.automirrored.rounded.ArrowBack29import androidx.compose.material.icons.outlined.Add30import androidx.compose.material.icons.outlined.Close31import androidx.compose.material.icons.outlined.DeleteOutline32import androidx.compose.material.icons.outlined.Search33import androidx.compose.material3.Icon34import androidx.compose.material3.Surface35import androidx.compose.material3.Text36import androidx.compose.runtime.Composable37import androidx.compose.runtime.LaunchedEffect38import androidx.compose.runtime.getValue39import androidx.compose.runtime.mutableStateOf40import androidx.compose.runtime.remember41import androidx.compose.runtime.setValue42import androidx.compose.ui.Alignment43import androidx.compose.ui.Modifier44import androidx.compose.ui.draw.alpha45import androidx.compose.ui.draw.clip46import androidx.compose.ui.graphics.SolidColor47import androidx.compose.ui.platform.LocalContext48import androidx.compose.ui.text.TextStyle49import androidx.compose.ui.text.TextRange50import androidx.compose.ui.text.font.FontStyle51import androidx.compose.ui.text.font.FontWeight52import androidx.compose.ui.text.input.TextFieldValue53import androidx.compose.ui.text.style.TextDecoration54import androidx.compose.ui.unit.dp55import androidx.compose.ui.unit.sp56import androidx.compose.runtime.collectAsState57import androidx.hilt.navigation.compose.hiltViewModel58import com.peachpasswords.haptics.PeachHaptics59import com.peachpasswords.ui.components.PeachEmptyOrEndState60import com.peachpasswords.ui.PeachNav61import com.peachpasswords.ui.components.SecureFlagEffect62import com.peachpasswords.ui.theme.PeachSpacing63import com.peachpasswords.vault.VaultEntry64import com.peachpasswords.ui.theme.LocalPeachAccent65import com.peachpasswords.ui.theme.peachColors66import com.peachpasswords.ui.theme.PeachType67import com.peachpasswords.ui.theme.ShellBackground68697071727374757677787980@Composable81fun NotesModeScreen(82 onBack: () -> Unit,83 onNavigate: (PeachNav) -> Unit,84 vm: NotesModeViewModel = hiltViewModel(),85) {86 val entry by vm.entry.collectAsState()87 val notes by vm.notes.collectAsState()88 val activeNoteId by vm.activeNoteId.collectAsState()89 val isMultiSelect by vm.isMultiSelectMode.collectAsState()90 val selectedIds by vm.selectedIds.collectAsState()91 val savedPulse by vm.savedPulse.collectAsState()92 val accent = LocalPeachAccent.current9394 SecureFlagEffect()9596 97 var showSaved by remember { mutableStateOf(false) }98 LaunchedEffect(savedPulse) {99 if (savedPulse == 0L) return@LaunchedEffect100 showSaved = true101 kotlinx.coroutines.delay(1300)102 showSaved = false103 }104 val savedAlpha by animateFloatAsState(105 targetValue = if (showSaved) 1f else 0f,106 animationSpec = tween(300),107 label = "saved-fade",108 )109110 if (activeNoteId == null) {111 NotesToc(112 notes = notes,113 onBack = onBack,114 onOpenNote = vm::openNote,115 onNewNote = vm::createNote,116 isMultiSelect = isMultiSelect,117 selectedIds = selectedIds,118 onEnterMultiSelect = vm::enterMultiSelect,119 onToggleSelected = vm::toggleSelected,120 onDeleteSelected = vm::batchDelete,121 onCancelMultiSelect = vm::exitMultiSelect,122 onNavigate = onNavigate,123 onLock = vm::lock,124 )125 return126 }127128 val e = entry129 val stored = e?.notes.orEmpty()130 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }131 var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }132 133 134 var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }135 var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }136 fun finishNote(onDone: () -> Unit) = vm.finishNote(title, noteStorageText(body.text, isHtml), onDone)137 BackHandler { finishNote(vm::showToc) }138139 ShellBackground {140 Column(141 Modifier142 .fillMaxSize()143 .statusBarsPadding()144 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),145 ) {146 PeachVaultIdentity("notes") { finishNote { onNavigate(PeachNav.VaultList) } }147 FieldShell("title") {148 Box(Modifier.weight(1f)) {149 if (title.isEmpty()) {150 Text(151 "untitled note",152 color = peachColors.T2,153 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),154 )155 }156 BasicTextField(157 value = title,158 onValueChange = { title = it; vm.onTitleChanged(it) },159 singleLine = true,160 textStyle = TextStyle(161 color = peachColors.T0,162 fontFamily = PeachType.Display,163 fontStyle = FontStyle.Italic,164 fontSize = 22.sp,165 ),166 cursorBrush = SolidColor(accent),167 modifier = Modifier.fillMaxWidth(),168 )169 }170 Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))171 Icon(172 Icons.Outlined.DeleteOutline,173 contentDescription = "delete note",174 tint = peachColors.T2,175 modifier = Modifier.size(36.dp).clip(CircleShape)176 .clickable { vm.delete {} }177 .padding(8.dp),178 )179 }180181 if (e == null) {182 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {183 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)184 }185 return@Column186 }187188 189 190 if (editing) {191 Row(192 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),193 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),194 verticalAlignment = Alignment.CenterVertically,195 ) {196 fun format(next: TextFieldValue) {197 body = next198 isHtml = true199 vm.onContentChanged(noteStorageText(next.text, isHtml = true))200 }201 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }202 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }203 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }204 Spacer(Modifier.width(4.dp))205 FormatButton("H") { format(applyLinePrefix(body, "## ")) }206 FormatButton("•") { format(applyLinePrefix(body, "- ")) }207 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }208 }209 }210211 212 Box(Modifier.weight(1f).fillMaxWidth().padding(vertical = PeachSpacing.sm)) {213 if (body.text.isEmpty()) {214 Text(215 "Start writing…",216 color = peachColors.T2,217 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),218 )219 }220 if (editing) {221 BasicTextField(222 value = body,223 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },224 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),225 cursorBrush = SolidColor(accent),226 modifier = Modifier.fillMaxSize(),227 )228 } else {229 Text(230 noteRendered(body.text, isHtml),231 color = peachColors.T0,232 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),233 modifier = Modifier.fillMaxSize().clickable { editing = true },234 )235 }236 }237 PeachDeckBottomBar(238 onSearch = { finishNote(vm::showToc) },239 onAdd = { finishNote(vm::createNote) },240 onNotes = { finishNote(vm::showToc) },241 onSettings = { finishNote { onNavigate(PeachNav.Settings) } },242 onLock = { finishNote(vm::lock) },243 onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },244 notesSelected = true,245 )246 }247 }248}249250@Composable251private fun NotesToc(252 notes: List<VaultEntry>,253 onBack: () -> Unit,254 onOpenNote: (String) -> Unit,255 onNewNote: () -> Unit,256 isMultiSelect: Boolean,257 selectedIds: Set<String>,258 onEnterMultiSelect: (String) -> Unit,259 onToggleSelected: (String) -> Unit,260 onDeleteSelected: () -> Unit,261 onCancelMultiSelect: () -> Unit,262 onNavigate: (PeachNav) -> Unit,263 onLock: () -> Unit,264) {265 var searchVisible by remember { mutableStateOf(false) }266 var query by remember { mutableStateOf("") }267 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }268 val accent = LocalPeachAccent.current269 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)270271 ShellBackground {272 Column(273 Modifier274 .fillMaxSize()275 .statusBarsPadding()276 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),277 ) {278 if (isMultiSelect) {279 MultiSelectTopBar(280 count = selectedIds.size,281 onDelete = onDeleteSelected,282 onCancel = onCancelMultiSelect,283 includeStatusBarPadding = false,284 )285 } else if (searchVisible) {286 Surface(287 modifier = Modifier.fillMaxWidth(),288 color = peachColors.S1,289 shape = RoundedCornerShape(PeachSpacing.lg),290 tonalElevation = 1.dp,291 ) {292 Row(293 Modifier294 .fillMaxWidth()295 .height(56.dp)296 .padding(horizontal = PeachSpacing.sm),297 verticalAlignment = Alignment.CenterVertically,298 ) {299 BasicTextField(300 value = query,301 onValueChange = { query = it },302 singleLine = true,303 textStyle = TextStyle(304 color = peachColors.T0,305 fontFamily = PeachType.Mono,306 fontSize = 14.sp,307 ),308 cursorBrush = SolidColor(accent),309 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),310 decorationBox = { inner ->311 Box {312 if (query.isEmpty()) {313 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)314 }315 inner()316 }317 },318 )319 Icon(320 Icons.Outlined.Close,321 contentDescription = "clear search",322 tint = peachColors.T0,323 modifier = Modifier.size(40.dp).clip(CircleShape)324 .clickable {325 query = ""326 searchVisible = false327 }328 .padding(9.dp),329 )330 }331 }332 } else PeachVaultIdentity("notes") { onNavigate(PeachNav.VaultList) }333 Spacer(Modifier.size(PeachSpacing.lg))334335 LazyColumn(336 Modifier.weight(1f),337 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),338 ) {339 if (filtered.isEmpty()) {340 item {341 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {342 PeachEmptyOrEndState(343 message = if (query.isBlank()) "No notes yet." else "No notes match.",344 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",345 )346 }347 }348 } else {349 items(filtered, key = { it.id }) { note ->350 NoteTocRow(351 note = note,352 multiSelect = isMultiSelect,353 selected = note.id in selectedIds,354 onClick = {355 if (isMultiSelect) onToggleSelected(note.id)356 else onOpenNote(note.id)357 },358 onLongClick = {359 if (isMultiSelect) onToggleSelected(note.id)360 else onEnterMultiSelect(note.id)361 },362 )363 }364 }365 }366367 if (!isMultiSelect) {368 PeachDeckBottomBar(369 onSearch = { searchVisible = true },370 onAdd = onNewNote,371 onNotes = {},372 onSettings = { onNavigate(PeachNav.Settings) },373 onLock = onLock,374 onSwipeUp = onBack,375 notesSelected = true,376 )377 }378379 }380 }381}382383@OptIn(ExperimentalFoundationApi::class)384@Composable385private fun NoteTocRow(386 note: VaultEntry,387 multiSelect: Boolean,388 selected: Boolean,389 onClick: () -> Unit,390 onLongClick: () -> Unit,391) {392 val context = LocalContext.current393 val accent = LocalPeachAccent.current394 Row(395 Modifier.fillMaxWidth()396 .clip(RoundedCornerShape(8.dp))397 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)398 .combinedClickable(399 onClick = onClick,400 onLongClickLabel = "Select note",401 onLongClick = {402 PeachHaptics.onLongPress(context)403 onLongClick()404 },405 )406 .padding(horizontal = 14.dp, vertical = 12.dp),407 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),408 verticalAlignment = Alignment.CenterVertically,409 ) {410 if (multiSelect) MultiSelectCheckbox(checked = selected)411 Column(412 Modifier.weight(1f),413 verticalArrangement = Arrangement.spacedBy(5.dp),414 ) {415 Text(416 note.title.ifBlank { "Untitled" },417 color = peachColors.T0,418 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),419 )420 val preview = notePreviewLine(note.notes.orEmpty())421 Text(422 preview.ifBlank { "No body text" },423 color = peachColors.T2,424 style = PeachType.bodyMono,425 maxLines = 2,426 )427 }428 }429}430431432433434435436internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {437 val sel = value.selection438 if (sel.collapsed) return value439 val text = value.text440 val next = text.substring(0, sel.min) + marker +441 text.substring(sel.min, sel.max) + marker +442 text.substring(sel.max)443 return value.copy(444 text = next,445 selection = TextRange(sel.min, sel.max + 2 * marker.length),446 )447}448449internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {450 val text = value.text451 val sel = value.selection452 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }453 val endExclusive = if (sel.collapsed) {454 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }455 } else {456 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }457 }458 val block = text.substring(start, endExclusive)459 val transformed = block460 .split('\n')461 .joinToString("\n") { line ->462 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line463 }464 val nextText = text.replaceRange(start, endExclusive, transformed)465 val delta = nextText.length - text.length466 return value.copy(467 text = nextText,468 selection = TextRange(469 sel.start.coerceIn(0, nextText.length),470 (sel.end + delta).coerceIn(0, nextText.length),471 ),472 )473}474475internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {476 val q = query.trim().lowercase()477 if (q.isBlank()) return notes478 return notes.filter { note ->479 note.title.lowercase().contains(q) ||480 notePlainText(note.notes.orEmpty()).lowercase().contains(q)481 }482}483484@Composable485private fun FormatButton(486 label: String,487 fontWeight: FontWeight? = null,488 fontStyle: FontStyle? = null,489 underline: Boolean = false,490 onClick: () -> Unit,491) {492 Box(493 Modifier494 .size(30.dp)495 .clip(RoundedCornerShape(6.dp))496 .background(peachColors.S2)497 .clickable(onClick = onClick),498 contentAlignment = Alignment.Center,499 ) {500 Text(501 label,502 color = peachColors.T0,503 style = TextStyle(504 fontFamily = PeachType.Body,505 fontSize = 14.sp,506 fontWeight = fontWeight,507 fontStyle = fontStyle,508 textDecoration = if (underline) TextDecoration.Underline else null,509 ),510 )511 }512}