NotesModeScreen.kt

17.8 KB · raw

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.ShellBackground6566/**67 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U68 * formatting toolbar that applies [SpanStyle] to the current selection, a69 * full-width Inter body field, and an 800ms-debounced auto-save with a "saved."70 * pulse. FLAG_SECURE blocks screenshots while open (same as EntryDetail).71 *72 * Span support is intentionally basic (no markdown/persistence of styles): the73 * toolbar styles the live [AnnotatedString] selection; only the plain text is74 * persisted via the ViewModel.75 */76@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    // FLAG_SECURE only while shown.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    // "saved." indicator fades in on each save pulse, holds, then fades out.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            // Top bar: back · editable title · "saved." indicator126            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            // Formatting toolbar: browser-compatible inline and block actions.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            // Content area.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}352353/**354 * Apply [style] to the current selection of [value], preserving the text and355 * existing spans. If the selection is collapsed, the value is returned unchanged356 * (basic span support — styling needs a non-empty selection).357 */358private 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}