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.AnnotatedString45import androidx.compose.ui.text.SpanStyle46import androidx.compose.ui.text.TextStyle47import androidx.compose.ui.text.TextRange48import androidx.compose.ui.text.buildAnnotatedString49import androidx.compose.ui.text.font.FontStyle50import androidx.compose.ui.text.font.FontWeight51import androidx.compose.ui.text.input.TextFieldValue52import androidx.compose.ui.text.style.TextDecoration53import androidx.compose.ui.text.withStyle54import androidx.compose.ui.unit.dp55import androidx.compose.ui.unit.sp56import androidx.compose.runtime.collectAsState57import androidx.hilt.navigation.compose.hiltViewModel58import com.peachpasswords.ui.components.PeachEmptyOrEndState59import com.peachpasswords.ui.components.PeachTopBar60import com.peachpasswords.ui.components.SecureFlagEffect61import com.peachpasswords.ui.theme.PeachSpacing62import com.peachpasswords.vault.VaultEntry63import com.peachpasswords.ui.theme.LocalPeachAccent64import com.peachpasswords.ui.theme.peachColors65import com.peachpasswords.ui.theme.PeachType66import com.peachpasswords.ui.theme.ShellBackground676869707172737475767778@Composable79fun NotesModeScreen(80 onBack: () -> Unit,81 vm: NotesModeViewModel = hiltViewModel(),82) {83 val entry by vm.entry.collectAsState()84 val notes by vm.notes.collectAsState()85 val activeNoteId by vm.activeNoteId.collectAsState()86 val savedPulse by vm.savedPulse.collectAsState()87 val accent = LocalPeachAccent.current8889 SecureFlagEffect()9091 92 var showSaved by remember { mutableStateOf(false) }93 LaunchedEffect(savedPulse) {94 if (savedPulse == 0L) return@LaunchedEffect95 showSaved = true96 kotlinx.coroutines.delay(1300)97 showSaved = false98 }99 val savedAlpha by animateFloatAsState(100 targetValue = if (showSaved) 1f else 0f,101 animationSpec = tween(300),102 label = "saved-fade",103 )104105 if (activeNoteId == null) {106 NotesToc(107 notes = notes,108 onBack = onBack,109 onOpenNote = vm::openNote,110 onNewNote = vm::createNote,111 )112 return113 }114115 val e = entry116 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }117 var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }118119 ShellBackground {120 Column(121 Modifier122 .fillMaxSize()123 .statusBarsPadding()124 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),125 ) {126 Surface(127 modifier = Modifier.fillMaxWidth(),128 color = peachColors.S1,129 contentColor = peachColors.T0,130 shape = RoundedCornerShape(PeachSpacing.lg),131 tonalElevation = 1.dp,132 ) {133 Row(134 Modifier135 .fillMaxWidth()136 .height(56.dp)137 .padding(horizontal = PeachSpacing.sm),138 verticalAlignment = Alignment.CenterVertically,139 ) {140 Icon(141 Icons.AutoMirrored.Rounded.ArrowBack,142 contentDescription = "back",143 tint = peachColors.T0,144 modifier = Modifier.size(40.dp).clip(CircleShape)145 .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp),146 )147 Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {148 if (title.isEmpty()) {149 Text(150 "untitled note",151 color = peachColors.T2,152 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),153 )154 }155 BasicTextField(156 value = title,157 onValueChange = { title = it; vm.onTitleChanged(it) },158 singleLine = true,159 textStyle = TextStyle(160 color = peachColors.T0,161 fontFamily = PeachType.Display,162 fontStyle = FontStyle.Italic,163 fontSize = 22.sp,164 ),165 cursorBrush = SolidColor(accent),166 modifier = Modifier.fillMaxWidth(),167 )168 }169 Text(170 "saved.",171 color = peachColors.T2,172 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),173 modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),174 )175 Icon(176 Icons.Outlined.DeleteOutline,177 contentDescription = "delete note",178 tint = peachColors.T2,179 modifier = Modifier.size(36.dp).clip(CircleShape)180 .clickable { vm.delete {} }181 .padding(8.dp),182 )183 }184 }185186 if (e == null) {187 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {188 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)189 }190 return@Column191 }192193 194 Row(195 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),196 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),197 verticalAlignment = Alignment.CenterVertically,198 ) {199 FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }200 FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }201 FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }202 Spacer(Modifier.width(4.dp))203 FormatButton("H") { body = applyLinePrefix(body, "## ") }204 FormatButton("\"") { body = applyLinePrefix(body, "> ") }205 FormatButton("•") { body = applyLinePrefix(body, "- ") }206 FormatButton("1.") { body = applyLinePrefix(body, "1. ") }207 }208209 210 Box(Modifier.fillMaxSize().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 BasicTextField(219 value = body,220 onValueChange = { body = it; vm.onContentChanged(it.text) },221 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),222 cursorBrush = SolidColor(accent),223 modifier = Modifier.fillMaxSize(),224 )225 }226 }227 }228}229230@Composable231private fun NotesToc(232 notes: List<VaultEntry>,233 onBack: () -> Unit,234 onOpenNote: (String) -> Unit,235 onNewNote: () -> Unit,236) {237 var searchVisible by remember { mutableStateOf(false) }238 var query by remember { mutableStateOf("") }239 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }240 val accent = LocalPeachAccent.current241242 ShellBackground {243 Column(244 Modifier245 .fillMaxSize()246 .statusBarsPadding()247 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),248 ) {249 if (searchVisible) {250 Surface(251 modifier = Modifier.fillMaxWidth(),252 color = peachColors.S1,253 shape = RoundedCornerShape(PeachSpacing.lg),254 tonalElevation = 1.dp,255 ) {256 Row(257 Modifier258 .fillMaxWidth()259 .height(56.dp)260 .padding(horizontal = PeachSpacing.sm),261 verticalAlignment = Alignment.CenterVertically,262 ) {263 BasicTextField(264 value = query,265 onValueChange = { query = it },266 singleLine = true,267 textStyle = TextStyle(268 color = peachColors.T0,269 fontFamily = PeachType.Mono,270 fontSize = 14.sp,271 ),272 cursorBrush = SolidColor(accent),273 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),274 decorationBox = { inner ->275 Box {276 if (query.isEmpty()) {277 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)278 }279 inner()280 }281 },282 )283 Icon(284 Icons.Outlined.Close,285 contentDescription = "clear search",286 tint = peachColors.T0,287 modifier = Modifier.size(40.dp).clip(CircleShape)288 .clickable {289 query = ""290 searchVisible = false291 }292 .padding(9.dp),293 )294 }295 }296 } else {297 PeachTopBar(298 title = "notes.",299 leading = {300 Icon(301 Icons.AutoMirrored.Rounded.ArrowBack,302 contentDescription = "back to vault",303 tint = peachColors.T0,304 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))305 .clickable(onClick = onBack),306 )307 },308 actions = {309 Icon(310 Icons.Outlined.Search,311 contentDescription = "search notes",312 tint = peachColors.T0,313 modifier = Modifier.size(28.dp).clip(CircleShape)314 .clickable { searchVisible = true }315 .padding(5.dp),316 )317 },318 )319 }320 Spacer(Modifier.size(PeachSpacing.lg))321322 LazyColumn(323 Modifier.weight(1f),324 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),325 ) {326 if (filtered.isEmpty()) {327 item {328 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {329 PeachEmptyOrEndState(330 message = if (query.isBlank()) "No notes yet." else "No notes match.",331 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",332 )333 }334 }335 } else {336 items(filtered, key = { it.id }) { note ->337 NoteTocRow(note = note, onClick = { onOpenNote(note.id) })338 }339 }340 }341342 Row(343 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)344 .clip(RoundedCornerShape(8.dp))345 .background(peachColors.S2)346 .clickable(onClick = onNewNote)347 .padding(horizontal = 14.dp, vertical = 12.dp),348 verticalAlignment = Alignment.CenterVertically,349 horizontalArrangement = Arrangement.spacedBy(10.dp),350 ) {351 Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))352 Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)353 }354 }355 }356}357358@Composable359private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {360 Column(361 Modifier.fillMaxWidth()362 .clip(RoundedCornerShape(8.dp))363 .background(peachColors.S1)364 .clickable(onClick = onClick)365 .padding(horizontal = 14.dp, vertical = 12.dp),366 verticalArrangement = Arrangement.spacedBy(5.dp),367 ) {368 Text(369 note.title.ifBlank { "Untitled" },370 color = peachColors.T0,371 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),372 )373 val preview = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty()374 Text(375 preview.ifBlank { "No body text" },376 color = peachColors.T2,377 style = PeachType.bodyMono,378 maxLines = 2,379 )380 }381}382383384385386387388private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue {389 val sel = value.selection390 if (sel.collapsed) return value391 val start = sel.min392 val end = sel.max393 val existing = value.annotatedString394 val merged: AnnotatedString = buildAnnotatedString {395 append(existing)396 addStyle(style, start, end)397 }398 return value.copy(annotatedString = merged)399}400401internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {402 val text = value.text403 val sel = value.selection404 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }405 val endExclusive = if (sel.collapsed) {406 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }407 } else {408 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }409 }410 val block = text.substring(start, endExclusive)411 val transformed = block412 .split('\n')413 .joinToString("\n") { line ->414 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line415 }416 val nextText = text.replaceRange(start, endExclusive, transformed)417 val delta = nextText.length - text.length418 return value.copy(419 text = nextText,420 selection = TextRange(421 sel.start.coerceIn(0, nextText.length),422 (sel.end + delta).coerceIn(0, nextText.length),423 ),424 )425}426427internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {428 val q = query.trim().lowercase()429 if (q.isBlank()) return notes430 return notes.filter { note ->431 note.title.lowercase().contains(q) ||432 note.notes.orEmpty().lowercase().contains(q)433 }434}435436@Composable437private fun FormatButton(438 label: String,439 fontWeight: FontWeight? = null,440 fontStyle: FontStyle? = null,441 underline: Boolean = false,442 onClick: () -> Unit,443) {444 Box(445 Modifier446 .size(30.dp)447 .clip(RoundedCornerShape(6.dp))448 .background(peachColors.S2)449 .clickable(onClick = onClick),450 contentAlignment = Alignment.Center,451 ) {452 Text(453 label,454 color = peachColors.T0,455 style = TextStyle(456 fontFamily = PeachType.Body,457 fontSize = 14.sp,458 fontWeight = fontWeight,459 fontStyle = fontStyle,460 textDecoration = if (underline) TextDecoration.Underline else null,461 ),462 )463 }464}