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.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.combinedClickable10import androidx.compose.foundation.ExperimentalFoundationApi11import androidx.compose.foundation.layout.Arrangement12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.Column14import androidx.compose.foundation.layout.Row15import androidx.compose.foundation.layout.Spacer16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.size21import androidx.compose.foundation.layout.statusBarsPadding22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.lazy.LazyColumn24import androidx.compose.foundation.lazy.items25import androidx.compose.foundation.shape.CircleShape26import androidx.compose.foundation.shape.RoundedCornerShape27import androidx.compose.foundation.text.BasicTextField28import androidx.compose.material.icons.Icons29import androidx.compose.material.icons.automirrored.rounded.ArrowBack30import androidx.compose.material.icons.outlined.Add31import androidx.compose.material.icons.outlined.Close32import androidx.compose.material.icons.outlined.DeleteOutline33import androidx.compose.material.icons.outlined.Search34import androidx.compose.material3.Icon35import androidx.compose.material3.Surface36import androidx.compose.material3.Text37import androidx.compose.runtime.Composable38import androidx.compose.runtime.LaunchedEffect39import androidx.compose.runtime.getValue40import androidx.compose.runtime.mutableStateOf41import androidx.compose.runtime.remember42import androidx.compose.runtime.setValue43import androidx.compose.ui.Alignment44import androidx.compose.ui.Modifier45import androidx.compose.ui.draw.alpha46import androidx.compose.ui.draw.clip47import androidx.compose.ui.graphics.SolidColor48import androidx.compose.ui.platform.LocalContext49import androidx.compose.ui.text.TextStyle50import androidx.compose.ui.text.TextRange51import androidx.compose.ui.text.font.FontStyle52import androidx.compose.ui.text.font.FontWeight53import androidx.compose.ui.text.input.TextFieldValue54import androidx.compose.ui.text.style.TextDecoration55import androidx.compose.ui.unit.dp56import androidx.compose.ui.unit.sp57import androidx.compose.runtime.collectAsState58import androidx.hilt.navigation.compose.hiltViewModel59import com.peachpasswords.haptics.PeachHaptics60import com.peachpasswords.ui.components.PeachEmptyOrEndState61import com.peachpasswords.ui.PeachNav62import com.peachpasswords.ui.components.SecureFlagEffect63import com.peachpasswords.ui.theme.PeachSpacing64import com.peachpasswords.ui.theme.PeachRadius65import com.peachpasswords.vault.VaultEntry66import com.peachpasswords.ui.theme.LocalPeachAccent67import com.peachpasswords.ui.theme.peachColors68import com.peachpasswords.ui.theme.PeachType69import com.peachpasswords.ui.theme.ShellBackground70717273747576777879808182@Composable83fun NotesModeScreen(84 onBack: () -> Unit,85 onNavigate: (PeachNav) -> Unit,86 vm: NotesModeViewModel = hiltViewModel(),87) {88 val entry by vm.entry.collectAsState()89 val notes by vm.notes.collectAsState()90 val activeNoteId by vm.activeNoteId.collectAsState()91 val isMultiSelect by vm.isMultiSelectMode.collectAsState()92 val selectedIds by vm.selectedIds.collectAsState()93 val savedPulse by vm.savedPulse.collectAsState()94 val accent = LocalPeachAccent.current9596 SecureFlagEffect()9798 99 var showSaved by remember { mutableStateOf(false) }100 LaunchedEffect(savedPulse) {101 if (savedPulse == 0L) return@LaunchedEffect102 showSaved = true103 kotlinx.coroutines.delay(1300)104 showSaved = false105 }106 val savedAlpha by animateFloatAsState(107 targetValue = if (showSaved) 1f else 0f,108 animationSpec = tween(300),109 label = "saved-fade",110 )111112 if (activeNoteId == null) {113 NotesToc(114 notes = notes,115 onBack = onBack,116 onOpenNote = vm::openNote,117 onNewNote = vm::createNote,118 isMultiSelect = isMultiSelect,119 selectedIds = selectedIds,120 onEnterMultiSelect = vm::enterMultiSelect,121 onToggleSelected = vm::toggleSelected,122 onDeleteSelected = vm::batchDelete,123 onCancelMultiSelect = vm::exitMultiSelect,124 onNavigate = onNavigate,125 onLock = vm::lock,126 )127 return128 }129130 val e = entry131 val stored = e?.notes.orEmpty()132 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }133 var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }134 135 136 var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }137 var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }138 fun finishNote(onDone: () -> Unit) = vm.finishNote(title, noteStorageText(body.text, isHtml), onDone)139 BackHandler { finishNote(vm::showToc) }140141 ShellBackground {142 Column(143 Modifier144 .fillMaxSize()145 .statusBarsPadding()146 .padding(vertical = PeachSpacing.md),147 ) {148 PeachVaultIdentity(149 "notes",150 Modifier.padding(horizontal = PeachSpacing.lg),151 ) { finishNote { onNavigate(PeachNav.VaultList) } }152 Column(153 Modifier.weight(1f).fillMaxWidth()154 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.xs)155 .clip(RoundedCornerShape(PeachRadius.StackCard))156 .background(peachColors.SurfaceCard)157 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),158 ) {159 FieldShell("title", stacked = true, divider = true) {160 Box(Modifier.weight(1f)) {161 if (title.isEmpty()) {162 Text(163 "untitled note",164 color = peachColors.T2,165 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),166 )167 }168 BasicTextField(169 value = title,170 onValueChange = { title = it; vm.onTitleChanged(it) },171 singleLine = true,172 textStyle = TextStyle(173 color = peachColors.T0,174 fontFamily = PeachType.Display,175 fontStyle = FontStyle.Italic,176 fontSize = 22.sp,177 ),178 cursorBrush = SolidColor(accent),179 modifier = Modifier.fillMaxWidth(),180 )181 }182 Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))183 Icon(184 Icons.Outlined.DeleteOutline,185 contentDescription = "delete note",186 tint = peachColors.T2,187 modifier = Modifier.size(36.dp).clip(CircleShape)188 .clickable { vm.delete {} }189 .padding(8.dp),190 )191 }192193 if (e == null) {194 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {195 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)196 }197 return@Column198 }199200 201 202 if (editing) {203 Row(204 Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),205 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),206 verticalAlignment = Alignment.CenterVertically,207 ) {208 fun format(next: TextFieldValue) {209 body = next210 isHtml = true211 vm.onContentChanged(noteStorageText(next.text, isHtml = true))212 }213 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }214 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }215 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }216 FormatButton("S", strikeThrough = true) { format(wrapSelection(body, "~~")) }217 Box(Modifier.width(1.dp).height(24.dp).background(peachColors.T3))218 FormatButton("H₂") { format(applyLinePrefix(body, "## ")) }219 FormatButton("❞") { format(applyLinePrefix(body, "> ")) }220 FormatButton("•") { format(applyLinePrefix(body, "- ")) }221 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }222 FormatButton("—") { format(applyLinePrefix(body, "---")) }223 }224 Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))225 }226227 228 Box(Modifier.weight(1f).fillMaxWidth().padding(PeachSpacing.lg)) {229 if (body.text.isEmpty()) {230 Text(231 "Start writing…",232 color = peachColors.T2,233 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),234 )235 }236 if (editing) {237 BasicTextField(238 value = body,239 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },240 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),241 cursorBrush = SolidColor(accent),242 modifier = Modifier.fillMaxSize(),243 )244 } else {245 Text(246 noteRendered(body.text, isHtml),247 color = peachColors.T0,248 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),249 modifier = Modifier.fillMaxSize().clickable { editing = true },250 )251 }252 }253 }254 PeachDeckBottomBar(255 onSearch = { finishNote(vm::showToc) },256 onAdd = { finishNote(vm::createNote) },257 onNotes = { finishNote(onBack) },258 onSettings = { finishNote { onNavigate(PeachNav.Settings) } },259 onLock = { finishNote(vm::lock) },260 onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },261 notesSelected = true,262 )263 }264 }265}266267@Composable268private fun NotesToc(269 notes: List<VaultEntry>,270 onBack: () -> Unit,271 onOpenNote: (String) -> Unit,272 onNewNote: () -> Unit,273 isMultiSelect: Boolean,274 selectedIds: Set<String>,275 onEnterMultiSelect: (String) -> Unit,276 onToggleSelected: (String) -> Unit,277 onDeleteSelected: () -> Unit,278 onCancelMultiSelect: () -> Unit,279 onNavigate: (PeachNav) -> Unit,280 onLock: () -> Unit,281) {282 var searchVisible by remember { mutableStateOf(false) }283 var query by remember { mutableStateOf("") }284 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }285 val accent = LocalPeachAccent.current286 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)287288 ShellBackground {289 Column(290 Modifier291 .fillMaxSize()292 .statusBarsPadding()293 .padding(vertical = PeachSpacing.md),294 ) {295 if (isMultiSelect) {296 MultiSelectTopBar(297 count = selectedIds.size,298 onDelete = onDeleteSelected,299 onCancel = onCancelMultiSelect,300 includeStatusBarPadding = false,301 )302 } else if (searchVisible) {303 Surface(304 modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg),305 color = peachColors.S1,306 shape = RoundedCornerShape(PeachRadius.StackCard),307 border = androidx.compose.foundation.BorderStroke(1.dp, peachColors.T3),308 tonalElevation = 1.dp,309 ) {310 Row(311 Modifier312 .fillMaxWidth()313 .height(56.dp)314 .padding(horizontal = PeachSpacing.sm),315 verticalAlignment = Alignment.CenterVertically,316 ) {317 BasicTextField(318 value = query,319 onValueChange = { query = it },320 singleLine = true,321 textStyle = TextStyle(322 color = peachColors.T0,323 fontFamily = PeachType.Mono,324 fontSize = 14.sp,325 ),326 cursorBrush = SolidColor(accent),327 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),328 decorationBox = { inner ->329 Box {330 if (query.isEmpty()) {331 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)332 }333 inner()334 }335 },336 )337 Icon(338 Icons.Outlined.Close,339 contentDescription = "clear search",340 tint = peachColors.T0,341 modifier = Modifier.size(40.dp).clip(CircleShape)342 .clickable {343 query = ""344 searchVisible = false345 }346 .padding(9.dp),347 )348 }349 }350 } else PeachVaultIdentity(351 "notes",352 Modifier.padding(horizontal = PeachSpacing.lg),353 ) { onNavigate(PeachNav.VaultList) }354 Spacer(Modifier.size(PeachSpacing.lg))355356 LazyColumn(357 Modifier.weight(1f).padding(horizontal = PeachSpacing.lg),358 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),359 ) {360 if (filtered.isEmpty()) {361 item {362 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {363 PeachEmptyOrEndState(364 message = if (query.isBlank()) "No notes yet." else "No notes match.",365 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",366 )367 }368 }369 } else {370 items(filtered, key = { it.id }) { note ->371 NoteTocRow(372 note = note,373 multiSelect = isMultiSelect,374 selected = note.id in selectedIds,375 onClick = {376 if (isMultiSelect) onToggleSelected(note.id)377 else onOpenNote(note.id)378 },379 onLongClick = {380 if (isMultiSelect) onToggleSelected(note.id)381 else onEnterMultiSelect(note.id)382 },383 )384 }385 }386 }387388 if (!isMultiSelect) {389 PeachDeckBottomBar(390 onSearch = { searchVisible = true },391 onAdd = onNewNote,392 onNotes = onBack,393 onSettings = { onNavigate(PeachNav.Settings) },394 onLock = onLock,395 onSwipeUp = onBack,396 notesSelected = true,397 )398 }399400 }401 }402}403404@OptIn(ExperimentalFoundationApi::class)405@Composable406private fun NoteTocRow(407 note: VaultEntry,408 multiSelect: Boolean,409 selected: Boolean,410 onClick: () -> Unit,411 onLongClick: () -> Unit,412) {413 val context = LocalContext.current414 val accent = LocalPeachAccent.current415 Row(416 Modifier.fillMaxWidth()417 .clip(RoundedCornerShape(PeachRadius.StackCard))418 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.SurfaceCard)419 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))420 .combinedClickable(421 onClick = onClick,422 onLongClickLabel = "Select note",423 onLongClick = {424 PeachHaptics.onLongPress(context)425 onLongClick()426 },427 )428 .padding(horizontal = 14.dp, vertical = 12.dp),429 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),430 verticalAlignment = Alignment.CenterVertically,431 ) {432 if (multiSelect) MultiSelectCheckbox(checked = selected)433 Column(434 Modifier.weight(1f),435 verticalArrangement = Arrangement.spacedBy(5.dp),436 ) {437 Text(438 note.title.ifBlank { "Untitled" },439 color = peachColors.T0,440 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),441 )442 val preview = notePreviewLine(note.notes.orEmpty())443 Text(444 preview.ifBlank { "No body text" },445 color = peachColors.T2,446 style = PeachType.bodyMono,447 maxLines = 2,448 )449 }450 }451}452453454455456457458internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {459 val sel = value.selection460 if (sel.collapsed) return value461 val text = value.text462 val next = text.substring(0, sel.min) + marker +463 text.substring(sel.min, sel.max) + marker +464 text.substring(sel.max)465 return value.copy(466 text = next,467 selection = TextRange(sel.min, sel.max + 2 * marker.length),468 )469}470471internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {472 val text = value.text473 val sel = value.selection474 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }475 val endExclusive = if (sel.collapsed) {476 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }477 } else {478 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }479 }480 val block = text.substring(start, endExclusive)481 val transformed = block482 .split('\n')483 .joinToString("\n") { line ->484 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line485 }486 val nextText = text.replaceRange(start, endExclusive, transformed)487 val delta = nextText.length - text.length488 return value.copy(489 text = nextText,490 selection = TextRange(491 sel.start.coerceIn(0, nextText.length),492 (sel.end + delta).coerceIn(0, nextText.length),493 ),494 )495}496497internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {498 val q = query.trim().lowercase()499 if (q.isBlank()) return notes500 return notes.filter { note ->501 note.title.lowercase().contains(q) ||502 notePlainText(note.notes.orEmpty()).lowercase().contains(q)503 }504}505506@Composable507private fun FormatButton(508 label: String,509 fontWeight: FontWeight? = null,510 fontStyle: FontStyle? = null,511 underline: Boolean = false,512 strikeThrough: Boolean = false,513 onClick: () -> Unit,514) {515 Box(516 Modifier517 .size(30.dp)518 .clip(RoundedCornerShape(6.dp))519 .background(peachColors.S2)520 .clickable(onClick = onClick),521 contentAlignment = Alignment.Center,522 ) {523 Text(524 label,525 color = peachColors.T0,526 style = TextStyle(527 fontFamily = PeachType.Body,528 fontSize = 14.sp,529 fontWeight = fontWeight,530 fontStyle = fontStyle,531 textDecoration = when {532 underline && strikeThrough -> TextDecoration.combine(533 listOf(TextDecoration.Underline, TextDecoration.LineThrough),534 )535 underline -> TextDecoration.Underline536 strikeThrough -> TextDecoration.LineThrough537 else -> null538 },539 ),540 )541 }542}