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.Surface33import androidx.compose.material3.Text34import androidx.compose.runtime.Composable35import androidx.compose.runtime.LaunchedEffect36import androidx.compose.runtime.getValue37import androidx.compose.runtime.mutableStateOf38import androidx.compose.runtime.remember39import androidx.compose.runtime.setValue40import androidx.compose.ui.Alignment41import androidx.compose.ui.Modifier42import androidx.compose.ui.draw.alpha43import androidx.compose.ui.draw.clip44import androidx.compose.ui.graphics.SolidColor45import androidx.compose.ui.platform.LocalContext46import androidx.compose.ui.text.AnnotatedString47import androidx.compose.ui.text.SpanStyle48import androidx.compose.ui.text.TextStyle49import androidx.compose.ui.text.TextRange50import androidx.compose.ui.text.buildAnnotatedString51import 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.text.withStyle56import androidx.compose.ui.unit.dp57import androidx.compose.ui.unit.sp58import androidx.compose.runtime.collectAsState59import androidx.fragment.app.FragmentActivity60import androidx.hilt.navigation.compose.hiltViewModel61import com.peachpasswords.ui.components.PeachEmptyOrEndState62import com.peachpasswords.ui.components.PeachTopBar63import com.peachpasswords.ui.theme.PeachSpacing64import com.peachpasswords.vault.VaultEntry65import com.peachpasswords.ui.theme.LocalPeachAccent66import com.peachpasswords.ui.theme.peachColors67import com.peachpasswords.ui.theme.PeachType68import com.peachpasswords.ui.theme.ShellBackground697071727374757677787980@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 savedPulse by vm.savedPulse.collectAsState()89 val accent = LocalPeachAccent.current90 val context = LocalContext.current9192 93 androidx.compose.runtime.DisposableEffect(Unit) {94 val window = (context as? FragmentActivity)?.window95 window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)96 onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }97 }9899 100 var showSaved by remember { mutableStateOf(false) }101 LaunchedEffect(savedPulse) {102 if (savedPulse == 0L) return@LaunchedEffect103 showSaved = true104 kotlinx.coroutines.delay(1300)105 showSaved = false106 }107 val savedAlpha by animateFloatAsState(108 targetValue = if (showSaved) 1f else 0f,109 animationSpec = tween(300),110 label = "saved-fade",111 )112113 if (activeNoteId == null) {114 NotesToc(115 notes = notes,116 onBack = onBack,117 onOpenNote = vm::openNote,118 onNewNote = vm::createNote,119 )120 return121 }122123 val e = entry124 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }125 var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }126127 ShellBackground {128 Column(129 Modifier130 .fillMaxSize()131 .statusBarsPadding()132 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),133 ) {134 Surface(135 modifier = Modifier.fillMaxWidth(),136 color = peachColors.S1,137 contentColor = peachColors.T0,138 shape = RoundedCornerShape(PeachSpacing.lg),139 tonalElevation = 1.dp,140 ) {141 Row(142 Modifier143 .fillMaxWidth()144 .height(56.dp)145 .padding(horizontal = PeachSpacing.sm),146 verticalAlignment = Alignment.CenterVertically,147 ) {148 Icon(149 Icons.AutoMirrored.Rounded.ArrowBack,150 contentDescription = "back",151 tint = peachColors.T0,152 modifier = Modifier.size(40.dp).clip(CircleShape)153 .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp),154 )155 Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {156 if (title.isEmpty()) {157 Text(158 "untitled note",159 color = peachColors.T2,160 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),161 )162 }163 BasicTextField(164 value = title,165 onValueChange = { title = it; vm.onTitleChanged(it) },166 singleLine = true,167 textStyle = TextStyle(168 color = peachColors.T0,169 fontFamily = PeachType.Display,170 fontStyle = FontStyle.Italic,171 fontSize = 22.sp,172 ),173 cursorBrush = SolidColor(accent),174 modifier = Modifier.fillMaxWidth(),175 )176 }177 Text(178 "saved.",179 color = peachColors.T2,180 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),181 modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),182 )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 }192 }193194 if (e == null) {195 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {196 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)197 }198 return@Column199 }200201 202 Row(203 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),204 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),205 verticalAlignment = Alignment.CenterVertically,206 ) {207 FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }208 FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }209 FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }210 Spacer(Modifier.width(4.dp))211 FormatButton("H") { body = applyLinePrefix(body, "## ") }212 FormatButton("\"") { body = applyLinePrefix(body, "> ") }213 FormatButton("•") { body = applyLinePrefix(body, "- ") }214 FormatButton("1.") { body = applyLinePrefix(body, "1. ") }215 }216217 218 Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {219 if (body.text.isEmpty()) {220 Text(221 "Start writing…",222 color = peachColors.T2,223 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),224 )225 }226 BasicTextField(227 value = body,228 onValueChange = { body = it; vm.onContentChanged(it.text) },229 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),230 cursorBrush = SolidColor(accent),231 modifier = Modifier.fillMaxSize(),232 )233 }234 }235 }236}237238@Composable239private fun NotesToc(240 notes: List<VaultEntry>,241 onBack: () -> Unit,242 onOpenNote: (String) -> Unit,243 onNewNote: () -> Unit,244) {245 var searchVisible by remember { mutableStateOf(false) }246 var query by remember { mutableStateOf("") }247 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }248 val accent = LocalPeachAccent.current249250 ShellBackground {251 Column(252 Modifier253 .fillMaxSize()254 .statusBarsPadding()255 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),256 ) {257 if (searchVisible) {258 Surface(259 modifier = Modifier.fillMaxWidth(),260 color = peachColors.S1,261 shape = RoundedCornerShape(PeachSpacing.lg),262 tonalElevation = 1.dp,263 ) {264 Row(265 Modifier266 .fillMaxWidth()267 .height(56.dp)268 .padding(horizontal = PeachSpacing.sm),269 verticalAlignment = Alignment.CenterVertically,270 ) {271 BasicTextField(272 value = query,273 onValueChange = { query = it },274 singleLine = true,275 textStyle = TextStyle(276 color = peachColors.T0,277 fontFamily = PeachType.Mono,278 fontSize = 14.sp,279 ),280 cursorBrush = SolidColor(accent),281 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),282 decorationBox = { inner ->283 Box {284 if (query.isEmpty()) {285 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)286 }287 inner()288 }289 },290 )291 Icon(292 Icons.Outlined.Close,293 contentDescription = "clear search",294 tint = peachColors.T0,295 modifier = Modifier.size(40.dp).clip(CircleShape)296 .clickable {297 query = ""298 searchVisible = false299 }300 .padding(9.dp),301 )302 }303 }304 } else {305 PeachTopBar(306 title = "notes.",307 leading = {308 Icon(309 Icons.AutoMirrored.Rounded.ArrowBack,310 contentDescription = "back to vault",311 tint = peachColors.T0,312 modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))313 .clickable(onClick = onBack),314 )315 },316 actions = {317 Icon(318 Icons.Outlined.Search,319 contentDescription = "search notes",320 tint = peachColors.T0,321 modifier = Modifier.size(28.dp).clip(CircleShape)322 .clickable { searchVisible = true }323 .padding(5.dp),324 )325 },326 )327 }328 Spacer(Modifier.size(PeachSpacing.lg))329330 LazyColumn(331 Modifier.weight(1f),332 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),333 ) {334 if (filtered.isEmpty()) {335 item {336 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {337 PeachEmptyOrEndState(338 message = if (query.isBlank()) "No notes yet." else "No notes match.",339 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",340 )341 }342 }343 } else {344 items(filtered, key = { it.id }) { note ->345 NoteTocRow(note = note, onClick = { onOpenNote(note.id) })346 }347 }348 }349350 Row(351 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)352 .clip(RoundedCornerShape(8.dp))353 .background(peachColors.S2)354 .clickable(onClick = onNewNote)355 .padding(horizontal = 14.dp, vertical = 12.dp),356 verticalAlignment = Alignment.CenterVertically,357 horizontalArrangement = Arrangement.spacedBy(10.dp),358 ) {359 Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))360 Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)361 }362 }363 }364}365366@Composable367private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {368 Column(369 Modifier.fillMaxWidth()370 .clip(RoundedCornerShape(8.dp))371 .background(peachColors.S1)372 .clickable(onClick = onClick)373 .padding(horizontal = 14.dp, vertical = 12.dp),374 verticalArrangement = Arrangement.spacedBy(5.dp),375 ) {376 Text(377 note.title.ifBlank { "Untitled" },378 color = peachColors.T0,379 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),380 )381 val preview = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty()382 Text(383 preview.ifBlank { "No body text" },384 color = peachColors.T2,385 style = PeachType.bodyMono,386 maxLines = 2,387 )388 }389}390391392393394395396private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue {397 val sel = value.selection398 if (sel.collapsed) return value399 val start = sel.min400 val end = sel.max401 val existing = value.annotatedString402 val merged: AnnotatedString = buildAnnotatedString {403 append(existing)404 addStyle(style, start, end)405 }406 return value.copy(annotatedString = merged)407}408409internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {410 val text = value.text411 val sel = value.selection412 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }413 val endExclusive = if (sel.collapsed) {414 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }415 } else {416 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }417 }418 val block = text.substring(start, endExclusive)419 val transformed = block420 .split('\n')421 .joinToString("\n") { line ->422 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line423 }424 val nextText = text.replaceRange(start, endExclusive, transformed)425 val delta = nextText.length - text.length426 return value.copy(427 text = nextText,428 selection = TextRange(429 sel.start.coerceIn(0, nextText.length),430 (sel.end + delta).coerceIn(0, nextText.length),431 ),432 )433}434435internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {436 val q = query.trim().lowercase()437 if (q.isBlank()) return notes438 return notes.filter { note ->439 note.title.lowercase().contains(q) ||440 note.notes.orEmpty().lowercase().contains(q)441 }442}443444@Composable445private fun FormatButton(446 label: String,447 fontWeight: FontWeight? = null,448 fontStyle: FontStyle? = null,449 underline: Boolean = false,450 onClick: () -> Unit,451) {452 Box(453 Modifier454 .size(30.dp)455 .clip(RoundedCornerShape(6.dp))456 .background(peachColors.S2)457 .clickable(onClick = onClick),458 contentAlignment = Alignment.Center,459 ) {460 Text(461 label,462 color = peachColors.T0,463 style = TextStyle(464 fontFamily = PeachType.Body,465 fontSize = 14.sp,466 fontWeight = fontWeight,467 fontStyle = fontStyle,468 textDecoration = if (underline) TextDecoration.Underline else null,469 ),470 )471 }472}