NotesModeScreen.kt

20.8 KB · raw

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.ShellBackground6869/**70 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U71 * formatting toolbar, a full-width Inter body, and an 800ms-debounced auto-save72 * with a "saved." pulse. FLAG_SECURE blocks screenshots while open (same as73 * EntryDetail).74 *75 * The body opens rendered (see NoteHtml.kt) and switches to the editor on tap,76 * because a note written in the browser is HTML and Compose has no text field77 * that can edit styled text without dropping the styles. Formatting typed here78 * is saved back as the browser's own tag vocabulary.79 */80@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    // "saved." indicator fades in on each save pulse, holds, then fades out.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    // A note stays plain text until the user asks for formatting, so typing133    // "- eggs" into a shopping list never turns it into a <ul> behind their back.134    var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }135    var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }136    fun finishNote(onDone: () -> Unit) = vm.finishNote(title, noteStorageText(body.text, isHtml), onDone)137    BackHandler { finishNote(vm::showToc) }138139    ShellBackground {140        Column(141            Modifier142                .fillMaxSize()143                .statusBarsPadding()144                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),145        ) {146            PeachVaultIdentity("notes") { finishNote { onNavigate(PeachNav.VaultList) } }147            FieldShell("title") {148                    Box(Modifier.weight(1f)) {149                        if (title.isEmpty()) {150                            Text(151                                "untitled note",152                                color = peachColors.T2,153                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),154                            )155                        }156                        BasicTextField(157                            value = title,158                            onValueChange = { title = it; vm.onTitleChanged(it) },159                            singleLine = true,160                            textStyle = TextStyle(161                                color = peachColors.T0,162                                fontFamily = PeachType.Display,163                                fontStyle = FontStyle.Italic,164                                fontSize = 22.sp,165                            ),166                            cursorBrush = SolidColor(accent),167                            modifier = Modifier.fillMaxWidth(),168                        )169                    }170                    Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))171                    Icon(172                        Icons.Outlined.DeleteOutline,173                        contentDescription = "delete note",174                        tint = peachColors.T2,175                        modifier = Modifier.size(36.dp).clip(CircleShape)176                            .clickable { vm.delete {} }177                            .padding(8.dp),178                    )179            }180181            if (e == null) {182                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {183                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)184                }185                return@Column186            }187188            // Formatting toolbar: every action writes the markup that saves back189            // as the browser's own tags, so formatting survives a phone edit.190            if (editing) {191                Row(192                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),193                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),194                    verticalAlignment = Alignment.CenterVertically,195                ) {196                    fun format(next: TextFieldValue) {197                        body = next198                        isHtml = true199                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))200                    }201                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }202                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }203                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }204                    Spacer(Modifier.width(4.dp))205                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }206                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }207                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }208                }209            }210211            // Content area.212            Box(Modifier.weight(1f).fillMaxWidth().padding(vertical = PeachSpacing.sm)) {213                if (body.text.isEmpty()) {214                    Text(215                        "Start writing…",216                        color = peachColors.T2,217                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),218                    )219                }220                if (editing) {221                    BasicTextField(222                        value = body,223                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },224                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),225                        cursorBrush = SolidColor(accent),226                        modifier = Modifier.fillMaxSize(),227                    )228                } else {229                    Text(230                        noteRendered(body.text, isHtml),231                        color = peachColors.T0,232                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),233                        modifier = Modifier.fillMaxSize().clickable { editing = true },234                    )235                }236            }237            PeachDeckBottomBar(238                onSearch = { finishNote(vm::showToc) },239                onAdd = { finishNote(vm::createNote) },240                onNotes = { finishNote(vm::showToc) },241                onSettings = { finishNote { onNavigate(PeachNav.Settings) } },242                onLock = { finishNote(vm::lock) },243                onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },244                notesSelected = true,245            )246        }247    }248}249250@Composable251private fun NotesToc(252    notes: List<VaultEntry>,253    onBack: () -> Unit,254    onOpenNote: (String) -> Unit,255    onNewNote: () -> Unit,256    isMultiSelect: Boolean,257    selectedIds: Set<String>,258    onEnterMultiSelect: (String) -> Unit,259    onToggleSelected: (String) -> Unit,260    onDeleteSelected: () -> Unit,261    onCancelMultiSelect: () -> Unit,262    onNavigate: (PeachNav) -> Unit,263    onLock: () -> Unit,264) {265    var searchVisible by remember { mutableStateOf(false) }266    var query by remember { mutableStateOf("") }267    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }268    val accent = LocalPeachAccent.current269    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)270271    ShellBackground {272        Column(273            Modifier274                .fillMaxSize()275                .statusBarsPadding()276                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),277        ) {278            if (isMultiSelect) {279                MultiSelectTopBar(280                    count = selectedIds.size,281                    onDelete = onDeleteSelected,282                    onCancel = onCancelMultiSelect,283                    includeStatusBarPadding = false,284                )285            } else if (searchVisible) {286                Surface(287                    modifier = Modifier.fillMaxWidth(),288                    color = peachColors.S1,289                    shape = RoundedCornerShape(PeachSpacing.lg),290                    tonalElevation = 1.dp,291                ) {292                    Row(293                        Modifier294                            .fillMaxWidth()295                            .height(56.dp)296                            .padding(horizontal = PeachSpacing.sm),297                        verticalAlignment = Alignment.CenterVertically,298                    ) {299                        BasicTextField(300                            value = query,301                            onValueChange = { query = it },302                            singleLine = true,303                            textStyle = TextStyle(304                                color = peachColors.T0,305                                fontFamily = PeachType.Mono,306                                fontSize = 14.sp,307                            ),308                            cursorBrush = SolidColor(accent),309                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),310                            decorationBox = { inner ->311                                Box {312                                    if (query.isEmpty()) {313                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)314                                    }315                                    inner()316                                }317                            },318                        )319                        Icon(320                            Icons.Outlined.Close,321                            contentDescription = "clear search",322                            tint = peachColors.T0,323                            modifier = Modifier.size(40.dp).clip(CircleShape)324                                .clickable {325                                    query = ""326                                    searchVisible = false327                                }328                                .padding(9.dp),329                        )330                    }331                }332            } else PeachVaultIdentity("notes") { onNavigate(PeachNav.VaultList) }333            Spacer(Modifier.size(PeachSpacing.lg))334335            LazyColumn(336                Modifier.weight(1f),337                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),338            ) {339                if (filtered.isEmpty()) {340                    item {341                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {342                            PeachEmptyOrEndState(343                                message = if (query.isBlank()) "No notes yet." else "No notes match.",344                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",345                            )346                        }347                    }348                } else {349                    items(filtered, key = { it.id }) { note ->350                        NoteTocRow(351                            note = note,352                            multiSelect = isMultiSelect,353                            selected = note.id in selectedIds,354                            onClick = {355                                if (isMultiSelect) onToggleSelected(note.id)356                                else onOpenNote(note.id)357                            },358                            onLongClick = {359                                if (isMultiSelect) onToggleSelected(note.id)360                                else onEnterMultiSelect(note.id)361                            },362                        )363                    }364                }365            }366367            if (!isMultiSelect) {368                PeachDeckBottomBar(369                    onSearch = { searchVisible = true },370                    onAdd = onNewNote,371                    onNotes = {},372                    onSettings = { onNavigate(PeachNav.Settings) },373                    onLock = onLock,374                    onSwipeUp = onBack,375                    notesSelected = true,376                )377            }378379        }380    }381}382383@OptIn(ExperimentalFoundationApi::class)384@Composable385private fun NoteTocRow(386    note: VaultEntry,387    multiSelect: Boolean,388    selected: Boolean,389    onClick: () -> Unit,390    onLongClick: () -> Unit,391) {392    val context = LocalContext.current393    val accent = LocalPeachAccent.current394    Row(395        Modifier.fillMaxWidth()396            .clip(RoundedCornerShape(8.dp))397            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)398            .combinedClickable(399                onClick = onClick,400                onLongClickLabel = "Select note",401                onLongClick = {402                    PeachHaptics.onLongPress(context)403                    onLongClick()404                },405            )406            .padding(horizontal = 14.dp, vertical = 12.dp),407        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),408        verticalAlignment = Alignment.CenterVertically,409    ) {410        if (multiSelect) MultiSelectCheckbox(checked = selected)411        Column(412            Modifier.weight(1f),413            verticalArrangement = Arrangement.spacedBy(5.dp),414        ) {415            Text(416                note.title.ifBlank { "Untitled" },417                color = peachColors.T0,418                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),419            )420            val preview = notePreviewLine(note.notes.orEmpty())421            Text(422                preview.ifBlank { "No body text" },423                color = peachColors.T2,424                style = PeachType.bodyMono,425                maxLines = 2,426            )427        }428    }429}430431/**432 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises433 * back to a tag). Collapsed selections are returned unchanged — there is nothing434 * to mark.435 */436internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {437    val sel = value.selection438    if (sel.collapsed) return value439    val text = value.text440    val next = text.substring(0, sel.min) + marker +441        text.substring(sel.min, sel.max) + marker +442        text.substring(sel.max)443    return value.copy(444        text = next,445        selection = TextRange(sel.min, sel.max + 2 * marker.length),446    )447}448449internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {450    val text = value.text451    val sel = value.selection452    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }453    val endExclusive = if (sel.collapsed) {454        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }455    } else {456        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }457    }458    val block = text.substring(start, endExclusive)459    val transformed = block460        .split('\n')461        .joinToString("\n") { line ->462            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line463        }464    val nextText = text.replaceRange(start, endExclusive, transformed)465    val delta = nextText.length - text.length466    return value.copy(467        text = nextText,468        selection = TextRange(469            sel.start.coerceIn(0, nextText.length),470            (sel.end + delta).coerceIn(0, nextText.length),471        ),472    )473}474475internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {476    val q = query.trim().lowercase()477    if (q.isBlank()) return notes478    return notes.filter { note ->479        note.title.lowercase().contains(q) ||480            notePlainText(note.notes.orEmpty()).lowercase().contains(q)481    }482}483484@Composable485private fun FormatButton(486    label: String,487    fontWeight: FontWeight? = null,488    fontStyle: FontStyle? = null,489    underline: Boolean = false,490    onClick: () -> Unit,491) {492    Box(493        Modifier494            .size(30.dp)495            .clip(RoundedCornerShape(6.dp))496            .background(peachColors.S2)497            .clickable(onClick = onClick),498        contentAlignment = Alignment.Center,499    ) {500        Text(501            label,502            color = peachColors.T0,503            style = TextStyle(504                fontFamily = PeachType.Body,505                fontSize = 14.sp,506                fontWeight = fontWeight,507                fontStyle = fontStyle,508                textDecoration = if (underline) TextDecoration.Underline else null,509            ),510        )511    }512}