NotesModeScreen.kt

22.7 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.border8import androidx.compose.foundation.clickable9import androidx.compose.foundation.combinedClickable10import androidx.compose.foundation.ExperimentalFoundationApi11import androidx.compose.foundation.layout.Arrangement12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.Column14import androidx.compose.foundation.layout.Row15import androidx.compose.foundation.layout.Spacer16import androidx.compose.foundation.layout.fillMaxSize17import androidx.compose.foundation.layout.fillMaxWidth18import androidx.compose.foundation.layout.height19import androidx.compose.foundation.layout.padding20import androidx.compose.foundation.layout.size21import androidx.compose.foundation.layout.statusBarsPadding22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.lazy.LazyColumn24import androidx.compose.foundation.lazy.items25import androidx.compose.foundation.shape.CircleShape26import androidx.compose.foundation.shape.RoundedCornerShape27import androidx.compose.foundation.text.BasicTextField28import androidx.compose.material.icons.Icons29import androidx.compose.material.icons.automirrored.rounded.ArrowBack30import androidx.compose.material.icons.outlined.Add31import androidx.compose.material.icons.outlined.Close32import androidx.compose.material.icons.outlined.DeleteOutline33import androidx.compose.material.icons.outlined.Search34import androidx.compose.material3.Icon35import androidx.compose.material3.Surface36import androidx.compose.material3.Text37import androidx.compose.runtime.Composable38import androidx.compose.runtime.LaunchedEffect39import androidx.compose.runtime.getValue40import androidx.compose.runtime.mutableStateOf41import androidx.compose.runtime.remember42import androidx.compose.runtime.setValue43import androidx.compose.ui.Alignment44import androidx.compose.ui.Modifier45import androidx.compose.ui.draw.alpha46import androidx.compose.ui.draw.clip47import androidx.compose.ui.graphics.SolidColor48import androidx.compose.ui.platform.LocalContext49import androidx.compose.ui.text.TextStyle50import androidx.compose.ui.text.TextRange51import androidx.compose.ui.text.font.FontStyle52import androidx.compose.ui.text.font.FontWeight53import androidx.compose.ui.text.input.TextFieldValue54import androidx.compose.ui.text.style.TextDecoration55import androidx.compose.ui.unit.dp56import androidx.compose.ui.unit.sp57import androidx.compose.runtime.collectAsState58import androidx.hilt.navigation.compose.hiltViewModel59import com.peachpasswords.haptics.PeachHaptics60import com.peachpasswords.ui.components.PeachEmptyOrEndState61import com.peachpasswords.ui.PeachNav62import com.peachpasswords.ui.components.SecureFlagEffect63import com.peachpasswords.ui.theme.PeachSpacing64import com.peachpasswords.ui.theme.PeachRadius65import com.peachpasswords.vault.VaultEntry66import com.peachpasswords.ui.theme.LocalPeachAccent67import com.peachpasswords.ui.theme.peachColors68import com.peachpasswords.ui.theme.PeachType69import com.peachpasswords.ui.theme.ShellBackground7071/**72 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U73 * formatting toolbar, a full-width Inter body, and an 800ms-debounced auto-save74 * with a "saved." pulse. FLAG_SECURE blocks screenshots while open (same as75 * EntryDetail).76 *77 * The body opens rendered (see NoteHtml.kt) and switches to the editor on tap,78 * because a note written in the browser is HTML and Compose has no text field79 * that can edit styled text without dropping the styles. Formatting typed here80 * is saved back as the browser's own tag vocabulary.81 */82@Composable83fun NotesModeScreen(84    onBack: () -> Unit,85    onNavigate: (PeachNav) -> Unit,86    vm: NotesModeViewModel = hiltViewModel(),87) {88    val entry by vm.entry.collectAsState()89    val notes by vm.notes.collectAsState()90    val activeNoteId by vm.activeNoteId.collectAsState()91    val isMultiSelect by vm.isMultiSelectMode.collectAsState()92    val selectedIds by vm.selectedIds.collectAsState()93    val savedPulse by vm.savedPulse.collectAsState()94    val accent = LocalPeachAccent.current9596    SecureFlagEffect()9798    // "saved." indicator fades in on each save pulse, holds, then fades out.99    var showSaved by remember { mutableStateOf(false) }100    LaunchedEffect(savedPulse) {101        if (savedPulse == 0L) return@LaunchedEffect102        showSaved = true103        kotlinx.coroutines.delay(1300)104        showSaved = false105    }106    val savedAlpha by animateFloatAsState(107        targetValue = if (showSaved) 1f else 0f,108        animationSpec = tween(300),109        label = "saved-fade",110    )111112    if (activeNoteId == null) {113        NotesToc(114            notes = notes,115            onBack = onBack,116            onOpenNote = vm::openNote,117            onNewNote = vm::createNote,118            isMultiSelect = isMultiSelect,119            selectedIds = selectedIds,120            onEnterMultiSelect = vm::enterMultiSelect,121            onToggleSelected = vm::toggleSelected,122            onDeleteSelected = vm::batchDelete,123            onCancelMultiSelect = vm::exitMultiSelect,124            onNavigate = onNavigate,125            onLock = vm::lock,126        )127        return128    }129130    val e = entry131    val stored = e?.notes.orEmpty()132    var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }133    var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }134    // A note stays plain text until the user asks for formatting, so typing135    // "- eggs" into a shopping list never turns it into a <ul> behind their back.136    var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }137    var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }138    fun finishNote(onDone: () -> Unit) = vm.finishNote(title, noteStorageText(body.text, isHtml), onDone)139    BackHandler { finishNote(vm::showToc) }140141    ShellBackground {142        Column(143            Modifier144                .fillMaxSize()145                .statusBarsPadding()146                .padding(vertical = PeachSpacing.md),147        ) {148            PeachVaultIdentity(149                "notes",150                Modifier.padding(horizontal = PeachSpacing.lg),151            ) { finishNote { onNavigate(PeachNav.VaultList) } }152            Column(153                Modifier.weight(1f).fillMaxWidth()154                    .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.xs)155                    .clip(RoundedCornerShape(PeachRadius.StackCard))156                    .background(peachColors.SurfaceCard)157                    .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard)),158            ) {159                FieldShell("title", stacked = true, divider = true) {160                    Box(Modifier.weight(1f)) {161                        if (title.isEmpty()) {162                            Text(163                                "untitled note",164                                color = peachColors.T2,165                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),166                            )167                        }168                        BasicTextField(169                            value = title,170                            onValueChange = { title = it; vm.onTitleChanged(it) },171                            singleLine = true,172                            textStyle = TextStyle(173                                color = peachColors.T0,174                                fontFamily = PeachType.Display,175                                fontStyle = FontStyle.Italic,176                                fontSize = 22.sp,177                            ),178                            cursorBrush = SolidColor(accent),179                            modifier = Modifier.fillMaxWidth(),180                        )181                    }182                    Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))183                    Icon(184                        Icons.Outlined.DeleteOutline,185                        contentDescription = "delete note",186                        tint = peachColors.T2,187                        modifier = Modifier.size(36.dp).clip(CircleShape)188                            .clickable { vm.delete {} }189                            .padding(8.dp),190                    )191                }192193                if (e == null) {194                    Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {195                        Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)196                    }197                    return@Column198                }199200                // Formatting toolbar: every action writes the markup that saves back201                // as the browser's own tags, so formatting survives a phone edit.202                if (editing) {203                    Row(204                        Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.sm),205                        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),206                        verticalAlignment = Alignment.CenterVertically,207                    ) {208                        fun format(next: TextFieldValue) {209                            body = next210                            isHtml = true211                            vm.onContentChanged(noteStorageText(next.text, isHtml = true))212                        }213                        FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }214                        FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }215                        FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }216                        FormatButton("S", strikeThrough = true) { format(wrapSelection(body, "~~")) }217                        Box(Modifier.width(1.dp).height(24.dp).background(peachColors.T3))218                        FormatButton("H₂") { format(applyLinePrefix(body, "## ")) }219                        FormatButton("❞") { format(applyLinePrefix(body, "> ")) }220                        FormatButton("•") { format(applyLinePrefix(body, "- ")) }221                        FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }222                        FormatButton("—") { format(applyLinePrefix(body, "---")) }223                    }224                    Box(Modifier.fillMaxWidth().height(1.dp).background(peachColors.T3))225                }226227                // Content area is the final layer of the single note card.228                Box(Modifier.weight(1f).fillMaxWidth().padding(PeachSpacing.lg)) {229                    if (body.text.isEmpty()) {230                        Text(231                            "Start writing…",232                            color = peachColors.T2,233                            style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),234                        )235                    }236                    if (editing) {237                        BasicTextField(238                            value = body,239                            onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },240                            textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),241                            cursorBrush = SolidColor(accent),242                            modifier = Modifier.fillMaxSize(),243                        )244                    } else {245                        Text(246                            noteRendered(body.text, isHtml),247                            color = peachColors.T0,248                            style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),249                            modifier = Modifier.fillMaxSize().clickable { editing = true },250                        )251                    }252                }253            }254            PeachDeckBottomBar(255                onSearch = { finishNote(vm::showToc) },256                onAdd = { finishNote(vm::createNote) },257                onNotes = { finishNote(onBack) },258                onSettings = { finishNote { onNavigate(PeachNav.Settings) } },259                onLock = { finishNote(vm::lock) },260                onSwipeUp = { finishNote { onNavigate(PeachNav.VaultList) } },261                notesSelected = true,262            )263        }264    }265}266267@Composable268private fun NotesToc(269    notes: List<VaultEntry>,270    onBack: () -> Unit,271    onOpenNote: (String) -> Unit,272    onNewNote: () -> Unit,273    isMultiSelect: Boolean,274    selectedIds: Set<String>,275    onEnterMultiSelect: (String) -> Unit,276    onToggleSelected: (String) -> Unit,277    onDeleteSelected: () -> Unit,278    onCancelMultiSelect: () -> Unit,279    onNavigate: (PeachNav) -> Unit,280    onLock: () -> Unit,281) {282    var searchVisible by remember { mutableStateOf(false) }283    var query by remember { mutableStateOf("") }284    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }285    val accent = LocalPeachAccent.current286    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)287288    ShellBackground {289        Column(290            Modifier291                .fillMaxSize()292                .statusBarsPadding()293                .padding(vertical = PeachSpacing.md),294        ) {295            if (isMultiSelect) {296                MultiSelectTopBar(297                    count = selectedIds.size,298                    onDelete = onDeleteSelected,299                    onCancel = onCancelMultiSelect,300                    includeStatusBarPadding = false,301                )302            } else if (searchVisible) {303                Surface(304                    modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.lg),305                    color = peachColors.S1,306                    shape = RoundedCornerShape(PeachRadius.StackCard),307                    border = androidx.compose.foundation.BorderStroke(1.dp, peachColors.T3),308                    tonalElevation = 1.dp,309                ) {310                    Row(311                        Modifier312                            .fillMaxWidth()313                            .height(56.dp)314                            .padding(horizontal = PeachSpacing.sm),315                        verticalAlignment = Alignment.CenterVertically,316                    ) {317                        BasicTextField(318                            value = query,319                            onValueChange = { query = it },320                            singleLine = true,321                            textStyle = TextStyle(322                                color = peachColors.T0,323                                fontFamily = PeachType.Mono,324                                fontSize = 14.sp,325                            ),326                            cursorBrush = SolidColor(accent),327                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),328                            decorationBox = { inner ->329                                Box {330                                    if (query.isEmpty()) {331                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)332                                    }333                                    inner()334                                }335                            },336                        )337                        Icon(338                            Icons.Outlined.Close,339                            contentDescription = "clear search",340                            tint = peachColors.T0,341                            modifier = Modifier.size(40.dp).clip(CircleShape)342                                .clickable {343                                    query = ""344                                    searchVisible = false345                                }346                                .padding(9.dp),347                        )348                    }349                }350            } else PeachVaultIdentity(351                "notes",352                Modifier.padding(horizontal = PeachSpacing.lg),353            ) { onNavigate(PeachNav.VaultList) }354            Spacer(Modifier.size(PeachSpacing.lg))355356            LazyColumn(357                Modifier.weight(1f).padding(horizontal = PeachSpacing.lg),358                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),359            ) {360                if (filtered.isEmpty()) {361                    item {362                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {363                            PeachEmptyOrEndState(364                                message = if (query.isBlank()) "No notes yet." else "No notes match.",365                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",366                            )367                        }368                    }369                } else {370                    items(filtered, key = { it.id }) { note ->371                        NoteTocRow(372                            note = note,373                            multiSelect = isMultiSelect,374                            selected = note.id in selectedIds,375                            onClick = {376                                if (isMultiSelect) onToggleSelected(note.id)377                                else onOpenNote(note.id)378                            },379                            onLongClick = {380                                if (isMultiSelect) onToggleSelected(note.id)381                                else onEnterMultiSelect(note.id)382                            },383                        )384                    }385                }386            }387388            if (!isMultiSelect) {389                PeachDeckBottomBar(390                    onSearch = { searchVisible = true },391                    onAdd = onNewNote,392                    onNotes = onBack,393                    onSettings = { onNavigate(PeachNav.Settings) },394                    onLock = onLock,395                    onSwipeUp = onBack,396                    notesSelected = true,397                )398            }399400        }401    }402}403404@OptIn(ExperimentalFoundationApi::class)405@Composable406private fun NoteTocRow(407    note: VaultEntry,408    multiSelect: Boolean,409    selected: Boolean,410    onClick: () -> Unit,411    onLongClick: () -> Unit,412) {413    val context = LocalContext.current414    val accent = LocalPeachAccent.current415    Row(416        Modifier.fillMaxWidth()417            .clip(RoundedCornerShape(PeachRadius.StackCard))418            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.SurfaceCard)419            .border(1.dp, peachColors.T3, RoundedCornerShape(PeachRadius.StackCard))420            .combinedClickable(421                onClick = onClick,422                onLongClickLabel = "Select note",423                onLongClick = {424                    PeachHaptics.onLongPress(context)425                    onLongClick()426                },427            )428            .padding(horizontal = 14.dp, vertical = 12.dp),429        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),430        verticalAlignment = Alignment.CenterVertically,431    ) {432        if (multiSelect) MultiSelectCheckbox(checked = selected)433        Column(434            Modifier.weight(1f),435            verticalArrangement = Arrangement.spacedBy(5.dp),436        ) {437            Text(438                note.title.ifBlank { "Untitled" },439                color = peachColors.T0,440                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),441            )442            val preview = notePreviewLine(note.notes.orEmpty())443            Text(444                preview.ifBlank { "No body text" },445                color = peachColors.T2,446                style = PeachType.bodyMono,447                maxLines = 2,448            )449        }450    }451}452453/**454 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises455 * back to a tag). Collapsed selections are returned unchanged — there is nothing456 * to mark.457 */458internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {459    val sel = value.selection460    if (sel.collapsed) return value461    val text = value.text462    val next = text.substring(0, sel.min) + marker +463        text.substring(sel.min, sel.max) + marker +464        text.substring(sel.max)465    return value.copy(466        text = next,467        selection = TextRange(sel.min, sel.max + 2 * marker.length),468    )469}470471internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {472    val text = value.text473    val sel = value.selection474    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }475    val endExclusive = if (sel.collapsed) {476        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }477    } else {478        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }479    }480    val block = text.substring(start, endExclusive)481    val transformed = block482        .split('\n')483        .joinToString("\n") { line ->484            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line485        }486    val nextText = text.replaceRange(start, endExclusive, transformed)487    val delta = nextText.length - text.length488    return value.copy(489        text = nextText,490        selection = TextRange(491            sel.start.coerceIn(0, nextText.length),492            (sel.end + delta).coerceIn(0, nextText.length),493        ),494    )495}496497internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {498    val q = query.trim().lowercase()499    if (q.isBlank()) return notes500    return notes.filter { note ->501        note.title.lowercase().contains(q) ||502            notePlainText(note.notes.orEmpty()).lowercase().contains(q)503    }504}505506@Composable507private fun FormatButton(508    label: String,509    fontWeight: FontWeight? = null,510    fontStyle: FontStyle? = null,511    underline: Boolean = false,512    strikeThrough: Boolean = false,513    onClick: () -> Unit,514) {515    Box(516        Modifier517            .size(30.dp)518            .clip(RoundedCornerShape(6.dp))519            .background(peachColors.S2)520            .clickable(onClick = onClick),521        contentAlignment = Alignment.Center,522    ) {523        Text(524            label,525            color = peachColors.T0,526            style = TextStyle(527                fontFamily = PeachType.Body,528                fontSize = 14.sp,529                fontWeight = fontWeight,530                fontStyle = fontStyle,531                textDecoration = when {532                    underline && strikeThrough -> TextDecoration.combine(533                        listOf(TextDecoration.Underline, TextDecoration.LineThrough),534                    )535                    underline -> TextDecoration.Underline536                    strikeThrough -> TextDecoration.LineThrough537                    else -> null538                },539            ),540        )541    }542}