NotesModeScreen.kt

21.1 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(vertical = PeachSpacing.md),145        ) {146            PeachVaultIdentity(147                "notes",148                Modifier.padding(horizontal = PeachSpacing.lg),149            ) { finishNote { onNavigate(PeachNav.VaultList) } }150            FieldShell("title", Modifier.padding(horizontal = PeachSpacing.lg)) {151                    Box(Modifier.weight(1f)) {152                        if (title.isEmpty()) {153                            Text(154                                "untitled note",155                                color = peachColors.T2,156                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),157                            )158                        }159                        BasicTextField(160                            value = title,161                            onValueChange = { title = it; vm.onTitleChanged(it) },162                            singleLine = true,163                            textStyle = TextStyle(164                                color = peachColors.T0,165                                fontFamily = PeachType.Display,166                                fontStyle = FontStyle.Italic,167                                fontSize = 22.sp,168                            ),169                            cursorBrush = SolidColor(accent),170                            modifier = Modifier.fillMaxWidth(),171                        )172                    }173                    Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))174                    Icon(175                        Icons.Outlined.DeleteOutline,176                        contentDescription = "delete note",177                        tint = peachColors.T2,178                        modifier = Modifier.size(36.dp).clip(CircleShape)179                            .clickable { vm.delete {} }180                            .padding(8.dp),181                    )182            }183184            if (e == null) {185                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {186                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)187                }188                return@Column189            }190191            // Formatting toolbar: every action writes the markup that saves back192            // as the browser's own tags, so formatting survives a phone edit.193            if (editing) {194                Row(195                    Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),196                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),197                    verticalAlignment = Alignment.CenterVertically,198                ) {199                    fun format(next: TextFieldValue) {200                        body = next201                        isHtml = true202                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))203                    }204                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }205                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }206                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }207                    Spacer(Modifier.width(4.dp))208                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }209                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }210                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }211                }212            }213214            // Content area.215            Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm)) {216                if (body.text.isEmpty()) {217                    Text(218                        "Start writing…",219                        color = peachColors.T2,220                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),221                    )222                }223                if (editing) {224                    BasicTextField(225                        value = body,226                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },227                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),228                        cursorBrush = SolidColor(accent),229                        modifier = Modifier.fillMaxSize(),230                    )231                } else {232                    Text(233                        noteRendered(body.text, isHtml),234                        color = peachColors.T0,235                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),236                        modifier = Modifier.fillMaxSize().clickable { editing = true },237                    )238                }239            }240            PeachDeckBottomBar(241                onSearch = { finishNote(vm::showToc) },242                onAdd = { finishNote(vm::createNote) },243                onNotes = { finishNote(vm::showToc) },244                onSettings = { finishNote { onNavigate(PeachNav.Settings) } },245                onLock = { finishNote(vm::lock) },246                onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },247                notesSelected = true,248            )249        }250    }251}252253@Composable254private fun NotesToc(255    notes: List<VaultEntry>,256    onBack: () -> Unit,257    onOpenNote: (String) -> Unit,258    onNewNote: () -> Unit,259    isMultiSelect: Boolean,260    selectedIds: Set<String>,261    onEnterMultiSelect: (String) -> Unit,262    onToggleSelected: (String) -> Unit,263    onDeleteSelected: () -> Unit,264    onCancelMultiSelect: () -> Unit,265    onNavigate: (PeachNav) -> Unit,266    onLock: () -> Unit,267) {268    var searchVisible by remember { mutableStateOf(false) }269    var query by remember { mutableStateOf("") }270    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }271    val accent = LocalPeachAccent.current272    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)273274    ShellBackground {275        Column(276            Modifier277                .fillMaxSize()278                .statusBarsPadding()279                .padding(vertical = PeachSpacing.md),280        ) {281            if (isMultiSelect) {282                MultiSelectTopBar(283                    count = selectedIds.size,284                    onDelete = onDeleteSelected,285                    onCancel = onCancelMultiSelect,286                    includeStatusBarPadding = false,287                )288            } else if (searchVisible) {289                Surface(290                    modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg),291                    color = peachColors.S1,292                    shape = RoundedCornerShape(PeachSpacing.lg),293                    tonalElevation = 1.dp,294                ) {295                    Row(296                        Modifier297                            .fillMaxWidth()298                            .height(56.dp)299                            .padding(horizontal = PeachSpacing.sm),300                        verticalAlignment = Alignment.CenterVertically,301                    ) {302                        BasicTextField(303                            value = query,304                            onValueChange = { query = it },305                            singleLine = true,306                            textStyle = TextStyle(307                                color = peachColors.T0,308                                fontFamily = PeachType.Mono,309                                fontSize = 14.sp,310                            ),311                            cursorBrush = SolidColor(accent),312                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),313                            decorationBox = { inner ->314                                Box {315                                    if (query.isEmpty()) {316                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)317                                    }318                                    inner()319                                }320                            },321                        )322                        Icon(323                            Icons.Outlined.Close,324                            contentDescription = "clear search",325                            tint = peachColors.T0,326                            modifier = Modifier.size(40.dp).clip(CircleShape)327                                .clickable {328                                    query = ""329                                    searchVisible = false330                                }331                                .padding(9.dp),332                        )333                    }334                }335            } else PeachVaultIdentity(336                "notes",337                Modifier.padding(horizontal = PeachSpacing.lg),338            ) { onNavigate(PeachNav.VaultList) }339            Spacer(Modifier.size(PeachSpacing.lg))340341            LazyColumn(342                Modifier.weight(1f).padding(horizontal = PeachSpacing.lg),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(357                            note = note,358                            multiSelect = isMultiSelect,359                            selected = note.id in selectedIds,360                            onClick = {361                                if (isMultiSelect) onToggleSelected(note.id)362                                else onOpenNote(note.id)363                            },364                            onLongClick = {365                                if (isMultiSelect) onToggleSelected(note.id)366                                else onEnterMultiSelect(note.id)367                            },368                        )369                    }370                }371            }372373            if (!isMultiSelect) {374                PeachDeckBottomBar(375                    onSearch = { searchVisible = true },376                    onAdd = onNewNote,377                    onNotes = {},378                    onSettings = { onNavigate(PeachNav.Settings) },379                    onLock = onLock,380                    onSwipeUp = onBack,381                    notesSelected = true,382                )383            }384385        }386    }387}388389@OptIn(ExperimentalFoundationApi::class)390@Composable391private fun NoteTocRow(392    note: VaultEntry,393    multiSelect: Boolean,394    selected: Boolean,395    onClick: () -> Unit,396    onLongClick: () -> Unit,397) {398    val context = LocalContext.current399    val accent = LocalPeachAccent.current400    Row(401        Modifier.fillMaxWidth()402            .clip(RoundedCornerShape(8.dp))403            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)404            .combinedClickable(405                onClick = onClick,406                onLongClickLabel = "Select note",407                onLongClick = {408                    PeachHaptics.onLongPress(context)409                    onLongClick()410                },411            )412            .padding(horizontal = 14.dp, vertical = 12.dp),413        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),414        verticalAlignment = Alignment.CenterVertically,415    ) {416        if (multiSelect) MultiSelectCheckbox(checked = selected)417        Column(418            Modifier.weight(1f),419            verticalArrangement = Arrangement.spacedBy(5.dp),420        ) {421            Text(422                note.title.ifBlank { "Untitled" },423                color = peachColors.T0,424                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),425            )426            val preview = notePreviewLine(note.notes.orEmpty())427            Text(428                preview.ifBlank { "No body text" },429                color = peachColors.T2,430                style = PeachType.bodyMono,431                maxLines = 2,432            )433        }434    }435}436437/**438 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises439 * back to a tag). Collapsed selections are returned unchanged — there is nothing440 * to mark.441 */442internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {443    val sel = value.selection444    if (sel.collapsed) return value445    val text = value.text446    val next = text.substring(0, sel.min) + marker +447        text.substring(sel.min, sel.max) + marker +448        text.substring(sel.max)449    return value.copy(450        text = next,451        selection = TextRange(sel.min, sel.max + 2 * marker.length),452    )453}454455internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {456    val text = value.text457    val sel = value.selection458    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }459    val endExclusive = if (sel.collapsed) {460        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }461    } else {462        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }463    }464    val block = text.substring(start, endExclusive)465    val transformed = block466        .split('\n')467        .joinToString("\n") { line ->468            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line469        }470    val nextText = text.replaceRange(start, endExclusive, transformed)471    val delta = nextText.length - text.length472    return value.copy(473        text = nextText,474        selection = TextRange(475            sel.start.coerceIn(0, nextText.length),476            (sel.end + delta).coerceIn(0, nextText.length),477        ),478    )479}480481internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {482    val q = query.trim().lowercase()483    if (q.isBlank()) return notes484    return notes.filter { note ->485        note.title.lowercase().contains(q) ||486            notePlainText(note.notes.orEmpty()).lowercase().contains(q)487    }488}489490@Composable491private fun FormatButton(492    label: String,493    fontWeight: FontWeight? = null,494    fontStyle: FontStyle? = null,495    underline: Boolean = false,496    onClick: () -> Unit,497) {498    Box(499        Modifier500            .size(30.dp)501            .clip(RoundedCornerShape(6.dp))502            .background(peachColors.S2)503            .clickable(onClick = onClick),504        contentAlignment = Alignment.Center,505    ) {506        Text(507            label,508            color = peachColors.T0,509            style = TextStyle(510                fontFamily = PeachType.Body,511                fontSize = 14.sp,512                fontWeight = fontWeight,513                fontStyle = fontStyle,514                textDecoration = if (underline) TextDecoration.Underline else null,515            ),516        )517    }518}