NotesModeScreen.kt

19.8 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.TextStyle45import androidx.compose.ui.text.TextRange46import androidx.compose.ui.text.font.FontStyle47import androidx.compose.ui.text.font.FontWeight48import androidx.compose.ui.text.input.TextFieldValue49import androidx.compose.ui.text.style.TextDecoration50import androidx.compose.ui.unit.dp51import androidx.compose.ui.unit.sp52import androidx.compose.runtime.collectAsState53import androidx.hilt.navigation.compose.hiltViewModel54import com.peachpasswords.ui.components.PeachEmptyOrEndState55import com.peachpasswords.ui.components.PeachTopBar56import com.peachpasswords.ui.components.SecureFlagEffect57import com.peachpasswords.ui.theme.PeachSpacing58import com.peachpasswords.vault.VaultEntry59import com.peachpasswords.ui.theme.LocalPeachAccent60import com.peachpasswords.ui.theme.peachColors61import com.peachpasswords.ui.theme.PeachType62import com.peachpasswords.ui.theme.ShellBackground6364/**65 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U66 * formatting toolbar, a full-width Inter body, and an 800ms-debounced auto-save67 * with a "saved." pulse. FLAG_SECURE blocks screenshots while open (same as68 * EntryDetail).69 *70 * The body opens rendered (see NoteHtml.kt) and switches to the editor on tap,71 * because a note written in the browser is HTML and Compose has no text field72 * that can edit styled text without dropping the styles. Formatting typed here73 * is saved back as the browser's own tag vocabulary.74 */75@Composable76fun NotesModeScreen(77    onBack: () -> Unit,78    vm: NotesModeViewModel = hiltViewModel(),79) {80    val entry by vm.entry.collectAsState()81    val notes by vm.notes.collectAsState()82    val activeNoteId by vm.activeNoteId.collectAsState()83    val savedPulse by vm.savedPulse.collectAsState()84    val accent = LocalPeachAccent.current8586    SecureFlagEffect()8788    // "saved." indicator fades in on each save pulse, holds, then fades out.89    var showSaved by remember { mutableStateOf(false) }90    LaunchedEffect(savedPulse) {91        if (savedPulse == 0L) return@LaunchedEffect92        showSaved = true93        kotlinx.coroutines.delay(1300)94        showSaved = false95    }96    val savedAlpha by animateFloatAsState(97        targetValue = if (showSaved) 1f else 0f,98        animationSpec = tween(300),99        label = "saved-fade",100    )101102    if (activeNoteId == null) {103        NotesToc(104            notes = notes,105            onBack = onBack,106            onOpenNote = vm::openNote,107            onNewNote = vm::createNote,108        )109        return110    }111112    val e = entry113    val stored = e?.notes.orEmpty()114    var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }115    var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }116    // A note stays plain text until the user asks for formatting, so typing117    // "- eggs" into a shopping list never turns it into a <ul> behind their back.118    var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }119    var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }120121    ShellBackground {122        Column(123            Modifier124                .fillMaxSize()125                .statusBarsPadding()126                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),127        ) {128            Surface(129                modifier = Modifier.fillMaxWidth(),130                color = peachColors.S1,131                contentColor = peachColors.T0,132                shape = RoundedCornerShape(PeachSpacing.lg),133                tonalElevation = 1.dp,134            ) {135                Row(136                    Modifier137                        .fillMaxWidth()138                        .height(56.dp)139                        .padding(horizontal = PeachSpacing.sm),140                    verticalAlignment = Alignment.CenterVertically,141                ) {142                    Icon(143                        Icons.AutoMirrored.Rounded.ArrowBack,144                        contentDescription = "back",145                        tint = peachColors.T0,146                        modifier = Modifier.size(40.dp).clip(CircleShape)147                            .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }148                            .padding(8.dp),149                    )150                    Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {151                        if (title.isEmpty()) {152                            Text(153                                "untitled note",154                                color = peachColors.T2,155                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),156                            )157                        }158                        BasicTextField(159                            value = title,160                            onValueChange = { title = it; vm.onTitleChanged(it) },161                            singleLine = true,162                            textStyle = TextStyle(163                                color = peachColors.T0,164                                fontFamily = PeachType.Display,165                                fontStyle = FontStyle.Italic,166                                fontSize = 22.sp,167                            ),168                            cursorBrush = SolidColor(accent),169                            modifier = Modifier.fillMaxWidth(),170                        )171                    }172                    Text(173                        "saved.",174                        color = peachColors.T2,175                        style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),176                        modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),177                    )178                    Icon(179                        Icons.Outlined.DeleteOutline,180                        contentDescription = "delete note",181                        tint = peachColors.T2,182                        modifier = Modifier.size(36.dp).clip(CircleShape)183                            .clickable { vm.delete {} }184                            .padding(8.dp),185                    )186                }187            }188189            if (e == null) {190                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {191                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)192                }193                return@Column194            }195196            // Formatting toolbar: every action writes the markup that saves back197            // as the browser's own tags, so formatting survives a phone edit.198            if (editing) {199                Row(200                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),201                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),202                    verticalAlignment = Alignment.CenterVertically,203                ) {204                    fun format(next: TextFieldValue) {205                        body = next206                        isHtml = true207                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))208                    }209                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }210                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }211                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }212                    Spacer(Modifier.width(4.dp))213                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }214                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }215                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }216                }217            }218219            // Content area.220            Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {221                if (body.text.isEmpty()) {222                    Text(223                        "Start writing…",224                        color = peachColors.T2,225                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),226                    )227                }228                if (editing) {229                    BasicTextField(230                        value = body,231                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },232                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),233                        cursorBrush = SolidColor(accent),234                        modifier = Modifier.fillMaxSize(),235                    )236                } else {237                    Text(238                        noteRendered(body.text, isHtml),239                        color = peachColors.T0,240                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),241                        modifier = Modifier.fillMaxSize().clickable { editing = true },242                    )243                }244            }245        }246    }247}248249@Composable250private fun NotesToc(251    notes: List<VaultEntry>,252    onBack: () -> Unit,253    onOpenNote: (String) -> Unit,254    onNewNote: () -> Unit,255) {256    var searchVisible by remember { mutableStateOf(false) }257    var query by remember { mutableStateOf("") }258    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }259    val accent = LocalPeachAccent.current260261    ShellBackground {262        Column(263            Modifier264                .fillMaxSize()265                .statusBarsPadding()266                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),267        ) {268            if (searchVisible) {269                Surface(270                    modifier = Modifier.fillMaxWidth(),271                    color = peachColors.S1,272                    shape = RoundedCornerShape(PeachSpacing.lg),273                    tonalElevation = 1.dp,274                ) {275                    Row(276                        Modifier277                            .fillMaxWidth()278                            .height(56.dp)279                            .padding(horizontal = PeachSpacing.sm),280                        verticalAlignment = Alignment.CenterVertically,281                    ) {282                        BasicTextField(283                            value = query,284                            onValueChange = { query = it },285                            singleLine = true,286                            textStyle = TextStyle(287                                color = peachColors.T0,288                                fontFamily = PeachType.Mono,289                                fontSize = 14.sp,290                            ),291                            cursorBrush = SolidColor(accent),292                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),293                            decorationBox = { inner ->294                                Box {295                                    if (query.isEmpty()) {296                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)297                                    }298                                    inner()299                                }300                            },301                        )302                        Icon(303                            Icons.Outlined.Close,304                            contentDescription = "clear search",305                            tint = peachColors.T0,306                            modifier = Modifier.size(40.dp).clip(CircleShape)307                                .clickable {308                                    query = ""309                                    searchVisible = false310                                }311                                .padding(9.dp),312                        )313                    }314                }315            } else {316                PeachTopBar(317                    title = "notes.",318                    leading = {319                        Icon(320                            Icons.AutoMirrored.Rounded.ArrowBack,321                            contentDescription = "back to vault",322                            tint = peachColors.T0,323                            modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))324                                .clickable(onClick = onBack),325                        )326                    },327                    actions = {328                        Icon(329                            Icons.Outlined.Search,330                            contentDescription = "search notes",331                            tint = peachColors.T0,332                            modifier = Modifier.size(28.dp).clip(CircleShape)333                                .clickable { searchVisible = true }334                                .padding(5.dp),335                        )336                    },337                )338            }339            Spacer(Modifier.size(PeachSpacing.lg))340341            LazyColumn(342                Modifier.weight(1f),343                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),344            ) {345                if (filtered.isEmpty()) {346                    item {347                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {348                            PeachEmptyOrEndState(349                                message = if (query.isBlank()) "No notes yet." else "No notes match.",350                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",351                            )352                        }353                    }354                } else {355                    items(filtered, key = { it.id }) { note ->356                        NoteTocRow(note = note, onClick = { onOpenNote(note.id) })357                    }358                }359            }360361            Row(362                Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)363                    .clip(RoundedCornerShape(8.dp))364                    .background(peachColors.S2)365                    .clickable(onClick = onNewNote)366                    .padding(horizontal = 14.dp, vertical = 12.dp),367                verticalAlignment = Alignment.CenterVertically,368                horizontalArrangement = Arrangement.spacedBy(10.dp),369            ) {370                Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))371                Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)372            }373        }374    }375}376377@Composable378private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {379    Column(380        Modifier.fillMaxWidth()381            .clip(RoundedCornerShape(8.dp))382            .background(peachColors.S1)383            .clickable(onClick = onClick)384            .padding(horizontal = 14.dp, vertical = 12.dp),385        verticalArrangement = Arrangement.spacedBy(5.dp),386    ) {387        Text(388            note.title.ifBlank { "Untitled" },389            color = peachColors.T0,390            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),391        )392        val preview = notePreviewLine(note.notes.orEmpty())393        Text(394            preview.ifBlank { "No body text" },395            color = peachColors.T2,396            style = PeachType.bodyMono,397            maxLines = 2,398        )399    }400}401402/**403 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises404 * back to a tag). Collapsed selections are returned unchanged — there is nothing405 * to mark.406 */407internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {408    val sel = value.selection409    if (sel.collapsed) return value410    val text = value.text411    val next = text.substring(0, sel.min) + marker +412        text.substring(sel.min, sel.max) + marker +413        text.substring(sel.max)414    return value.copy(415        text = next,416        selection = TextRange(sel.min, sel.max + 2 * marker.length),417    )418}419420internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {421    val text = value.text422    val sel = value.selection423    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }424    val endExclusive = if (sel.collapsed) {425        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }426    } else {427        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }428    }429    val block = text.substring(start, endExclusive)430    val transformed = block431        .split('\n')432        .joinToString("\n") { line ->433            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line434        }435    val nextText = text.replaceRange(start, endExclusive, transformed)436    val delta = nextText.length - text.length437    return value.copy(438        text = nextText,439        selection = TextRange(440            sel.start.coerceIn(0, nextText.length),441            (sel.end + delta).coerceIn(0, nextText.length),442        ),443    )444}445446internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {447    val q = query.trim().lowercase()448    if (q.isBlank()) return notes449    return notes.filter { note ->450        note.title.lowercase().contains(q) ||451            notePlainText(note.notes.orEmpty()).lowercase().contains(q)452    }453}454455@Composable456private fun FormatButton(457    label: String,458    fontWeight: FontWeight? = null,459    fontStyle: FontStyle? = null,460    underline: Boolean = false,461    onClick: () -> Unit,462) {463    Box(464        Modifier465            .size(30.dp)466            .clip(RoundedCornerShape(6.dp))467            .background(peachColors.S2)468            .clickable(onClick = onClick),469        contentAlignment = Alignment.Center,470    ) {471        Text(472            label,473            color = peachColors.T0,474            style = TextStyle(475                fontFamily = PeachType.Body,476                fontSize = 14.sp,477                fontWeight = fontWeight,478                fontStyle = fontStyle,479                textDecoration = if (underline) TextDecoration.Underline else null,480            ),481        )482    }483}