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(vertical = PeachSpacing.md),145 ) {146 PeachVaultIdentity(147 "notes",148 Modifier.padding(horizontal = PeachSpacing.lg),149 ) { finishNote { onNavigate(PeachNav.VaultList) } }150 FieldShell("title", Modifier.padding(horizontal = PeachSpacing.lg)) {151 Box(Modifier.weight(1f)) {152 if (title.isEmpty()) {153 Text(154 "untitled note",155 color = peachColors.T2,156 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),157 )158 }159 BasicTextField(160 value = title,161 onValueChange = { title = it; vm.onTitleChanged(it) },162 singleLine = true,163 textStyle = TextStyle(164 color = peachColors.T0,165 fontFamily = PeachType.Display,166 fontStyle = FontStyle.Italic,167 fontSize = 22.sp,168 ),169 cursorBrush = SolidColor(accent),170 modifier = Modifier.fillMaxWidth(),171 )172 }173 Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))174 Icon(175 Icons.Outlined.DeleteOutline,176 contentDescription = "delete note",177 tint = peachColors.T2,178 modifier = Modifier.size(36.dp).clip(CircleShape)179 .clickable { vm.delete {} }180 .padding(8.dp),181 )182 }183184 if (e == null) {185 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {186 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)187 }188 return@Column189 }190191 192 193 if (editing) {194 Row(195 Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),196 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),197 verticalAlignment = Alignment.CenterVertically,198 ) {199 fun format(next: TextFieldValue) {200 body = next201 isHtml = true202 vm.onContentChanged(noteStorageText(next.text, isHtml = true))203 }204 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }205 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }206 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }207 Spacer(Modifier.width(4.dp))208 FormatButton("H") { format(applyLinePrefix(body, "## ")) }209 FormatButton("•") { format(applyLinePrefix(body, "- ")) }210 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }211 }212 }213214 215 Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm)) {216 if (body.text.isEmpty()) {217 Text(218 "Start writing…",219 color = peachColors.T2,220 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),221 )222 }223 if (editing) {224 BasicTextField(225 value = body,226 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },227 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),228 cursorBrush = SolidColor(accent),229 modifier = Modifier.fillMaxSize(),230 )231 } else {232 Text(233 noteRendered(body.text, isHtml),234 color = peachColors.T0,235 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),236 modifier = Modifier.fillMaxSize().clickable { editing = true },237 )238 }239 }240 PeachDeckBottomBar(241 onSearch = { finishNote(vm::showToc) },242 onAdd = { finishNote(vm::createNote) },243 onNotes = { finishNote(vm::showToc) },244 onSettings = { finishNote { onNavigate(PeachNav.Settings) } },245 onLock = { finishNote(vm::lock) },246 onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },247 notesSelected = true,248 )249 }250 }251}252253@Composable254private fun NotesToc(255 notes: List<VaultEntry>,256 onBack: () -> Unit,257 onOpenNote: (String) -> Unit,258 onNewNote: () -> Unit,259 isMultiSelect: Boolean,260 selectedIds: Set<String>,261 onEnterMultiSelect: (String) -> Unit,262 onToggleSelected: (String) -> Unit,263 onDeleteSelected: () -> Unit,264 onCancelMultiSelect: () -> Unit,265 onNavigate: (PeachNav) -> Unit,266 onLock: () -> Unit,267) {268 var searchVisible by remember { mutableStateOf(false) }269 var query by remember { mutableStateOf("") }270 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }271 val accent = LocalPeachAccent.current272 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)273274 ShellBackground {275 Column(276 Modifier277 .fillMaxSize()278 .statusBarsPadding()279 .padding(vertical = PeachSpacing.md),280 ) {281 if (isMultiSelect) {282 MultiSelectTopBar(283 count = selectedIds.size,284 onDelete = onDeleteSelected,285 onCancel = onCancelMultiSelect,286 includeStatusBarPadding = false,287 )288 } else if (searchVisible) {289 Surface(290 modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg),291 color = peachColors.S1,292 shape = RoundedCornerShape(PeachSpacing.lg),293 tonalElevation = 1.dp,294 ) {295 Row(296 Modifier297 .fillMaxWidth()298 .height(56.dp)299 .padding(horizontal = PeachSpacing.sm),300 verticalAlignment = Alignment.CenterVertically,301 ) {302 BasicTextField(303 value = query,304 onValueChange = { query = it },305 singleLine = true,306 textStyle = TextStyle(307 color = peachColors.T0,308 fontFamily = PeachType.Mono,309 fontSize = 14.sp,310 ),311 cursorBrush = SolidColor(accent),312 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),313 decorationBox = { inner ->314 Box {315 if (query.isEmpty()) {316 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)317 }318 inner()319 }320 },321 )322 Icon(323 Icons.Outlined.Close,324 contentDescription = "clear search",325 tint = peachColors.T0,326 modifier = Modifier.size(40.dp).clip(CircleShape)327 .clickable {328 query = ""329 searchVisible = false330 }331 .padding(9.dp),332 )333 }334 }335 } else PeachVaultIdentity(336 "notes",337 Modifier.padding(horizontal = PeachSpacing.lg),338 ) { onNavigate(PeachNav.VaultList) }339 Spacer(Modifier.size(PeachSpacing.lg))340341 LazyColumn(342 Modifier.weight(1f).padding(horizontal = PeachSpacing.lg),343 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),344 ) {345 if (filtered.isEmpty()) {346 item {347 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {348 PeachEmptyOrEndState(349 message = if (query.isBlank()) "No notes yet." else "No notes match.",350 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",351 )352 }353 }354 } else {355 items(filtered, key = { it.id }) { note ->356 NoteTocRow(357 note = note,358 multiSelect = isMultiSelect,359 selected = note.id in selectedIds,360 onClick = {361 if (isMultiSelect) onToggleSelected(note.id)362 else onOpenNote(note.id)363 },364 onLongClick = {365 if (isMultiSelect) onToggleSelected(note.id)366 else onEnterMultiSelect(note.id)367 },368 )369 }370 }371 }372373 if (!isMultiSelect) {374 PeachDeckBottomBar(375 onSearch = { searchVisible = true },376 onAdd = onNewNote,377 onNotes = {},378 onSettings = { onNavigate(PeachNav.Settings) },379 onLock = onLock,380 onSwipeUp = onBack,381 notesSelected = true,382 )383 }384385 }386 }387}388389@OptIn(ExperimentalFoundationApi::class)390@Composable391private fun NoteTocRow(392 note: VaultEntry,393 multiSelect: Boolean,394 selected: Boolean,395 onClick: () -> Unit,396 onLongClick: () -> Unit,397) {398 val context = LocalContext.current399 val accent = LocalPeachAccent.current400 Row(401 Modifier.fillMaxWidth()402 .clip(RoundedCornerShape(8.dp))403 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)404 .combinedClickable(405 onClick = onClick,406 onLongClickLabel = "Select note",407 onLongClick = {408 PeachHaptics.onLongPress(context)409 onLongClick()410 },411 )412 .padding(horizontal = 14.dp, vertical = 12.dp),413 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),414 verticalAlignment = Alignment.CenterVertically,415 ) {416 if (multiSelect) MultiSelectCheckbox(checked = selected)417 Column(418 Modifier.weight(1f),419 verticalArrangement = Arrangement.spacedBy(5.dp),420 ) {421 Text(422 note.title.ifBlank { "Untitled" },423 color = peachColors.T0,424 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),425 )426 val preview = notePreviewLine(note.notes.orEmpty())427 Text(428 preview.ifBlank { "No body text" },429 color = peachColors.T2,430 style = PeachType.bodyMono,431 maxLines = 2,432 )433 }434 }435}436437438439440441442internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {443 val sel = value.selection444 if (sel.collapsed) return value445 val text = value.text446 val next = text.substring(0, sel.min) + marker +447 text.substring(sel.min, sel.max) + marker +448 text.substring(sel.max)449 return value.copy(450 text = next,451 selection = TextRange(sel.min, sel.max + 2 * marker.length),452 )453}454455internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {456 val text = value.text457 val sel = value.selection458 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }459 val endExclusive = if (sel.collapsed) {460 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }461 } else {462 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }463 }464 val block = text.substring(start, endExclusive)465 val transformed = block466 .split('\n')467 .joinToString("\n") { line ->468 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line469 }470 val nextText = text.replaceRange(start, endExclusive, transformed)471 val delta = nextText.length - text.length472 return value.copy(473 text = nextText,474 selection = TextRange(475 sel.start.coerceIn(0, nextText.length),476 (sel.end + delta).coerceIn(0, nextText.length),477 ),478 )479}480481internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {482 val q = query.trim().lowercase()483 if (q.isBlank()) return notes484 return notes.filter { note ->485 note.title.lowercase().contains(q) ||486 notePlainText(note.notes.orEmpty()).lowercase().contains(q)487 }488}489490@Composable491private fun FormatButton(492 label: String,493 fontWeight: FontWeight? = null,494 fontStyle: FontStyle? = null,495 underline: Boolean = false,496 onClick: () -> Unit,497) {498 Box(499 Modifier500 .size(30.dp)501 .clip(RoundedCornerShape(6.dp))502 .background(peachColors.S2)503 .clickable(onClick = onClick),504 contentAlignment = Alignment.Center,505 ) {506 Text(507 label,508 color = peachColors.T0,509 style = TextStyle(510 fontFamily = PeachType.Body,511 fontSize = 14.sp,512 fontWeight = fontWeight,513 fontStyle = fontStyle,514 textDecoration = if (underline) TextDecoration.Underline else null,515 ),516 )517 }518}