1package com.peachpasswords.ui.vault23import android.view.WindowManager4import androidx.compose.animation.core.animateFloatAsState5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.clickable8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.layout.statusBarsPadding19import androidx.compose.foundation.layout.width20import androidx.compose.foundation.lazy.LazyColumn21import androidx.compose.foundation.lazy.items22import androidx.compose.foundation.shape.CircleShape23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.material.icons.Icons26import androidx.compose.material.icons.automirrored.rounded.ArrowBack27import androidx.compose.material.icons.outlined.Add28import androidx.compose.material.icons.outlined.Close29import androidx.compose.material.icons.outlined.DeleteOutline30import androidx.compose.material.icons.outlined.Search31import androidx.compose.material3.Icon32import 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.platform.LocalContext45import androidx.compose.ui.text.AnnotatedString46import androidx.compose.ui.text.SpanStyle47import androidx.compose.ui.text.TextStyle48import androidx.compose.ui.text.TextRange49import androidx.compose.ui.text.buildAnnotatedString50import 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.text.withStyle55import androidx.compose.ui.unit.dp56import androidx.compose.ui.unit.sp57import androidx.compose.runtime.collectAsState58import androidx.fragment.app.FragmentActivity59import androidx.hilt.navigation.compose.hiltViewModel60import com.peachpasswords.vault.VaultEntry61import com.peachpasswords.ui.theme.LocalPeachAccent62import com.peachpasswords.ui.theme.peachColors63import com.peachpasswords.ui.theme.PeachType64import com.peachpasswords.ui.theme.ShellBackground656667686970717273747576@Composable77fun NotesModeScreen(78 onBack: () -> Unit,79 vm: NotesModeViewModel = hiltViewModel(),80) {81 val entry by vm.entry.collectAsState()82 val notes by vm.notes.collectAsState()83 val activeNoteId by vm.activeNoteId.collectAsState()84 val savedPulse by vm.savedPulse.collectAsState()85 val accent = LocalPeachAccent.current86 val context = LocalContext.current8788 89 androidx.compose.runtime.DisposableEffect(Unit) {90 val window = (context as? FragmentActivity)?.window91 window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)92 onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }93 }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 )116 return117 }118119 val e = entry120 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }121 var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }122123 ShellBackground {124 Column(Modifier.fillMaxSize().statusBarsPadding()) {125 126 Row(127 Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),128 verticalAlignment = Alignment.CenterVertically,129 ) {130 Icon(131 Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,132 modifier = Modifier.size(40.dp).clip(CircleShape)133 .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp),134 )135 Box(Modifier.weight(1f).padding(horizontal = 4.dp)) {136 if (title.isEmpty()) {137 Text(138 "untitled note",139 color = peachColors.T2,140 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),141 )142 }143 BasicTextField(144 value = title,145 onValueChange = { title = it; vm.onTitleChanged(it) },146 singleLine = true,147 textStyle = TextStyle(148 color = peachColors.T0,149 fontFamily = PeachType.Display,150 fontStyle = FontStyle.Italic,151 fontSize = 22.sp,152 ),153 cursorBrush = SolidColor(accent),154 modifier = Modifier.fillMaxWidth(),155 )156 }157 Text(158 "saved.",159 color = peachColors.T2,160 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),161 modifier = Modifier.padding(end = 8.dp).alpha(savedAlpha),162 )163 Icon(164 Icons.Outlined.DeleteOutline,165 contentDescription = "delete note",166 tint = peachColors.T2,167 modifier = Modifier.size(36.dp).clip(CircleShape)168 .clickable { vm.delete {} }169 .padding(8.dp),170 )171 }172173 if (e == null) {174 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {175 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)176 }177 return@Column178 }179180 181 Row(182 Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),183 horizontalArrangement = Arrangement.spacedBy(6.dp),184 verticalAlignment = Alignment.CenterVertically,185 ) {186 FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }187 FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }188 FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }189 Spacer(Modifier.width(4.dp))190 FormatButton("H") { body = applyLinePrefix(body, "## ") }191 FormatButton("\"") { body = applyLinePrefix(body, "> ") }192 FormatButton("•") { body = applyLinePrefix(body, "- ") }193 FormatButton("1.") { body = applyLinePrefix(body, "1. ") }194 }195196 197 Box(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp)) {198 if (body.text.isEmpty()) {199 Text(200 "Start writing…",201 color = peachColors.T2,202 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),203 )204 }205 BasicTextField(206 value = body,207 onValueChange = { body = it; vm.onContentChanged(it.text) },208 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),209 cursorBrush = SolidColor(accent),210 modifier = Modifier.fillMaxSize(),211 )212 }213 }214 }215}216217@Composable218private fun NotesToc(219 notes: List<VaultEntry>,220 onBack: () -> Unit,221 onOpenNote: (String) -> Unit,222 onNewNote: () -> Unit,223) {224 var searchVisible by remember { mutableStateOf(false) }225 var query by remember { mutableStateOf("") }226 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }227 val accent = LocalPeachAccent.current228229 ShellBackground {230 Column(Modifier.fillMaxSize().statusBarsPadding()) {231 Row(232 Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),233 verticalAlignment = Alignment.CenterVertically,234 ) {235 if (searchVisible) {236 BasicTextField(237 value = query,238 onValueChange = { query = it },239 singleLine = true,240 textStyle = TextStyle(241 color = peachColors.T0,242 fontFamily = PeachType.Mono,243 fontSize = 14.sp,244 ),245 cursorBrush = SolidColor(accent),246 modifier = Modifier.weight(1f).padding(horizontal = 12.dp),247 decorationBox = { inner ->248 Box {249 if (query.isEmpty()) {250 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)251 }252 inner()253 }254 },255 )256 Icon(257 Icons.Outlined.Close,258 contentDescription = "clear search",259 tint = peachColors.T0,260 modifier = Modifier.size(40.dp).clip(CircleShape)261 .clickable {262 query = ""263 searchVisible = false264 }265 .padding(9.dp),266 )267 } else {268 Icon(269 Icons.AutoMirrored.Rounded.ArrowBack,270 contentDescription = "back to vault",271 tint = peachColors.T0,272 modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onBack).padding(8.dp),273 )274 Text(275 "notes.",276 color = peachColors.T0,277 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 24.sp),278 modifier = Modifier.weight(1f).padding(horizontal = 8.dp),279 )280 Icon(281 Icons.Outlined.Search,282 contentDescription = "search notes",283 tint = peachColors.T0,284 modifier = Modifier.size(40.dp).clip(CircleShape)285 .clickable { searchVisible = true }286 .padding(9.dp),287 )288 }289 }290291 LazyColumn(292 Modifier.weight(1f).padding(horizontal = 16.dp),293 verticalArrangement = Arrangement.spacedBy(8.dp),294 ) {295 if (filtered.isEmpty()) {296 item {297 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {298 Text(299 if (query.isBlank()) "No notes yet.\nStart writing." else "No notes match.",300 color = peachColors.T1,301 style = PeachType.bodyMono,302 )303 }304 }305 } else {306 items(filtered, key = { it.id }) { note ->307 NoteTocRow(note = note, onClick = { onOpenNote(note.id) })308 }309 }310 }311312 Row(313 Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp)314 .clip(RoundedCornerShape(8.dp))315 .background(peachColors.S2)316 .clickable(onClick = onNewNote)317 .padding(horizontal = 14.dp, vertical = 12.dp),318 verticalAlignment = Alignment.CenterVertically,319 horizontalArrangement = Arrangement.spacedBy(10.dp),320 ) {321 Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))322 Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)323 }324 }325 }326}327328@Composable329private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {330 Column(331 Modifier.fillMaxWidth()332 .clip(RoundedCornerShape(8.dp))333 .background(peachColors.S1)334 .clickable(onClick = onClick)335 .padding(horizontal = 14.dp, vertical = 12.dp),336 verticalArrangement = Arrangement.spacedBy(5.dp),337 ) {338 Text(339 note.title.ifBlank { "Untitled" },340 color = peachColors.T0,341 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),342 )343 val preview = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty()344 Text(345 preview.ifBlank { "No body text" },346 color = peachColors.T2,347 style = PeachType.bodyMono,348 maxLines = 2,349 )350 }351}352353354355356357358private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue {359 val sel = value.selection360 if (sel.collapsed) return value361 val start = sel.min362 val end = sel.max363 val existing = value.annotatedString364 val merged: AnnotatedString = buildAnnotatedString {365 append(existing)366 addStyle(style, start, end)367 }368 return value.copy(annotatedString = merged)369}370371internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {372 val text = value.text373 val sel = value.selection374 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }375 val endExclusive = if (sel.collapsed) {376 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }377 } else {378 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }379 }380 val block = text.substring(start, endExclusive)381 val transformed = block382 .split('\n')383 .joinToString("\n") { line ->384 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line385 }386 val nextText = text.replaceRange(start, endExclusive, transformed)387 val delta = nextText.length - text.length388 return value.copy(389 text = nextText,390 selection = TextRange(391 sel.start.coerceIn(0, nextText.length),392 (sel.end + delta).coerceIn(0, nextText.length),393 ),394 )395}396397internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {398 val q = query.trim().lowercase()399 if (q.isBlank()) return notes400 return notes.filter { note ->401 note.title.lowercase().contains(q) ||402 note.notes.orEmpty().lowercase().contains(q)403 }404}405406@Composable407private fun FormatButton(408 label: String,409 fontWeight: FontWeight? = null,410 fontStyle: FontStyle? = null,411 underline: Boolean = false,412 onClick: () -> Unit,413) {414 Box(415 Modifier416 .size(30.dp)417 .clip(RoundedCornerShape(6.dp))418 .background(peachColors.S2)419 .clickable(onClick = onClick),420 contentAlignment = Alignment.Center,421 ) {422 Text(423 label,424 color = peachColors.T0,425 style = TextStyle(426 fontFamily = PeachType.Body,427 fontSize = 14.sp,428 fontWeight = fontWeight,429 fontStyle = fontStyle,430 textDecoration = if (underline) TextDecoration.Underline else null,431 ),432 )433 }434}