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()) }136137 ShellBackground {138 Column(139 Modifier140 .fillMaxSize()141 .statusBarsPadding()142 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),143 ) {144 PeachVaultIdentity("notes") { onNavigate(PeachNav.VaultList) }145 FieldShell("title") {146 Box(Modifier.weight(1f)) {147 if (title.isEmpty()) {148 Text(149 "untitled note",150 color = peachColors.T2,151 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),152 )153 }154 BasicTextField(155 value = title,156 onValueChange = { title = it; vm.onTitleChanged(it) },157 singleLine = true,158 textStyle = TextStyle(159 color = peachColors.T0,160 fontFamily = PeachType.Display,161 fontStyle = FontStyle.Italic,162 fontSize = 22.sp,163 ),164 cursorBrush = SolidColor(accent),165 modifier = Modifier.fillMaxWidth(),166 )167 }168 Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))169 Icon(170 Icons.Outlined.DeleteOutline,171 contentDescription = "delete note",172 tint = peachColors.T2,173 modifier = Modifier.size(36.dp).clip(CircleShape)174 .clickable { vm.delete {} }175 .padding(8.dp),176 )177 }178179 if (e == null) {180 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {181 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)182 }183 return@Column184 }185186 187 188 if (editing) {189 Row(190 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),191 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),192 verticalAlignment = Alignment.CenterVertically,193 ) {194 fun format(next: TextFieldValue) {195 body = next196 isHtml = true197 vm.onContentChanged(noteStorageText(next.text, isHtml = true))198 }199 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }200 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }201 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }202 Spacer(Modifier.width(4.dp))203 FormatButton("H") { format(applyLinePrefix(body, "## ")) }204 FormatButton("•") { format(applyLinePrefix(body, "- ")) }205 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }206 }207 }208209 210 Box(Modifier.weight(1f).fillMaxWidth().padding(vertical = PeachSpacing.sm)) {211 if (body.text.isEmpty()) {212 Text(213 "Start writing…",214 color = peachColors.T2,215 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),216 )217 }218 if (editing) {219 BasicTextField(220 value = body,221 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },222 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),223 cursorBrush = SolidColor(accent),224 modifier = Modifier.fillMaxSize(),225 )226 } else {227 Text(228 noteRendered(body.text, isHtml),229 color = peachColors.T0,230 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),231 modifier = Modifier.fillMaxSize().clickable { editing = true },232 )233 }234 }235 PeachDeckBottomBar(236 onSearch = { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() },237 onAdd = vm::createNote,238 onNotes = { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() },239 onSettings = { onNavigate(PeachNav.Settings) },240 onLock = vm::lock,241 onSwipeUp = { onNavigate(PeachNav.VaultList) },242 notesSelected = true,243 )244 }245 }246}247248@Composable249private fun NotesToc(250 notes: List<VaultEntry>,251 onBack: () -> Unit,252 onOpenNote: (String) -> Unit,253 onNewNote: () -> Unit,254 isMultiSelect: Boolean,255 selectedIds: Set<String>,256 onEnterMultiSelect: (String) -> Unit,257 onToggleSelected: (String) -> Unit,258 onDeleteSelected: () -> Unit,259 onCancelMultiSelect: () -> Unit,260 onNavigate: (PeachNav) -> Unit,261 onLock: () -> Unit,262) {263 var searchVisible by remember { mutableStateOf(false) }264 var query by remember { mutableStateOf("") }265 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }266 val accent = LocalPeachAccent.current267 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)268269 ShellBackground {270 Column(271 Modifier272 .fillMaxSize()273 .statusBarsPadding()274 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),275 ) {276 if (isMultiSelect) {277 MultiSelectTopBar(278 count = selectedIds.size,279 onDelete = onDeleteSelected,280 onCancel = onCancelMultiSelect,281 includeStatusBarPadding = false,282 )283 } else if (searchVisible) {284 Surface(285 modifier = Modifier.fillMaxWidth(),286 color = peachColors.S1,287 shape = RoundedCornerShape(PeachSpacing.lg),288 tonalElevation = 1.dp,289 ) {290 Row(291 Modifier292 .fillMaxWidth()293 .height(56.dp)294 .padding(horizontal = PeachSpacing.sm),295 verticalAlignment = Alignment.CenterVertically,296 ) {297 BasicTextField(298 value = query,299 onValueChange = { query = it },300 singleLine = true,301 textStyle = TextStyle(302 color = peachColors.T0,303 fontFamily = PeachType.Mono,304 fontSize = 14.sp,305 ),306 cursorBrush = SolidColor(accent),307 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),308 decorationBox = { inner ->309 Box {310 if (query.isEmpty()) {311 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)312 }313 inner()314 }315 },316 )317 Icon(318 Icons.Outlined.Close,319 contentDescription = "clear search",320 tint = peachColors.T0,321 modifier = Modifier.size(40.dp).clip(CircleShape)322 .clickable {323 query = ""324 searchVisible = false325 }326 .padding(9.dp),327 )328 }329 }330 } else PeachVaultIdentity("notes") { onNavigate(PeachNav.VaultList) }331 Spacer(Modifier.size(PeachSpacing.lg))332333 LazyColumn(334 Modifier.weight(1f),335 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),336 ) {337 if (filtered.isEmpty()) {338 item {339 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {340 PeachEmptyOrEndState(341 message = if (query.isBlank()) "No notes yet." else "No notes match.",342 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",343 )344 }345 }346 } else {347 items(filtered, key = { it.id }) { note ->348 NoteTocRow(349 note = note,350 multiSelect = isMultiSelect,351 selected = note.id in selectedIds,352 onClick = {353 if (isMultiSelect) onToggleSelected(note.id)354 else onOpenNote(note.id)355 },356 onLongClick = {357 if (isMultiSelect) onToggleSelected(note.id)358 else onEnterMultiSelect(note.id)359 },360 )361 }362 }363 }364365 if (!isMultiSelect) {366 PeachDeckBottomBar(367 onSearch = { searchVisible = true },368 onAdd = onNewNote,369 onNotes = {},370 onSettings = { onNavigate(PeachNav.Settings) },371 onLock = onLock,372 onSwipeUp = onBack,373 notesSelected = true,374 )375 }376377 }378 }379}380381@OptIn(ExperimentalFoundationApi::class)382@Composable383private fun NoteTocRow(384 note: VaultEntry,385 multiSelect: Boolean,386 selected: Boolean,387 onClick: () -> Unit,388 onLongClick: () -> Unit,389) {390 val context = LocalContext.current391 val accent = LocalPeachAccent.current392 Row(393 Modifier.fillMaxWidth()394 .clip(RoundedCornerShape(8.dp))395 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)396 .combinedClickable(397 onClick = onClick,398 onLongClickLabel = "Select note",399 onLongClick = {400 PeachHaptics.onLongPress(context)401 onLongClick()402 },403 )404 .padding(horizontal = 14.dp, vertical = 12.dp),405 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),406 verticalAlignment = Alignment.CenterVertically,407 ) {408 if (multiSelect) MultiSelectCheckbox(checked = selected)409 Column(410 Modifier.weight(1f),411 verticalArrangement = Arrangement.spacedBy(5.dp),412 ) {413 Text(414 note.title.ifBlank { "Untitled" },415 color = peachColors.T0,416 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),417 )418 val preview = notePreviewLine(note.notes.orEmpty())419 Text(420 preview.ifBlank { "No body text" },421 color = peachColors.T2,422 style = PeachType.bodyMono,423 maxLines = 2,424 )425 }426 }427}428429430431432433434internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {435 val sel = value.selection436 if (sel.collapsed) return value437 val text = value.text438 val next = text.substring(0, sel.min) + marker +439 text.substring(sel.min, sel.max) + marker +440 text.substring(sel.max)441 return value.copy(442 text = next,443 selection = TextRange(sel.min, sel.max + 2 * marker.length),444 )445}446447internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {448 val text = value.text449 val sel = value.selection450 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }451 val endExclusive = if (sel.collapsed) {452 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }453 } else {454 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }455 }456 val block = text.substring(start, endExclusive)457 val transformed = block458 .split('\n')459 .joinToString("\n") { line ->460 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line461 }462 val nextText = text.replaceRange(start, endExclusive, transformed)463 val delta = nextText.length - text.length464 return value.copy(465 text = nextText,466 selection = TextRange(467 sel.start.coerceIn(0, nextText.length),468 (sel.end + delta).coerceIn(0, nextText.length),469 ),470 )471}472473internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {474 val q = query.trim().lowercase()475 if (q.isBlank()) return notes476 return notes.filter { note ->477 note.title.lowercase().contains(q) ||478 notePlainText(note.notes.orEmpty()).lowercase().contains(q)479 }480}481482@Composable483private fun FormatButton(484 label: String,485 fontWeight: FontWeight? = null,486 fontStyle: FontStyle? = null,487 underline: Boolean = false,488 onClick: () -> Unit,489) {490 Box(491 Modifier492 .size(30.dp)493 .clip(RoundedCornerShape(6.dp))494 .background(peachColors.S2)495 .clickable(onClick = onClick),496 contentAlignment = Alignment.Center,497 ) {498 Text(499 label,500 color = peachColors.T0,501 style = TextStyle(502 fontFamily = PeachType.Body,503 fontSize = 14.sp,504 fontWeight = fontWeight,505 fontStyle = fontStyle,506 textDecoration = if (underline) TextDecoration.Underline else null,507 ),508 )509 }510}