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.components.PeachTopBar61import 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 vm: NotesModeViewModel = hiltViewModel(),84) {85 val entry by vm.entry.collectAsState()86 val notes by vm.notes.collectAsState()87 val activeNoteId by vm.activeNoteId.collectAsState()88 val isMultiSelect by vm.isMultiSelectMode.collectAsState()89 val selectedIds by vm.selectedIds.collectAsState()90 val savedPulse by vm.savedPulse.collectAsState()91 val accent = LocalPeachAccent.current9293 SecureFlagEffect()9495 96 var showSaved by remember { mutableStateOf(false) }97 LaunchedEffect(savedPulse) {98 if (savedPulse == 0L) return@LaunchedEffect99 showSaved = true100 kotlinx.coroutines.delay(1300)101 showSaved = false102 }103 val savedAlpha by animateFloatAsState(104 targetValue = if (showSaved) 1f else 0f,105 animationSpec = tween(300),106 label = "saved-fade",107 )108109 if (activeNoteId == null) {110 NotesToc(111 notes = notes,112 onBack = onBack,113 onOpenNote = vm::openNote,114 onNewNote = vm::createNote,115 isMultiSelect = isMultiSelect,116 selectedIds = selectedIds,117 onEnterMultiSelect = vm::enterMultiSelect,118 onToggleSelected = vm::toggleSelected,119 onDeleteSelected = vm::batchDelete,120 onCancelMultiSelect = vm::exitMultiSelect,121 )122 return123 }124125 val e = entry126 val stored = e?.notes.orEmpty()127 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }128 var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }129 130 131 var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }132 var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }133134 ShellBackground {135 Column(136 Modifier137 .fillMaxSize()138 .statusBarsPadding()139 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),140 ) {141 Surface(142 modifier = Modifier.fillMaxWidth(),143 color = peachColors.S1,144 contentColor = peachColors.T0,145 shape = RoundedCornerShape(PeachSpacing.lg),146 tonalElevation = 1.dp,147 ) {148 Row(149 Modifier150 .fillMaxWidth()151 .height(56.dp)152 .padding(horizontal = PeachSpacing.sm),153 verticalAlignment = Alignment.CenterVertically,154 ) {155 Icon(156 Icons.AutoMirrored.Rounded.ArrowBack,157 contentDescription = "back",158 tint = peachColors.T0,159 modifier = Modifier.size(40.dp).clip(CircleShape)160 .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }161 .padding(8.dp),162 )163 Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {164 if (title.isEmpty()) {165 Text(166 "untitled note",167 color = peachColors.T2,168 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),169 )170 }171 BasicTextField(172 value = title,173 onValueChange = { title = it; vm.onTitleChanged(it) },174 singleLine = true,175 textStyle = TextStyle(176 color = peachColors.T0,177 fontFamily = PeachType.Display,178 fontStyle = FontStyle.Italic,179 fontSize = 22.sp,180 ),181 cursorBrush = SolidColor(accent),182 modifier = Modifier.fillMaxWidth(),183 )184 }185 Text(186 "saved.",187 color = peachColors.T2,188 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),189 modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),190 )191 Icon(192 Icons.Outlined.DeleteOutline,193 contentDescription = "delete note",194 tint = peachColors.T2,195 modifier = Modifier.size(36.dp).clip(CircleShape)196 .clickable { vm.delete {} }197 .padding(8.dp),198 )199 }200 }201202 if (e == null) {203 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {204 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)205 }206 return@Column207 }208209 210 211 if (editing) {212 Row(213 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),214 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),215 verticalAlignment = Alignment.CenterVertically,216 ) {217 fun format(next: TextFieldValue) {218 body = next219 isHtml = true220 vm.onContentChanged(noteStorageText(next.text, isHtml = true))221 }222 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }223 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }224 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }225 Spacer(Modifier.width(4.dp))226 FormatButton("H") { format(applyLinePrefix(body, "## ")) }227 FormatButton("•") { format(applyLinePrefix(body, "- ")) }228 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }229 }230 }231232 233 Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {234 if (body.text.isEmpty()) {235 Text(236 "Start writing…",237 color = peachColors.T2,238 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),239 )240 }241 if (editing) {242 BasicTextField(243 value = body,244 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },245 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),246 cursorBrush = SolidColor(accent),247 modifier = Modifier.fillMaxSize(),248 )249 } else {250 Text(251 noteRendered(body.text, isHtml),252 color = peachColors.T0,253 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),254 modifier = Modifier.fillMaxSize().clickable { editing = true },255 )256 }257 }258 }259 }260}261262@Composable263private fun NotesToc(264 notes: List<VaultEntry>,265 onBack: () -> Unit,266 onOpenNote: (String) -> Unit,267 onNewNote: () -> Unit,268 isMultiSelect: Boolean,269 selectedIds: Set<String>,270 onEnterMultiSelect: (String) -> Unit,271 onToggleSelected: (String) -> Unit,272 onDeleteSelected: () -> Unit,273 onCancelMultiSelect: () -> Unit,274) {275 var searchVisible by remember { mutableStateOf(false) }276 var query by remember { mutableStateOf("") }277 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }278 val accent = LocalPeachAccent.current279 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)280281 ShellBackground {282 Column(283 Modifier284 .fillMaxSize()285 .statusBarsPadding()286 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),287 ) {288 if (isMultiSelect) {289 MultiSelectTopBar(290 count = selectedIds.size,291 onDelete = onDeleteSelected,292 onCancel = onCancelMultiSelect,293 includeStatusBarPadding = false,294 )295 } else if (searchVisible) {296 Surface(297 modifier = Modifier.fillMaxWidth(),298 color = peachColors.S1,299 shape = RoundedCornerShape(PeachSpacing.lg),300 tonalElevation = 1.dp,301 ) {302 Row(303 Modifier304 .fillMaxWidth()305 .height(56.dp)306 .padding(horizontal = PeachSpacing.sm),307 verticalAlignment = Alignment.CenterVertically,308 ) {309 BasicTextField(310 value = query,311 onValueChange = { query = it },312 singleLine = true,313 textStyle = TextStyle(314 color = peachColors.T0,315 fontFamily = PeachType.Mono,316 fontSize = 14.sp,317 ),318 cursorBrush = SolidColor(accent),319 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),320 decorationBox = { inner ->321 Box {322 if (query.isEmpty()) {323 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)324 }325 inner()326 }327 },328 )329 Icon(330 Icons.Outlined.Close,331 contentDescription = "clear search",332 tint = peachColors.T0,333 modifier = Modifier.size(40.dp).clip(CircleShape)334 .clickable {335 query = ""336 searchVisible = false337 }338 .padding(9.dp),339 )340 }341 }342 } else {343 PeachTopBar(344 title = "notes.",345 leading = {346 Icon(347 Icons.AutoMirrored.Rounded.ArrowBack,348 contentDescription = "back to vault",349 tint = peachColors.T0,350 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))351 .clickable(onClick = onBack),352 )353 },354 actions = {355 Icon(356 Icons.Outlined.Search,357 contentDescription = "search notes",358 tint = peachColors.T0,359 modifier = Modifier.size(28.dp).clip(CircleShape)360 .clickable { searchVisible = true }361 .padding(5.dp),362 )363 },364 )365 }366 Spacer(Modifier.size(PeachSpacing.lg))367368 LazyColumn(369 Modifier.weight(1f),370 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),371 ) {372 if (filtered.isEmpty()) {373 item {374 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {375 PeachEmptyOrEndState(376 message = if (query.isBlank()) "No notes yet." else "No notes match.",377 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",378 )379 }380 }381 } else {382 items(filtered, key = { it.id }) { note ->383 NoteTocRow(384 note = note,385 multiSelect = isMultiSelect,386 selected = note.id in selectedIds,387 onClick = {388 if (isMultiSelect) onToggleSelected(note.id)389 else onOpenNote(note.id)390 },391 onLongClick = {392 if (isMultiSelect) onToggleSelected(note.id)393 else onEnterMultiSelect(note.id)394 },395 )396 }397 }398 }399400 if (!isMultiSelect) {401 Row(402 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)403 .clip(RoundedCornerShape(8.dp))404 .background(peachColors.S2)405 .clickable(onClick = onNewNote)406 .padding(horizontal = 14.dp, vertical = 12.dp),407 verticalAlignment = Alignment.CenterVertically,408 horizontalArrangement = Arrangement.spacedBy(10.dp),409 ) {410 Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))411 Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)412 }413 }414 }415 }416}417418@OptIn(ExperimentalFoundationApi::class)419@Composable420private fun NoteTocRow(421 note: VaultEntry,422 multiSelect: Boolean,423 selected: Boolean,424 onClick: () -> Unit,425 onLongClick: () -> Unit,426) {427 val context = LocalContext.current428 val accent = LocalPeachAccent.current429 Row(430 Modifier.fillMaxWidth()431 .clip(RoundedCornerShape(8.dp))432 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)433 .combinedClickable(434 onClick = onClick,435 onLongClickLabel = "Select note",436 onLongClick = {437 PeachHaptics.onLongPress(context)438 onLongClick()439 },440 )441 .padding(horizontal = 14.dp, vertical = 12.dp),442 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),443 verticalAlignment = Alignment.CenterVertically,444 ) {445 if (multiSelect) MultiSelectCheckbox(checked = selected)446 Column(447 Modifier.weight(1f),448 verticalArrangement = Arrangement.spacedBy(5.dp),449 ) {450 Text(451 note.title.ifBlank { "Untitled" },452 color = peachColors.T0,453 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),454 )455 val preview = notePreviewLine(note.notes.orEmpty())456 Text(457 preview.ifBlank { "No body text" },458 color = peachColors.T2,459 style = PeachType.bodyMono,460 maxLines = 2,461 )462 }463 }464}465466467468469470471internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {472 val sel = value.selection473 if (sel.collapsed) return value474 val text = value.text475 val next = text.substring(0, sel.min) + marker +476 text.substring(sel.min, sel.max) + marker +477 text.substring(sel.max)478 return value.copy(479 text = next,480 selection = TextRange(sel.min, sel.max + 2 * marker.length),481 )482}483484internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {485 val text = value.text486 val sel = value.selection487 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }488 val endExclusive = if (sel.collapsed) {489 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }490 } else {491 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }492 }493 val block = text.substring(start, endExclusive)494 val transformed = block495 .split('\n')496 .joinToString("\n") { line ->497 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line498 }499 val nextText = text.replaceRange(start, endExclusive, transformed)500 val delta = nextText.length - text.length501 return value.copy(502 text = nextText,503 selection = TextRange(504 sel.start.coerceIn(0, nextText.length),505 (sel.end + delta).coerceIn(0, nextText.length),506 ),507 )508}509510internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {511 val q = query.trim().lowercase()512 if (q.isBlank()) return notes513 return notes.filter { note ->514 note.title.lowercase().contains(q) ||515 notePlainText(note.notes.orEmpty()).lowercase().contains(q)516 }517}518519@Composable520private fun FormatButton(521 label: String,522 fontWeight: FontWeight? = null,523 fontStyle: FontStyle? = null,524 underline: Boolean = false,525 onClick: () -> Unit,526) {527 Box(528 Modifier529 .size(30.dp)530 .clip(RoundedCornerShape(6.dp))531 .background(peachColors.S2)532 .clickable(onClick = onClick),533 contentAlignment = Alignment.Center,534 ) {535 Text(536 label,537 color = peachColors.T0,538 style = TextStyle(539 fontFamily = PeachType.Body,540 fontSize = 14.sp,541 fontWeight = fontWeight,542 fontStyle = fontStyle,543 textDecoration = if (underline) TextDecoration.Underline else null,544 ),545 )546 }547}