NotesModeScreen.kt

21.3 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            Surface(145                modifier = Modifier.fillMaxWidth(),146                color = peachColors.S1,147                contentColor = peachColors.T0,148                shape = RoundedCornerShape(PeachSpacing.lg),149                tonalElevation = 1.dp,150            ) {151                Row(152                    Modifier153                        .fillMaxWidth()154                        .height(56.dp)155                        .padding(horizontal = PeachSpacing.sm),156                    verticalAlignment = Alignment.CenterVertically,157                ) {158                    Icon(159                        Icons.AutoMirrored.Rounded.ArrowBack,160                        contentDescription = "back",161                        tint = peachColors.T0,162                        modifier = Modifier.size(40.dp).clip(CircleShape)163                            .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }164                            .padding(8.dp),165                    )166                    Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {167                        if (title.isEmpty()) {168                            Text(169                                "untitled note",170                                color = peachColors.T2,171                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),172                            )173                        }174                        BasicTextField(175                            value = title,176                            onValueChange = { title = it; vm.onTitleChanged(it) },177                            singleLine = true,178                            textStyle = TextStyle(179                                color = peachColors.T0,180                                fontFamily = PeachType.Display,181                                fontStyle = FontStyle.Italic,182                                fontSize = 22.sp,183                            ),184                            cursorBrush = SolidColor(accent),185                            modifier = Modifier.fillMaxWidth(),186                        )187                    }188                    Text(189                        "saved.",190                        color = peachColors.T2,191                        style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),192                        modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),193                    )194                    Icon(195                        Icons.Outlined.DeleteOutline,196                        contentDescription = "delete note",197                        tint = peachColors.T2,198                        modifier = Modifier.size(36.dp).clip(CircleShape)199                            .clickable { vm.delete {} }200                            .padding(8.dp),201                    )202                }203            }204205            if (e == null) {206                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {207                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)208                }209                return@Column210            }211212            // Formatting toolbar: every action writes the markup that saves back213            // as the browser's own tags, so formatting survives a phone edit.214            if (editing) {215                Row(216                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),217                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),218                    verticalAlignment = Alignment.CenterVertically,219                ) {220                    fun format(next: TextFieldValue) {221                        body = next222                        isHtml = true223                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))224                    }225                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }226                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }227                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }228                    Spacer(Modifier.width(4.dp))229                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }230                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }231                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }232                }233            }234235            // Content area.236            Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {237                if (body.text.isEmpty()) {238                    Text(239                        "Start writing…",240                        color = peachColors.T2,241                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),242                    )243                }244                if (editing) {245                    BasicTextField(246                        value = body,247                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },248                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),249                        cursorBrush = SolidColor(accent),250                        modifier = Modifier.fillMaxSize(),251                    )252                } else {253                    Text(254                        noteRendered(body.text, isHtml),255                        color = peachColors.T0,256                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),257                        modifier = Modifier.fillMaxSize().clickable { editing = true },258                    )259                }260            }261        }262    }263}264265@Composable266private fun NotesToc(267    notes: List<VaultEntry>,268    onBack: () -> Unit,269    onOpenNote: (String) -> Unit,270    onNewNote: () -> Unit,271    isMultiSelect: Boolean,272    selectedIds: Set<String>,273    onEnterMultiSelect: (String) -> Unit,274    onToggleSelected: (String) -> Unit,275    onDeleteSelected: () -> Unit,276    onCancelMultiSelect: () -> Unit,277    onNavigate: (PeachNav) -> Unit,278    onLock: () -> Unit,279) {280    var searchVisible by remember { mutableStateOf(false) }281    var query by remember { mutableStateOf("") }282    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }283    val accent = LocalPeachAccent.current284    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)285286    ShellBackground {287        Column(288            Modifier289                .fillMaxSize()290                .statusBarsPadding()291                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),292        ) {293            if (isMultiSelect) {294                MultiSelectTopBar(295                    count = selectedIds.size,296                    onDelete = onDeleteSelected,297                    onCancel = onCancelMultiSelect,298                    includeStatusBarPadding = false,299                )300            } else if (searchVisible) {301                Surface(302                    modifier = Modifier.fillMaxWidth(),303                    color = peachColors.S1,304                    shape = RoundedCornerShape(PeachSpacing.lg),305                    tonalElevation = 1.dp,306                ) {307                    Row(308                        Modifier309                            .fillMaxWidth()310                            .height(56.dp)311                            .padding(horizontal = PeachSpacing.sm),312                        verticalAlignment = Alignment.CenterVertically,313                    ) {314                        BasicTextField(315                            value = query,316                            onValueChange = { query = it },317                            singleLine = true,318                            textStyle = TextStyle(319                                color = peachColors.T0,320                                fontFamily = PeachType.Mono,321                                fontSize = 14.sp,322                            ),323                            cursorBrush = SolidColor(accent),324                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),325                            decorationBox = { inner ->326                                Box {327                                    if (query.isEmpty()) {328                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)329                                    }330                                    inner()331                                }332                            },333                        )334                        Icon(335                            Icons.Outlined.Close,336                            contentDescription = "clear search",337                            tint = peachColors.T0,338                            modifier = Modifier.size(40.dp).clip(CircleShape)339                                .clickable {340                                    query = ""341                                    searchVisible = false342                                }343                                .padding(9.dp),344                        )345                    }346                }347            } else PeachVaultIdentity("notes")348            Spacer(Modifier.size(PeachSpacing.lg))349350            LazyColumn(351                Modifier.weight(1f),352                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),353            ) {354                if (filtered.isEmpty()) {355                    item {356                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {357                            PeachEmptyOrEndState(358                                message = if (query.isBlank()) "No notes yet." else "No notes match.",359                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",360                            )361                        }362                    }363                } else {364                    items(filtered, key = { it.id }) { note ->365                        NoteTocRow(366                            note = note,367                            multiSelect = isMultiSelect,368                            selected = note.id in selectedIds,369                            onClick = {370                                if (isMultiSelect) onToggleSelected(note.id)371                                else onOpenNote(note.id)372                            },373                            onLongClick = {374                                if (isMultiSelect) onToggleSelected(note.id)375                                else onEnterMultiSelect(note.id)376                            },377                        )378                    }379                }380            }381382            if (!isMultiSelect) {383                PeachDeckBottomBar(384                    onSearch = { searchVisible = true },385                    onAdd = onNewNote,386                    onNotes = {},387                    onSettings = { onNavigate(PeachNav.Settings) },388                    onLock = onLock,389                    onSwipeUp = onBack,390                    notesSelected = true,391                )392            }393394        }395    }396}397398@OptIn(ExperimentalFoundationApi::class)399@Composable400private fun NoteTocRow(401    note: VaultEntry,402    multiSelect: Boolean,403    selected: Boolean,404    onClick: () -> Unit,405    onLongClick: () -> Unit,406) {407    val context = LocalContext.current408    val accent = LocalPeachAccent.current409    Row(410        Modifier.fillMaxWidth()411            .clip(RoundedCornerShape(8.dp))412            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)413            .combinedClickable(414                onClick = onClick,415                onLongClickLabel = "Select note",416                onLongClick = {417                    PeachHaptics.onLongPress(context)418                    onLongClick()419                },420            )421            .padding(horizontal = 14.dp, vertical = 12.dp),422        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),423        verticalAlignment = Alignment.CenterVertically,424    ) {425        if (multiSelect) MultiSelectCheckbox(checked = selected)426        Column(427            Modifier.weight(1f),428            verticalArrangement = Arrangement.spacedBy(5.dp),429        ) {430            Text(431                note.title.ifBlank { "Untitled" },432                color = peachColors.T0,433                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),434            )435            val preview = notePreviewLine(note.notes.orEmpty())436            Text(437                preview.ifBlank { "No body text" },438                color = peachColors.T2,439                style = PeachType.bodyMono,440                maxLines = 2,441            )442        }443    }444}445446/**447 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises448 * back to a tag). Collapsed selections are returned unchanged — there is nothing449 * to mark.450 */451internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {452    val sel = value.selection453    if (sel.collapsed) return value454    val text = value.text455    val next = text.substring(0, sel.min) + marker +456        text.substring(sel.min, sel.max) + marker +457        text.substring(sel.max)458    return value.copy(459        text = next,460        selection = TextRange(sel.min, sel.max + 2 * marker.length),461    )462}463464internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {465    val text = value.text466    val sel = value.selection467    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }468    val endExclusive = if (sel.collapsed) {469        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }470    } else {471        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }472    }473    val block = text.substring(start, endExclusive)474    val transformed = block475        .split('\n')476        .joinToString("\n") { line ->477            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line478        }479    val nextText = text.replaceRange(start, endExclusive, transformed)480    val delta = nextText.length - text.length481    return value.copy(482        text = nextText,483        selection = TextRange(484            sel.start.coerceIn(0, nextText.length),485            (sel.end + delta).coerceIn(0, nextText.length),486        ),487    )488}489490internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {491    val q = query.trim().lowercase()492    if (q.isBlank()) return notes493    return notes.filter { note ->494        note.title.lowercase().contains(q) ||495            notePlainText(note.notes.orEmpty()).lowercase().contains(q)496    }497}498499@Composable500private fun FormatButton(501    label: String,502    fontWeight: FontWeight? = null,503    fontStyle: FontStyle? = null,504    underline: Boolean = false,505    onClick: () -> Unit,506) {507    Box(508        Modifier509            .size(30.dp)510            .clip(RoundedCornerShape(6.dp))511            .background(peachColors.S2)512            .clickable(onClick = onClick),513        contentAlignment = Alignment.Center,514    ) {515        Text(516            label,517            color = peachColors.T0,518            style = TextStyle(519                fontFamily = PeachType.Body,520                fontSize = 14.sp,521                fontWeight = fontWeight,522                fontStyle = fontStyle,523                textDecoration = if (underline) TextDecoration.Underline else null,524            ),525        )526    }527}