NotesModeScreen.kt

20.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.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()) }136137    ShellBackground {138        Column(139            Modifier140                .fillMaxSize()141                .statusBarsPadding()142                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),143        ) {144            PeachVaultIdentity("notes") { onNavigate(PeachNav.VaultList) }145            FieldShell("title") {146                    Box(Modifier.weight(1f)) {147                        if (title.isEmpty()) {148                            Text(149                                "untitled note",150                                color = peachColors.T2,151                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),152                            )153                        }154                        BasicTextField(155                            value = title,156                            onValueChange = { title = it; vm.onTitleChanged(it) },157                            singleLine = true,158                            textStyle = TextStyle(159                                color = peachColors.T0,160                                fontFamily = PeachType.Display,161                                fontStyle = FontStyle.Italic,162                                fontSize = 22.sp,163                            ),164                            cursorBrush = SolidColor(accent),165                            modifier = Modifier.fillMaxWidth(),166                        )167                    }168                    Text("saved.", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.alpha(savedAlpha))169                    Icon(170                        Icons.Outlined.DeleteOutline,171                        contentDescription = "delete note",172                        tint = peachColors.T2,173                        modifier = Modifier.size(36.dp).clip(CircleShape)174                            .clickable { vm.delete {} }175                            .padding(8.dp),176                    )177            }178179            if (e == null) {180                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {181                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)182                }183                return@Column184            }185186            // Formatting toolbar: every action writes the markup that saves back187            // as the browser's own tags, so formatting survives a phone edit.188            if (editing) {189                Row(190                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),191                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),192                    verticalAlignment = Alignment.CenterVertically,193                ) {194                    fun format(next: TextFieldValue) {195                        body = next196                        isHtml = true197                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))198                    }199                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }200                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }201                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }202                    Spacer(Modifier.width(4.dp))203                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }204                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }205                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }206                }207            }208209            // Content area.210            Box(Modifier.weight(1f).fillMaxWidth().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                if (editing) {219                    BasicTextField(220                        value = body,221                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },222                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),223                        cursorBrush = SolidColor(accent),224                        modifier = Modifier.fillMaxSize(),225                    )226                } else {227                    Text(228                        noteRendered(body.text, isHtml),229                        color = peachColors.T0,230                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),231                        modifier = Modifier.fillMaxSize().clickable { editing = true },232                    )233                }234            }235            PeachDeckBottomBar(236                onSearch = { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() },237                onAdd = vm::createNote,238                onNotes = { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() },239                onSettings = { onNavigate(PeachNav.Settings) },240                onLock = vm::lock,241                onSwipeUp = { onNavigate(PeachNav.VaultList) },242                notesSelected = true,243            )244        }245    }246}247248@Composable249private fun NotesToc(250    notes: List<VaultEntry>,251    onBack: () -> Unit,252    onOpenNote: (String) -> Unit,253    onNewNote: () -> Unit,254    isMultiSelect: Boolean,255    selectedIds: Set<String>,256    onEnterMultiSelect: (String) -> Unit,257    onToggleSelected: (String) -> Unit,258    onDeleteSelected: () -> Unit,259    onCancelMultiSelect: () -> Unit,260    onNavigate: (PeachNav) -> Unit,261    onLock: () -> Unit,262) {263    var searchVisible by remember { mutableStateOf(false) }264    var query by remember { mutableStateOf("") }265    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }266    val accent = LocalPeachAccent.current267    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)268269    ShellBackground {270        Column(271            Modifier272                .fillMaxSize()273                .statusBarsPadding()274                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),275        ) {276            if (isMultiSelect) {277                MultiSelectTopBar(278                    count = selectedIds.size,279                    onDelete = onDeleteSelected,280                    onCancel = onCancelMultiSelect,281                    includeStatusBarPadding = false,282                )283            } else if (searchVisible) {284                Surface(285                    modifier = Modifier.fillMaxWidth(),286                    color = peachColors.S1,287                    shape = RoundedCornerShape(PeachSpacing.lg),288                    tonalElevation = 1.dp,289                ) {290                    Row(291                        Modifier292                            .fillMaxWidth()293                            .height(56.dp)294                            .padding(horizontal = PeachSpacing.sm),295                        verticalAlignment = Alignment.CenterVertically,296                    ) {297                        BasicTextField(298                            value = query,299                            onValueChange = { query = it },300                            singleLine = true,301                            textStyle = TextStyle(302                                color = peachColors.T0,303                                fontFamily = PeachType.Mono,304                                fontSize = 14.sp,305                            ),306                            cursorBrush = SolidColor(accent),307                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),308                            decorationBox = { inner ->309                                Box {310                                    if (query.isEmpty()) {311                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)312                                    }313                                    inner()314                                }315                            },316                        )317                        Icon(318                            Icons.Outlined.Close,319                            contentDescription = "clear search",320                            tint = peachColors.T0,321                            modifier = Modifier.size(40.dp).clip(CircleShape)322                                .clickable {323                                    query = ""324                                    searchVisible = false325                                }326                                .padding(9.dp),327                        )328                    }329                }330            } else PeachVaultIdentity("notes") { onNavigate(PeachNav.VaultList) }331            Spacer(Modifier.size(PeachSpacing.lg))332333            LazyColumn(334                Modifier.weight(1f),335                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),336            ) {337                if (filtered.isEmpty()) {338                    item {339                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {340                            PeachEmptyOrEndState(341                                message = if (query.isBlank()) "No notes yet." else "No notes match.",342                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",343                            )344                        }345                    }346                } else {347                    items(filtered, key = { it.id }) { note ->348                        NoteTocRow(349                            note = note,350                            multiSelect = isMultiSelect,351                            selected = note.id in selectedIds,352                            onClick = {353                                if (isMultiSelect) onToggleSelected(note.id)354                                else onOpenNote(note.id)355                            },356                            onLongClick = {357                                if (isMultiSelect) onToggleSelected(note.id)358                                else onEnterMultiSelect(note.id)359                            },360                        )361                    }362                }363            }364365            if (!isMultiSelect) {366                PeachDeckBottomBar(367                    onSearch = { searchVisible = true },368                    onAdd = onNewNote,369                    onNotes = {},370                    onSettings = { onNavigate(PeachNav.Settings) },371                    onLock = onLock,372                    onSwipeUp = onBack,373                    notesSelected = true,374                )375            }376377        }378    }379}380381@OptIn(ExperimentalFoundationApi::class)382@Composable383private fun NoteTocRow(384    note: VaultEntry,385    multiSelect: Boolean,386    selected: Boolean,387    onClick: () -> Unit,388    onLongClick: () -> Unit,389) {390    val context = LocalContext.current391    val accent = LocalPeachAccent.current392    Row(393        Modifier.fillMaxWidth()394            .clip(RoundedCornerShape(8.dp))395            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)396            .combinedClickable(397                onClick = onClick,398                onLongClickLabel = "Select note",399                onLongClick = {400                    PeachHaptics.onLongPress(context)401                    onLongClick()402                },403            )404            .padding(horizontal = 14.dp, vertical = 12.dp),405        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),406        verticalAlignment = Alignment.CenterVertically,407    ) {408        if (multiSelect) MultiSelectCheckbox(checked = selected)409        Column(410            Modifier.weight(1f),411            verticalArrangement = Arrangement.spacedBy(5.dp),412        ) {413            Text(414                note.title.ifBlank { "Untitled" },415                color = peachColors.T0,416                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),417            )418            val preview = notePreviewLine(note.notes.orEmpty())419            Text(420                preview.ifBlank { "No body text" },421                color = peachColors.T2,422                style = PeachType.bodyMono,423                maxLines = 2,424            )425        }426    }427}428429/**430 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises431 * back to a tag). Collapsed selections are returned unchanged — there is nothing432 * to mark.433 */434internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {435    val sel = value.selection436    if (sel.collapsed) return value437    val text = value.text438    val next = text.substring(0, sel.min) + marker +439        text.substring(sel.min, sel.max) + marker +440        text.substring(sel.max)441    return value.copy(442        text = next,443        selection = TextRange(sel.min, sel.max + 2 * marker.length),444    )445}446447internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {448    val text = value.text449    val sel = value.selection450    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }451    val endExclusive = if (sel.collapsed) {452        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }453    } else {454        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }455    }456    val block = text.substring(start, endExclusive)457    val transformed = block458        .split('\n')459        .joinToString("\n") { line ->460            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line461        }462    val nextText = text.replaceRange(start, endExclusive, transformed)463    val delta = nextText.length - text.length464    return value.copy(465        text = nextText,466        selection = TextRange(467            sel.start.coerceIn(0, nextText.length),468            (sel.end + delta).coerceIn(0, nextText.length),469        ),470    )471}472473internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {474    val q = query.trim().lowercase()475    if (q.isBlank()) return notes476    return notes.filter { note ->477        note.title.lowercase().contains(q) ||478            notePlainText(note.notes.orEmpty()).lowercase().contains(q)479    }480}481482@Composable483private fun FormatButton(484    label: String,485    fontWeight: FontWeight? = null,486    fontStyle: FontStyle? = null,487    underline: Boolean = false,488    onClick: () -> Unit,489) {490    Box(491        Modifier492            .size(30.dp)493            .clip(RoundedCornerShape(6.dp))494            .background(peachColors.S2)495            .clickable(onClick = onClick),496        contentAlignment = Alignment.Center,497    ) {498        Text(499            label,500            color = peachColors.T0,501            style = TextStyle(502                fontFamily = PeachType.Body,503                fontSize = 14.sp,504                fontWeight = fontWeight,505                fontStyle = fontStyle,506                textDecoration = if (underline) TextDecoration.Underline else null,507            ),508        )509    }510}