NotesModeScreen.kt

22.4 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.components.PeachTopBar61import 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    vm: NotesModeViewModel = hiltViewModel(),84) {85    val entry by vm.entry.collectAsState()86    val notes by vm.notes.collectAsState()87    val activeNoteId by vm.activeNoteId.collectAsState()88    val isMultiSelect by vm.isMultiSelectMode.collectAsState()89    val selectedIds by vm.selectedIds.collectAsState()90    val savedPulse by vm.savedPulse.collectAsState()91    val accent = LocalPeachAccent.current9293    SecureFlagEffect()9495    // "saved." indicator fades in on each save pulse, holds, then fades out.96    var showSaved by remember { mutableStateOf(false) }97    LaunchedEffect(savedPulse) {98        if (savedPulse == 0L) return@LaunchedEffect99        showSaved = true100        kotlinx.coroutines.delay(1300)101        showSaved = false102    }103    val savedAlpha by animateFloatAsState(104        targetValue = if (showSaved) 1f else 0f,105        animationSpec = tween(300),106        label = "saved-fade",107    )108109    if (activeNoteId == null) {110        NotesToc(111            notes = notes,112            onBack = onBack,113            onOpenNote = vm::openNote,114            onNewNote = vm::createNote,115            isMultiSelect = isMultiSelect,116            selectedIds = selectedIds,117            onEnterMultiSelect = vm::enterMultiSelect,118            onToggleSelected = vm::toggleSelected,119            onDeleteSelected = vm::batchDelete,120            onCancelMultiSelect = vm::exitMultiSelect,121        )122        return123    }124125    val e = entry126    val stored = e?.notes.orEmpty()127    var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }128    var body by remember(e?.id) { mutableStateOf(TextFieldValue(noteEditorText(stored))) }129    // A note stays plain text until the user asks for formatting, so typing130    // "- eggs" into a shopping list never turns it into a <ul> behind their back.131    var isHtml by remember(e?.id) { mutableStateOf(isNoteHtml(stored)) }132    var editing by remember(e?.id) { mutableStateOf(stored.isBlank()) }133134    ShellBackground {135        Column(136            Modifier137                .fillMaxSize()138                .statusBarsPadding()139                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),140        ) {141            Surface(142                modifier = Modifier.fillMaxWidth(),143                color = peachColors.S1,144                contentColor = peachColors.T0,145                shape = RoundedCornerShape(PeachSpacing.lg),146                tonalElevation = 1.dp,147            ) {148                Row(149                    Modifier150                        .fillMaxWidth()151                        .height(56.dp)152                        .padding(horizontal = PeachSpacing.sm),153                    verticalAlignment = Alignment.CenterVertically,154                ) {155                    Icon(156                        Icons.AutoMirrored.Rounded.ArrowBack,157                        contentDescription = "back",158                        tint = peachColors.T0,159                        modifier = Modifier.size(40.dp).clip(CircleShape)160                            .clickable { vm.saveNow(title, noteStorageText(body.text, isHtml)); vm.showToc() }161                            .padding(8.dp),162                    )163                    Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {164                        if (title.isEmpty()) {165                            Text(166                                "untitled note",167                                color = peachColors.T2,168                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),169                            )170                        }171                        BasicTextField(172                            value = title,173                            onValueChange = { title = it; vm.onTitleChanged(it) },174                            singleLine = true,175                            textStyle = TextStyle(176                                color = peachColors.T0,177                                fontFamily = PeachType.Display,178                                fontStyle = FontStyle.Italic,179                                fontSize = 22.sp,180                            ),181                            cursorBrush = SolidColor(accent),182                            modifier = Modifier.fillMaxWidth(),183                        )184                    }185                    Text(186                        "saved.",187                        color = peachColors.T2,188                        style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),189                        modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),190                    )191                    Icon(192                        Icons.Outlined.DeleteOutline,193                        contentDescription = "delete note",194                        tint = peachColors.T2,195                        modifier = Modifier.size(36.dp).clip(CircleShape)196                            .clickable { vm.delete {} }197                            .padding(8.dp),198                    )199                }200            }201202            if (e == null) {203                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {204                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)205                }206                return@Column207            }208209            // Formatting toolbar: every action writes the markup that saves back210            // as the browser's own tags, so formatting survives a phone edit.211            if (editing) {212                Row(213                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),214                    horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),215                    verticalAlignment = Alignment.CenterVertically,216                ) {217                    fun format(next: TextFieldValue) {218                        body = next219                        isHtml = true220                        vm.onContentChanged(noteStorageText(next.text, isHtml = true))221                    }222                    FormatButton("B", FontWeight.Bold) { format(wrapSelection(body, "**")) }223                    FormatButton("I", fontStyle = FontStyle.Italic) { format(wrapSelection(body, "*")) }224                    FormatButton("U", underline = true) { format(wrapSelection(body, "__")) }225                    Spacer(Modifier.width(4.dp))226                    FormatButton("H") { format(applyLinePrefix(body, "## ")) }227                    FormatButton("•") { format(applyLinePrefix(body, "- ")) }228                    FormatButton("1.") { format(applyLinePrefix(body, "1. ")) }229                }230            }231232            // Content area.233            Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {234                if (body.text.isEmpty()) {235                    Text(236                        "Start writing…",237                        color = peachColors.T2,238                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),239                    )240                }241                if (editing) {242                    BasicTextField(243                        value = body,244                        onValueChange = { body = it; vm.onContentChanged(noteStorageText(it.text, isHtml)) },245                        textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),246                        cursorBrush = SolidColor(accent),247                        modifier = Modifier.fillMaxSize(),248                    )249                } else {250                    Text(251                        noteRendered(body.text, isHtml),252                        color = peachColors.T0,253                        style = TextStyle(fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),254                        modifier = Modifier.fillMaxSize().clickable { editing = true },255                    )256                }257            }258        }259    }260}261262@Composable263private fun NotesToc(264    notes: List<VaultEntry>,265    onBack: () -> Unit,266    onOpenNote: (String) -> Unit,267    onNewNote: () -> Unit,268    isMultiSelect: Boolean,269    selectedIds: Set<String>,270    onEnterMultiSelect: (String) -> Unit,271    onToggleSelected: (String) -> Unit,272    onDeleteSelected: () -> Unit,273    onCancelMultiSelect: () -> Unit,274) {275    var searchVisible by remember { mutableStateOf(false) }276    var query by remember { mutableStateOf("") }277    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }278    val accent = LocalPeachAccent.current279    BackHandler(enabled = isMultiSelect, onBack = onCancelMultiSelect)280281    ShellBackground {282        Column(283            Modifier284                .fillMaxSize()285                .statusBarsPadding()286                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),287        ) {288            if (isMultiSelect) {289                MultiSelectTopBar(290                    count = selectedIds.size,291                    onDelete = onDeleteSelected,292                    onCancel = onCancelMultiSelect,293                    includeStatusBarPadding = false,294                )295            } else if (searchVisible) {296                Surface(297                    modifier = Modifier.fillMaxWidth(),298                    color = peachColors.S1,299                    shape = RoundedCornerShape(PeachSpacing.lg),300                    tonalElevation = 1.dp,301                ) {302                    Row(303                        Modifier304                            .fillMaxWidth()305                            .height(56.dp)306                            .padding(horizontal = PeachSpacing.sm),307                        verticalAlignment = Alignment.CenterVertically,308                    ) {309                        BasicTextField(310                            value = query,311                            onValueChange = { query = it },312                            singleLine = true,313                            textStyle = TextStyle(314                                color = peachColors.T0,315                                fontFamily = PeachType.Mono,316                                fontSize = 14.sp,317                            ),318                            cursorBrush = SolidColor(accent),319                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),320                            decorationBox = { inner ->321                                Box {322                                    if (query.isEmpty()) {323                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)324                                    }325                                    inner()326                                }327                            },328                        )329                        Icon(330                            Icons.Outlined.Close,331                            contentDescription = "clear search",332                            tint = peachColors.T0,333                            modifier = Modifier.size(40.dp).clip(CircleShape)334                                .clickable {335                                    query = ""336                                    searchVisible = false337                                }338                                .padding(9.dp),339                        )340                    }341                }342            } else {343                PeachTopBar(344                    title = "notes.",345                    leading = {346                        Icon(347                            Icons.AutoMirrored.Rounded.ArrowBack,348                            contentDescription = "back to vault",349                            tint = peachColors.T0,350                            modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))351                                .clickable(onClick = onBack),352                        )353                    },354                    actions = {355                        Icon(356                            Icons.Outlined.Search,357                            contentDescription = "search notes",358                            tint = peachColors.T0,359                            modifier = Modifier.size(28.dp).clip(CircleShape)360                                .clickable { searchVisible = true }361                                .padding(5.dp),362                        )363                    },364                )365            }366            Spacer(Modifier.size(PeachSpacing.lg))367368            LazyColumn(369                Modifier.weight(1f),370                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),371            ) {372                if (filtered.isEmpty()) {373                    item {374                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {375                            PeachEmptyOrEndState(376                                message = if (query.isBlank()) "No notes yet." else "No notes match.",377                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",378                            )379                        }380                    }381                } else {382                    items(filtered, key = { it.id }) { note ->383                        NoteTocRow(384                            note = note,385                            multiSelect = isMultiSelect,386                            selected = note.id in selectedIds,387                            onClick = {388                                if (isMultiSelect) onToggleSelected(note.id)389                                else onOpenNote(note.id)390                            },391                            onLongClick = {392                                if (isMultiSelect) onToggleSelected(note.id)393                                else onEnterMultiSelect(note.id)394                            },395                        )396                    }397                }398            }399400            if (!isMultiSelect) {401                Row(402                    Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)403                        .clip(RoundedCornerShape(8.dp))404                        .background(peachColors.S2)405                        .clickable(onClick = onNewNote)406                        .padding(horizontal = 14.dp, vertical = 12.dp),407                    verticalAlignment = Alignment.CenterVertically,408                    horizontalArrangement = Arrangement.spacedBy(10.dp),409                ) {410                    Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))411                    Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)412                }413            }414        }415    }416}417418@OptIn(ExperimentalFoundationApi::class)419@Composable420private fun NoteTocRow(421    note: VaultEntry,422    multiSelect: Boolean,423    selected: Boolean,424    onClick: () -> Unit,425    onLongClick: () -> Unit,426) {427    val context = LocalContext.current428    val accent = LocalPeachAccent.current429    Row(430        Modifier.fillMaxWidth()431            .clip(RoundedCornerShape(8.dp))432            .background(if (selected) accent.copy(alpha = 0.14f) else peachColors.S1)433            .combinedClickable(434                onClick = onClick,435                onLongClickLabel = "Select note",436                onLongClick = {437                    PeachHaptics.onLongPress(context)438                    onLongClick()439                },440            )441            .padding(horizontal = 14.dp, vertical = 12.dp),442        horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm),443        verticalAlignment = Alignment.CenterVertically,444    ) {445        if (multiSelect) MultiSelectCheckbox(checked = selected)446        Column(447            Modifier.weight(1f),448            verticalArrangement = Arrangement.spacedBy(5.dp),449        ) {450            Text(451                note.title.ifBlank { "Untitled" },452                color = peachColors.T0,453                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),454            )455            val preview = notePreviewLine(note.notes.orEmpty())456            Text(457                preview.ifBlank { "No body text" },458                color = peachColors.T2,459                style = PeachType.bodyMono,460                maxLines = 2,461            )462        }463    }464}465466/**467 * Wrap the current selection in [marker] (the inline markup NoteHtml serialises468 * back to a tag). Collapsed selections are returned unchanged — there is nothing469 * to mark.470 */471internal fun wrapSelection(value: TextFieldValue, marker: String): TextFieldValue {472    val sel = value.selection473    if (sel.collapsed) return value474    val text = value.text475    val next = text.substring(0, sel.min) + marker +476        text.substring(sel.min, sel.max) + marker +477        text.substring(sel.max)478    return value.copy(479        text = next,480        selection = TextRange(sel.min, sel.max + 2 * marker.length),481    )482}483484internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {485    val text = value.text486    val sel = value.selection487    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }488    val endExclusive = if (sel.collapsed) {489        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }490    } else {491        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }492    }493    val block = text.substring(start, endExclusive)494    val transformed = block495        .split('\n')496        .joinToString("\n") { line ->497            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line498        }499    val nextText = text.replaceRange(start, endExclusive, transformed)500    val delta = nextText.length - text.length501    return value.copy(502        text = nextText,503        selection = TextRange(504            sel.start.coerceIn(0, nextText.length),505            (sel.end + delta).coerceIn(0, nextText.length),506        ),507    )508}509510internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {511    val q = query.trim().lowercase()512    if (q.isBlank()) return notes513    return notes.filter { note ->514        note.title.lowercase().contains(q) ||515            notePlainText(note.notes.orEmpty()).lowercase().contains(q)516    }517}518519@Composable520private fun FormatButton(521    label: String,522    fontWeight: FontWeight? = null,523    fontStyle: FontStyle? = null,524    underline: Boolean = false,525    onClick: () -> Unit,526) {527    Box(528        Modifier529            .size(30.dp)530            .clip(RoundedCornerShape(6.dp))531            .background(peachColors.S2)532            .clickable(onClick = onClick),533        contentAlignment = Alignment.Center,534    ) {535        Text(536            label,537            color = peachColors.T0,538            style = TextStyle(539                fontFamily = PeachType.Body,540                fontSize = 14.sp,541                fontWeight = fontWeight,542                fontStyle = fontStyle,543                textDecoration = if (underline) TextDecoration.Underline else null,544            ),545        )546    }547}