NotesModeScreen.kt

18.9 KB · raw

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.ShellBackground6768/**69 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U70 * formatting toolbar that applies [SpanStyle] to the current selection, a71 * full-width Inter body field, and an 800ms-debounced auto-save with a "saved."72 * pulse. FLAG_SECURE blocks screenshots while open (same as EntryDetail).73 *74 * Span support is intentionally basic (no markdown/persistence of styles): the75 * toolbar styles the live [AnnotatedString] selection; only the plain text is76 * persisted via the ViewModel.77 */78@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    // "saved." indicator fades in on each save pulse, holds, then fades out.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            // Formatting toolbar: browser-compatible inline and block actions.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            // Content area.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}382383/**384 * Apply [style] to the current selection of [value], preserving the text and385 * existing spans. If the selection is collapsed, the value is returned unchanged386 * (basic span support — styling needs a non-empty selection).387 */388private 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}