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.PeachNav61import 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 onNavigate: (PeachNav) -> Unit,84 vm: NotesModeViewModel = hiltViewModel(),85) {86 val entry by vm.entry.collectAsState()87 val notes by vm.notes.collectAsState()88 val activeNoteId by vm.activeNoteId.collectAsState()89 val isMultiSelect by vm.isMultiSelectMode.collectAsState()90 val selectedIds by vm.selectedIds.collectAsState()91 val savedPulse by vm.savedPulse.collectAsState()92 val accent = LocalPeachAccent.current9394 SecureFlagEffect()9596 97 var showSaved by remember { mutableStateOf(false) }98 LaunchedEffect(savedPulse) {99 if (savedPulse == 0L) return@LaunchedEffect100 showSaved = true101 kotlinx.coroutines.delay(1300)102 showSaved = false103 }104 val savedAlpha by animateFloatAsState(105 targetValue = if (showSaved) 1f else 0f,106 animationSpec = tween(300),107 label = "saved-fade",108 )109110 if (activeNoteId == null) {111 NotesToc(112 notes = notes,113 onBack = onBack,114 onOpenNote = vm::openNote,115 onNewNote = vm::createNote,116 isMultiSelect = isMultiSelect,117 selectedIds = selectedIds,118 onEnterMultiSelect = vm::enterMultiSelect,119 onToggleSelected = vm::toggleSelected,120 onDeleteSelected = vm::batchDelete,121 onCancelMultiSelect = vm::exitMultiSelect,122 onNavigate = onNavigate,123 onLock = vm::lock,124 )125 return126 }127128 val e = entry129 val stored = e?.notes.orEmpty()130 var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }131 var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }132 133 134 var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }135 var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }136137 ShellBackground {138 Column(139 Modifier140 .fillMaxSize()141 .statusBarsPadding()142 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),143 ) {144 Surface(145 modifier = Modifier.fillMaxWidth(),146 color = peachColors.S1,147 contentColor = peachColors.T0,148 shape = RoundedCornerShape(PeachSpacing.lg),149 tonalElevation = 1.dp,150 ) {151 Row(152 Modifier153 .fillMaxWidth()154 .height(56.dp)155 .padding(horizontal = PeachSpacing.sm),156 verticalAlignment = Alignment.CenterVertically,157 ) {158 Icon(159 Icons.AutoMirrored.Rounded.ArrowBack,160 contentDescription = "back",161 tint = peachColors.T0,162 modifier = Modifier.size(40.dp).clip(CircleShape)163 .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }164 .padding(8.dp),165 )166 Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {167 if (title.isEmpty()) {168 Text(169 "untitled note",170 color = peachColors.T2,171 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),172 )173 }174 BasicTextField(175 value = title,176 onValueChange = { title = it; vm.onTitleChanged(it) },177 singleLine = true,178 textStyle = TextStyle(179 color = peachColors.T0,180 fontFamily = PeachType.Display,181 fontStyle = FontStyle.Italic,182 fontSize = 22.sp,183 ),184 cursorBrush = SolidColor(accent),185 modifier = Modifier.fillMaxWidth(),186 )187 }188 Text(189 "saved.",190 color = peachColors.T2,191 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),192 modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),193 )194 Icon(195 Icons.Outlined.DeleteOutline,196 contentDescription = "delete note",197 tint = peachColors.T2,198 modifier = Modifier.size(36.dp).clip(CircleShape)199 .clickable { vm.delete {} }200 .padding(8.dp),201 )202 }203 }204205 if (e == null) {206 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {207 Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)208 }209 return@Column210 }211212 213 214 if (editing) {215 Row(216 Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),217 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),218 verticalAlignment = Alignment.CenterVertically,219 ) {220 fun format(next: TextFieldValue) {221 body = next222 isHtml = true223 vm.onContentChanged(noteStorageText(next.text, isHtml = true))224 }225 FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }226 FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }227 FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }228 Spacer(Modifier.width(4.dp))229 FormatButton("H") { format(applyLinePrefix(body, "## ")) }230 FormatButton("•") { format(applyLinePrefix(body, "- ")) }231 FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }232 }233 }234235 236 Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {237 if (body.text.isEmpty()) {238 Text(239 "Start writing…",240 color = peachColors.T2,241 style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),242 )243 }244 if (editing) {245 BasicTextField(246 value = body,247 onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },248 textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),249 cursorBrush = SolidColor(accent),250 modifier = Modifier.fillMaxSize(),251 )252 } else {253 Text(254 noteRendered(body.text, isHtml),255 color = peachColors.T0,256 style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),257 modifier = Modifier.fillMaxSize().clickable { editing = true },258 )259 }260 }261 }262 }263}264265@Composable266private fun NotesToc(267 notes: List<VaultEntry>,268 onBack: () -> Unit,269 onOpenNote: (String) -> Unit,270 onNewNote: () -> Unit,271 isMultiSelect: Boolean,272 selectedIds: Set<String>,273 onEnterMultiSelect: (String) -> Unit,274 onToggleSelected: (String) -> Unit,275 onDeleteSelected: () -> Unit,276 onCancelMultiSelect: () -> Unit,277 onNavigate: (PeachNav) -> Unit,278 onLock: () -> Unit,279) {280 var searchVisible by remember { mutableStateOf(false) }281 var query by remember { mutableStateOf("") }282 val filtered = remember(notes, query) { filterNotesForToc(notes, query) }283 val accent = LocalPeachAccent.current284 BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)285286 ShellBackground {287 Column(288 Modifier289 .fillMaxSize()290 .statusBarsPadding()291 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),292 ) {293 if (isMultiSelect) {294 MultiSelectTopBar(295 count = selectedIds.size,296 onDelete = onDeleteSelected,297 onCancel = onCancelMultiSelect,298 includeStatusBarPadding = false,299 )300 } else if (searchVisible) {301 Surface(302 modifier = Modifier.fillMaxWidth(),303 color = peachColors.S1,304 shape = RoundedCornerShape(PeachSpacing.lg),305 tonalElevation = 1.dp,306 ) {307 Row(308 Modifier309 .fillMaxWidth()310 .height(56.dp)311 .padding(horizontal = PeachSpacing.sm),312 verticalAlignment = Alignment.CenterVertically,313 ) {314 BasicTextField(315 value = query,316 onValueChange = { query = it },317 singleLine = true,318 textStyle = TextStyle(319 color = peachColors.T0,320 fontFamily = PeachType.Mono,321 fontSize = 14.sp,322 ),323 cursorBrush = SolidColor(accent),324 modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),325 decorationBox = { inner ->326 Box {327 if (query.isEmpty()) {328 Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)329 }330 inner()331 }332 },333 )334 Icon(335 Icons.Outlined.Close,336 contentDescription = "clear search",337 tint = peachColors.T0,338 modifier = Modifier.size(40.dp).clip(CircleShape)339 .clickable {340 query = ""341 searchVisible = false342 }343 .padding(9.dp),344 )345 }346 }347 } else PeachVaultIdentity("notes")348 Spacer(Modifier.size(PeachSpacing.lg))349350 LazyColumn(351 Modifier.weight(1f),352 verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),353 ) {354 if (filtered.isEmpty()) {355 item {356 Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {357 PeachEmptyOrEndState(358 message = if (query.isBlank()) "No notes yet." else "No notes match.",359 detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",360 )361 }362 }363 } else {364 items(filtered, key = { it.id }) { note ->365 NoteTocRow(366 note = note,367 multiSelect = isMultiSelect,368 selected = note.id in selectedIds,369 onClick = {370 if (isMultiSelect) onToggleSelected(note.id)371 else onOpenNote(note.id)372 },373 onLongClick = {374 if (isMultiSelect) onToggleSelected(note.id)375 else onEnterMultiSelect(note.id)376 },377 )378 }379 }380 }381382 if (!isMultiSelect) {383 PeachDeckBottomBar(384 onSearch = { searchVisible = true },385 onAdd = onNewNote,386 onNotes = {},387 onSettings = { onNavigate(PeachNav.Settings) },388 onLock = onLock,389 onSwipeUp = onBack,390 notesSelected = true,391 )392 }393394 }395 }396}397398@OptIn(ExperimentalFoundationApi::class)399@Composable400private fun NoteTocRow(401 note: VaultEntry,402 multiSelect: Boolean,403 selected: Boolean,404 onClick: () -> Unit,405 onLongClick: () -> Unit,406) {407 val context = LocalContext.current408 val accent = LocalPeachAccent.current409 Row(410 Modifier.fillMaxWidth()411 .clip(RoundedCornerShape(8.dp))412 .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)413 .combinedClickable(414 onClick = onClick,415 onLongClickLabel = "Select note",416 onLongClick = {417 PeachHaptics.onLongPress(context)418 onLongClick()419 },420 )421 .padding(horizontal = 14.dp, vertical = 12.dp),422 horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),423 verticalAlignment = Alignment.CenterVertically,424 ) {425 if (multiSelect) MultiSelectCheckbox(checked = selected)426 Column(427 Modifier.weight(1f),428 verticalArrangement = Arrangement.spacedBy(5.dp),429 ) {430 Text(431 note.title.ifBlank { "Untitled" },432 color = peachColors.T0,433 style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),434 )435 val preview = notePreviewLine(note.notes.orEmpty())436 Text(437 preview.ifBlank { "No body text" },438 color = peachColors.T2,439 style = PeachType.bodyMono,440 maxLines = 2,441 )442 }443 }444}445446447448449450451internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {452 val sel = value.selection453 if (sel.collapsed) return value454 val text = value.text455 val next = text.substring(0, sel.min) + marker +456 text.substring(sel.min, sel.max) + marker +457 text.substring(sel.max)458 return value.copy(459 text = next,460 selection = TextRange(sel.min, sel.max + 2 * marker.length),461 )462}463464internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {465 val text = value.text466 val sel = value.selection467 val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }468 val endExclusive = if (sel.collapsed) {469 text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }470 } else {471 text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }472 }473 val block = text.substring(start, endExclusive)474 val transformed = block475 .split('\n')476 .joinToString("\n") { line ->477 if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line478 }479 val nextText = text.replaceRange(start, endExclusive, transformed)480 val delta = nextText.length - text.length481 return value.copy(482 text = nextText,483 selection = TextRange(484 sel.start.coerceIn(0, nextText.length),485 (sel.end + delta).coerceIn(0, nextText.length),486 ),487 )488}489490internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {491 val q = query.trim().lowercase()492 if (q.isBlank()) return notes493 return notes.filter { note ->494 note.title.lowercase().contains(q) ||495 notePlainText(note.notes.orEmpty()).lowercase().contains(q)496 }497}498499@Composable500private fun FormatButton(501 label: String,502 fontWeight: FontWeight? = null,503 fontStyle: FontStyle? = null,504 underline: Boolean = false,505 onClick: () -> Unit,506) {507 Box(508 Modifier509 .size(30.dp)510 .clip(RoundedCornerShape(6.dp))511 .background(peachColors.S2)512 .clickable(onClick = onClick),513 contentAlignment = Alignment.Center,514 ) {515 Text(516 label,517 color = peachColors.T0,518 style = TextStyle(519 fontFamily = PeachType.Body,520 fontSize = 14.sp,521 fontWeight = fontWeight,522 fontStyle = fontStyle,523 textDecoration = if (underline) TextDecoration.Underline else null,524 ),525 )526 }527}