1package com.peachpasswords.ui.vault23import androidx.compose.animation.core.animateFloatAsState4import androidx.compose.animation.core.tween5import androidx.compose.foundation.background6import androidx.compose.foundation.clickable7import androidx.compose.foundation.layout.Arrangement8import androidx.compose.foundation.layout.Box9import androidx.compose.foundation.layout.Column10import androidx.compose.foundation.layout.Row11import androidx.compose.foundation.layout.Spacer12import androidx.compose.foundation.layout.fillMaxSize13import androidx.compose.foundation.layout.fillMaxWidth14import androidx.compose.foundation.layout.height15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.size17import androidx.compose.foundation.layout.statusBarsPadding18import androidx.compose.foundation.layout.width19import androidx.compose.foundation.lazy.LazyColumn20import androidx.compose.foundation.lazy.items21import androidx.compose.foundation.shape.CircleShape22import androidx.compose.foundation.shape.RoundedCornerShape23import androidx.compose.foundation.text.BasicTextField24import androidx.compose.material.icons.Icons25import androidx.compose.material.icons.automirrored.rounded.ArrowBack26import androidx.compose.material.icons.outlined.Add27import androidx.compose.material.icons.outlined.Close28import androidx.compose.material.icons.outlined.DeleteOutline29import androidx.compose.material.icons.outlined.Search30import androidx.compose.material3.Icon31import androidx.compose.material3.Surface32import androidx.compose.material3.Text33import androidx.compose.runtime.Composable34import androidx.compose.runtime.LaunchedEffect35import androidx.compose.runtime.getValue36import androidx.compose.runtime.mutableStateOf37import androidx.compose.runtime.remember38import androidx.compose.runtime.setValue39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.draw.alpha42import androidx.compose.ui.draw.clip43import androidx.compose.ui.graphics.SolidColor44import androidx.compose.ui.text.TextStyle45import androidx.compose.ui.text.TextRange46import androidx.compose.ui.text.font.FontStyle47import androidx.compose.ui.text.font.FontWeight48import androidx.compose.ui.text.input.TextFieldValue49import androidx.compose.ui.text.style.TextDecoration50import androidx.compose.ui.unit.dp51import androidx.compose.ui.unit.sp52import androidx.compose.runtime.collectAsState53import androidx.hilt.navigation.compose.hiltViewModel54import com.peachpasswords.ui.components.PeachEmptyOrEndState55import com.peachpasswords.ui.components.PeachTopBar56import com.peachpasswords.ui.components.SecureFlagEffect57import com.peachpasswords.ui.theme.PeachSpacing58import com.peachpasswords.vault.VaultEntry59import com.peachpasswords.ui.theme.LocalPeachAccent60import com.peachpasswords.ui.theme.peachColors61import com.peachpasswords.ui.theme.PeachType62import com.peachpasswords.ui.theme.ShellBackground63646566676869707172737475@Composable76fun NotesModeScreen(77 onBack: () -> Unit,78 vm: NotesModeViewModel = hiltViewModel(),79) {80 val entry by vm.entry.collectAsState()81 val notes by vm.notes.collectAsState()82 val activeNoteId by vm.activeNoteId.collectAsState()83 val savedPulse by vm.savedPulse.collectAsState()84 val accent = LocalPeachAccent.current8586 SecureFlagEffect()8788 89 var showSaved by remember { mutableStateOf(false) }90 LaunchedEffect(savedPulse) {91 if (savedPulse == 0L) return@LaunchedEffect92 showSaved = true93 kotlinx.coroutines.delay(1300)94 showSaved = false95 }96 val savedAlpha by animateFloatAsState(97 targetValue = if (showSaved) 1f else 0f,98 animationSpec = tween(300),99 label = "saved-fade",100 )101102 if (activeNoteId == null) {103 NotesToc(104 notes = notes,105 onBack = onBack,106 onOpenNote = vm::openNote,107 onNewNote = vm::createNote,108 )109 return110 }111112 val e = entry113 val stored = e?.notes.orEmpty()114 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }115 var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }116 117 118 var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }119 var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }120121 ShellBackground {122 Column(123 Modifier124 .fillMaxSize()125 .statusBarsPadding()126 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),127 ) {128 Surface(129 modifier = Modifier.fillMaxWidth(),130 color = peachColors.S1,131 contentColor = peachColors.T0,132 shape = RoundedCornerShape(PeachSpacing.lg),133 tonalElevation = 1.dp,134 ) {135 Row(136 Modifier137 .fillMaxWidth()138 .height(56.dp)139 .padding(horizontal = PeachSpacing.sm),140 verticalAlignment = Alignment.CenterVertically,141 ) {142 Icon(143 Icons.AutoMirrored.Rounded.ArrowBack,144 contentDescription = "back",145 tint = peachColors.T0,146 modifier = Modifier.size(40.dp).clip(CircleShape)147 .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }148 .padding(8.dp),149 )150 Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {151 if (title.isEmpty()) {152 Text(153 "untitled note",154 color = peachColors.T2,155 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),156 )157 }158 BasicTextField(159 value = title,160 onValueChange = { title = it; vm.onTitleChanged(it) },161 singleLine = true,162 textStyle = TextStyle(163 color = peachColors.T0,164 fontFamily = PeachType.Display,165 fontStyle = FontStyle.Italic,166 fontSize = 22.sp,167 ),168 cursorBrush = SolidColor(accent),169 modifier = Modifier.fillMaxWidth(),170 )171 }172 Text(173 "saved.",174 color = peachColors.T2,175 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),176 modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),177 )178 Icon(179 Icons.Outlined.DeleteOutline,180 contentDescription = "delete note",181 tint = peachColors.T2,182 modifier = Modifier.size(36.dp).clip(CircleShape)183 .clickable { vm.delete {} }184 .padding(8.dp),185 )186 }187 }188189 if (e == null) {190 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {191 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)192 }193 return@Column194 }195196 197 198 if (editing) {199 Row(200 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),201 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),202 verticalAlignment = Alignment.CenterVertically,203 ) {204 fun format(next: TextFieldValue) {205 body = next206 isHtml = true207 vm.onContentChanged(noteStorageText(next.text, isHtml = true))208 }209 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }210 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }211 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }212 Spacer(Modifier.width(4.dp))213 FormatButton("H") { format(applyLinePrefix(body, "## ")) }214 FormatButton("•") { format(applyLinePrefix(body, "- ")) }215 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }216 }217 }218219 220 Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {221 if (body.text.isEmpty()) {222 Text(223 "Start writing…",224 color = peachColors.T2,225 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),226 )227 }228 if (editing) {229 BasicTextField(230 value = body,231 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },232 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),233 cursorBrush = SolidColor(accent),234 modifier = Modifier.fillMaxSize(),235 )236 } else {237 Text(238 noteRendered(body.text, isHtml),239 color = peachColors.T0,240 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),241 modifier = Modifier.fillMaxSize().clickable { editing = true },242 )243 }244 }245 }246 }247}248249@Composable250private fun NotesToc(251 notes: List<VaultEntry>,252 onBack: () -> Unit,253 onOpenNote: (String) -> Unit,254 onNewNote: () -> Unit,255) {256 var searchVisible by remember { mutableStateOf(false) }257 var query by remember { mutableStateOf("") }258 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }259 val accent = LocalPeachAccent.current260261 ShellBackground {262 Column(263 Modifier264 .fillMaxSize()265 .statusBarsPadding()266 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),267 ) {268 if (searchVisible) {269 Surface(270 modifier = Modifier.fillMaxWidth(),271 color = peachColors.S1,272 shape = RoundedCornerShape(PeachSpacing.lg),273 tonalElevation = 1.dp,274 ) {275 Row(276 Modifier277 .fillMaxWidth()278 .height(56.dp)279 .padding(horizontal = PeachSpacing.sm),280 verticalAlignment = Alignment.CenterVertically,281 ) {282 BasicTextField(283 value = query,284 onValueChange = { query = it },285 singleLine = true,286 textStyle = TextStyle(287 color = peachColors.T0,288 fontFamily = PeachType.Mono,289 fontSize = 14.sp,290 ),291 cursorBrush = SolidColor(accent),292 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),293 decorationBox = { inner ->294 Box {295 if (query.isEmpty()) {296 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)297 }298 inner()299 }300 },301 )302 Icon(303 Icons.Outlined.Close,304 contentDescription = "clear search",305 tint = peachColors.T0,306 modifier = Modifier.size(40.dp).clip(CircleShape)307 .clickable {308 query = ""309 searchVisible = false310 }311 .padding(9.dp),312 )313 }314 }315 } else {316 PeachTopBar(317 title = "notes.",318 leading = {319 Icon(320 Icons.AutoMirrored.Rounded.ArrowBack,321 contentDescription = "back to vault",322 tint = peachColors.T0,323 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))324 .clickable(onClick = onBack),325 )326 },327 actions = {328 Icon(329 Icons.Outlined.Search,330 contentDescription = "search notes",331 tint = peachColors.T0,332 modifier = Modifier.size(28.dp).clip(CircleShape)333 .clickable { searchVisible = true }334 .padding(5.dp),335 )336 },337 )338 }339 Spacer(Modifier.size(PeachSpacing.lg))340341 LazyColumn(342 Modifier.weight(1f),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(note = note, onClick = { onOpenNote(note.id) })357 }358 }359 }360361 Row(362 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)363 .clip(RoundedCornerShape(8.dp))364 .background(peachColors.S2)365 .clickable(onClick = onNewNote)366 .padding(horizontal = 14.dp, vertical = 12.dp),367 verticalAlignment = Alignment.CenterVertically,368 horizontalArrangement = Arrangement.spacedBy(10.dp),369 ) {370 Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))371 Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)372 }373 }374 }375}376377@Composable378private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {379 Column(380 Modifier.fillMaxWidth()381 .clip(RoundedCornerShape(8.dp))382 .background(peachColors.S1)383 .clickable(onClick = onClick)384 .padding(horizontal = 14.dp, vertical = 12.dp),385 verticalArrangement = Arrangement.spacedBy(5.dp),386 ) {387 Text(388 note.title.ifBlank { "Untitled" },389 color = peachColors.T0,390 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),391 )392 val preview = notePreviewLine(note.notes.orEmpty())393 Text(394 preview.ifBlank { "No body text" },395 color = peachColors.T2,396 style = PeachType.bodyMono,397 maxLines = 2,398 )399 }400}401402403404405406407internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {408 val sel = value.selection409 if (sel.collapsed) return value410 val text = value.text411 val next = text.substring(0, sel.min) + marker +412 text.substring(sel.min, sel.max) + marker +413 text.substring(sel.max)414 return value.copy(415 text = next,416 selection = TextRange(sel.min, sel.max + 2 * marker.length),417 )418}419420internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {421 val text = value.text422 val sel = value.selection423 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }424 val endExclusive = if (sel.collapsed) {425 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }426 } else {427 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }428 }429 val block = text.substring(start, endExclusive)430 val transformed = block431 .split('\n')432 .joinToString("\n") { line ->433 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line434 }435 val nextText = text.replaceRange(start, endExclusive, transformed)436 val delta = nextText.length - text.length437 return value.copy(438 text = nextText,439 selection = TextRange(440 sel.start.coerceIn(0, nextText.length),441 (sel.end + delta).coerceIn(0, nextText.length),442 ),443 )444}445446internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {447 val q = query.trim().lowercase()448 if (q.isBlank()) return notes449 return notes.filter { note ->450 note.title.lowercase().contains(q) ||451 notePlainText(note.notes.orEmpty()).lowercase().contains(q)452 }453}454455@Composable456private fun FormatButton(457 label: String,458 fontWeight: FontWeight? = null,459 fontStyle: FontStyle? = null,460 underline: Boolean = false,461 onClick: () -> Unit,462) {463 Box(464 Modifier465 .size(30.dp)466 .clip(RoundedCornerShape(6.dp))467 .background(peachColors.S2)468 .clickable(onClick = onClick),469 contentAlignment = Alignment.Center,470 ) {471 Text(472 label,473 color = peachColors.T0,474 style = TextStyle(475 fontFamily = PeachType.Body,476 fontSize = 14.sp,477 fontWeight = fontWeight,478 fontStyle = fontStyle,479 textDecoration = if (underline) TextDecoration.Underline else null,480 ),481 )482 }483}