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 Spacer(Modifier.width(4.dp))217 FormatButton("H") { format(applyLinePrefix(body, "## ")) }218 FormatButton("•") { format(applyLinePrefix(body, "- ")) }219 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }220 }221 Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))222 }223224 225 Box(Modifier.weight(1f).fillMaxWidth().padding(PeachSpacing.lg)) {226 if (body.text.isEmpty()) {227 Text(228 "Start writing…",229 color = peachColors.T2,230 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),231 )232 }233 if (editing) {234 BasicTextField(235 value = body,236 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },237 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),238 cursorBrush = SolidColor(accent),239 modifier = Modifier.fillMaxSize(),240 )241 } else {242 Text(243 noteRendered(body.text, isHtml),244 color = peachColors.T0,245 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),246 modifier = Modifier.fillMaxSize().clickable { editing = true },247 )248 }249 }250 }251 PeachDeckBottomBar(252 onSearch = { finishNote(vm::showToc) },253 onAdd = { finishNote(vm::createNote) },254 onNotes = { finishNote(vm::showToc) },255 onSettings = { finishNote { onNavigate(PeachNav.Settings) } },256 onLock = { finishNote(vm::lock) },257 onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },258 notesSelected = true,259 )260 }261 }262}263264@Composable265private fun NotesToc(266 notes: List<VaultEntry>,267 onBack: () -> Unit,268 onOpenNote: (String) -> Unit,269 onNewNote: () -> Unit,270 isMultiSelect: Boolean,271 selectedIds: Set<String>,272 onEnterMultiSelect: (String) -> Unit,273 onToggleSelected: (String) -> Unit,274 onDeleteSelected: () -> Unit,275 onCancelMultiSelect: () -> Unit,276 onNavigate: (PeachNav) -> Unit,277 onLock: () -> Unit,278) {279 var searchVisible by remember { mutableStateOf(false) }280 var query by remember { mutableStateOf("") }281 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }282 val accent = LocalPeachAccent.current283 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)284285 ShellBackground {286 Column(287 Modifier288 .fillMaxSize()289 .statusBarsPadding()290 .padding(vertical = PeachSpacing.md),291 ) {292 if (isMultiSelect) {293 MultiSelectTopBar(294 count = selectedIds.size,295 onDelete = onDeleteSelected,296 onCancel = onCancelMultiSelect,297 includeStatusBarPadding = false,298 )299 } else if (searchVisible) {300 Surface(301 modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg),302 color = peachColors.S1,303 shape = RoundedCornerShape(PeachRadius.StackCard),304 border = androidx.compose.foundation.BorderStroke(1.dp, peachColors.T3),305 tonalElevation = 1.dp,306 ) {307 Row(308 Modifier309 .fillMaxWidth()310 .height(56.dp)311 .padding(horizontal = PeachSpacing.sm),312 verticalAlignment = Alignment.CenterVertically,313 ) {314 BasicTextField(315 value = query,316 onValueChange = { query = it },317 singleLine = true,318 textStyle = TextStyle(319 color = peachColors.T0,320 fontFamily = PeachType.Mono,321 fontSize = 14.sp,322 ),323 cursorBrush = SolidColor(accent),324 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),325 decorationBox = { inner ->326 Box {327 if (query.isEmpty()) {328 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)329 }330 inner()331 }332 },333 )334 Icon(335 Icons.Outlined.Close,336 contentDescription = "clear search",337 tint = peachColors.T0,338 modifier = Modifier.size(40.dp).clip(CircleShape)339 .clickable {340 query = ""341 searchVisible = false342 }343 .padding(9.dp),344 )345 }346 }347 } else PeachVaultIdentity(348 "notes",349 Modifier.padding(horizontal = PeachSpacing.lg),350 ) { onNavigate(PeachNav.VaultList) }351 Spacer(Modifier.size(PeachSpacing.lg))352353 LazyColumn(354 Modifier.weight(1f).padding(horizontal = PeachSpacing.lg),355 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),356 ) {357 if (filtered.isEmpty()) {358 item {359 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {360 PeachEmptyOrEndState(361 message = if (query.isBlank()) "No notes yet." else "No notes match.",362 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",363 )364 }365 }366 } else {367 items(filtered, key = { it.id }) { note ->368 NoteTocRow(369 note = note,370 multiSelect = isMultiSelect,371 selected = note.id in selectedIds,372 onClick = {373 if (isMultiSelect) onToggleSelected(note.id)374 else onOpenNote(note.id)375 },376 onLongClick = {377 if (isMultiSelect) onToggleSelected(note.id)378 else onEnterMultiSelect(note.id)379 },380 )381 }382 }383 }384385 if (!isMultiSelect) {386 PeachDeckBottomBar(387 onSearch = { searchVisible = true },388 onAdd = onNewNote,389 onNotes = {},390 onSettings = { onNavigate(PeachNav.Settings) },391 onLock = onLock,392 onSwipeUp = onBack,393 notesSelected = true,394 )395 }396397 }398 }399}400401@OptIn(ExperimentalFoundationApi::class)402@Composable403private fun NoteTocRow(404 note: VaultEntry,405 multiSelect: Boolean,406 selected: Boolean,407 onClick: () -> Unit,408 onLongClick: () -> Unit,409) {410 val context = LocalContext.current411 val accent = LocalPeachAccent.current412 Row(413 Modifier.fillMaxWidth()414 .clip(RoundedCornerShape(PeachRadius.StackCard))415 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.SurfaceCard)416 .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))417 .combinedClickable(418 onClick = onClick,419 onLongClickLabel = "Select note",420 onLongClick = {421 PeachHaptics.onLongPress(context)422 onLongClick()423 },424 )425 .padding(horizontal = 14.dp, vertical = 12.dp),426 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),427 verticalAlignment = Alignment.CenterVertically,428 ) {429 if (multiSelect) MultiSelectCheckbox(checked = selected)430 Column(431 Modifier.weight(1f),432 verticalArrangement = Arrangement.spacedBy(5.dp),433 ) {434 Text(435 note.title.ifBlank { "Untitled" },436 color = peachColors.T0,437 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),438 )439 val preview = notePreviewLine(note.notes.orEmpty())440 Text(441 preview.ifBlank { "No body text" },442 color = peachColors.T2,443 style = PeachType.bodyMono,444 maxLines = 2,445 )446 }447 }448}449450451452453454455internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {456 val sel = value.selection457 if (sel.collapsed) return value458 val text = value.text459 val next = text.substring(0, sel.min) + marker +460 text.substring(sel.min, sel.max) + marker +461 text.substring(sel.max)462 return value.copy(463 text = next,464 selection = TextRange(sel.min, sel.max + 2 * marker.length),465 )466}467468internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {469 val text = value.text470 val sel = value.selection471 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }472 val endExclusive = if (sel.collapsed) {473 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }474 } else {475 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }476 }477 val block = text.substring(start, endExclusive)478 val transformed = block479 .split('\n')480 .joinToString("\n") { line ->481 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line482 }483 val nextText = text.replaceRange(start, endExclusive, transformed)484 val delta = nextText.length - text.length485 return value.copy(486 text = nextText,487 selection = TextRange(488 sel.start.coerceIn(0, nextText.length),489 (sel.end + delta).coerceIn(0, nextText.length),490 ),491 )492}493494internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {495 val q = query.trim().lowercase()496 if (q.isBlank()) return notes497 return notes.filter { note ->498 note.title.lowercase().contains(q) ||499 notePlainText(note.notes.orEmpty()).lowercase().contains(q)500 }501}502503@Composable504private fun FormatButton(505 label: String,506 fontWeight: FontWeight? = null,507 fontStyle: FontStyle? = null,508 underline: Boolean = false,509 onClick: () -> Unit,510) {511 Box(512 Modifier513 .size(30.dp)514 .clip(RoundedCornerShape(6.dp))515 .background(peachColors.S2)516 .clickable(onClick = onClick),517 contentAlignment = Alignment.Center,518 ) {519 Text(520 label,521 color = peachColors.T0,522 style = TextStyle(523 fontFamily = PeachType.Body,524 fontSize = 14.sp,525 fontWeight = fontWeight,526 fontStyle = fontStyle,527 textDecoration = if (underline) TextDecoration.Underline else null,528 ),529 )530 }531}