NotesModeScreen.kt

19.3 KB · raw

1package com.peachpasswords.ui.vault23import android.view.WindowManager4import androidx.compose.animation.core.animateFloatAsState5import androidx.compose.animation.core.tween6import androidx.compose.foundation.background7import androidx.compose.foundation.clickable8import androidx.compose.foundation.layout.Arrangement9import androidx.compose.foundation.layout.Box10import androidx.compose.foundation.layout.Column11import androidx.compose.foundation.layout.Row12import androidx.compose.foundation.layout.Spacer13import androidx.compose.foundation.layout.fillMaxSize14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.height16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.layout.statusBarsPadding19import androidx.compose.foundation.layout.width20import androidx.compose.foundation.lazy.LazyColumn21import androidx.compose.foundation.lazy.items22import androidx.compose.foundation.shape.CircleShape23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.material.icons.Icons26import androidx.compose.material.icons.automirrored.rounded.ArrowBack27import androidx.compose.material.icons.outlined.Add28import androidx.compose.material.icons.outlined.Close29import androidx.compose.material.icons.outlined.DeleteOutline30import androidx.compose.material.icons.outlined.Search31import androidx.compose.material3.Icon32import androidx.compose.material3.Surface33import androidx.compose.material3.Text34import androidx.compose.runtime.Composable35import androidx.compose.runtime.LaunchedEffect36import androidx.compose.runtime.getValue37import androidx.compose.runtime.mutableStateOf38import androidx.compose.runtime.remember39import androidx.compose.runtime.setValue40import androidx.compose.ui.Alignment41import androidx.compose.ui.Modifier42import androidx.compose.ui.draw.alpha43import androidx.compose.ui.draw.clip44import androidx.compose.ui.graphics.SolidColor45import androidx.compose.ui.platform.LocalContext46import androidx.compose.ui.text.AnnotatedString47import androidx.compose.ui.text.SpanStyle48import androidx.compose.ui.text.TextStyle49import androidx.compose.ui.text.TextRange50import androidx.compose.ui.text.buildAnnotatedString51import 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.text.withStyle56import androidx.compose.ui.unit.dp57import androidx.compose.ui.unit.sp58import androidx.compose.runtime.collectAsState59import androidx.fragment.app.FragmentActivity60import androidx.hilt.navigation.compose.hiltViewModel61import com.peachpasswords.ui.components.PeachEmptyOrEndState62import com.peachpasswords.ui.components.PeachTopBar63import com.peachpasswords.ui.theme.PeachSpacing64import com.peachpasswords.vault.VaultEntry65import com.peachpasswords.ui.theme.LocalPeachAccent66import com.peachpasswords.ui.theme.peachColors67import com.peachpasswords.ui.theme.PeachType68import com.peachpasswords.ui.theme.ShellBackground6970/**71 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U72 * formatting toolbar that applies [SpanStyle] to the current selection, a73 * full-width Inter body field, and an 800ms-debounced auto-save with a "saved."74 * pulse. FLAG_SECURE blocks screenshots while open (same as EntryDetail).75 *76 * Span support is intentionally basic (no markdown/persistence of styles): the77 * toolbar styles the live [AnnotatedString] selection; only the plain text is78 * persisted via the ViewModel.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 savedPulse by vm.savedPulse.collectAsState()89    val accent = LocalPeachAccent.current90    val context = LocalContext.current9192    // FLAG_SECURE only while shown.93    androidx.compose.runtime.DisposableEffect(Unit) {94        val window = (context as? FragmentActivity)?.window95        window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)96        onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }97    }9899    // "saved." indicator fades in on each save pulse, holds, then fades out.100    var showSaved by remember { mutableStateOf(false) }101    LaunchedEffect(savedPulse) {102        if (savedPulse == 0L) return@LaunchedEffect103        showSaved = true104        kotlinx.coroutines.delay(1300)105        showSaved = false106    }107    val savedAlpha by animateFloatAsState(108        targetValue = if (showSaved) 1f else 0f,109        animationSpec = tween(300),110        label = "saved-fade",111    )112113    if (activeNoteId == null) {114        NotesToc(115            notes = notes,116            onBack = onBack,117            onOpenNote = vm::openNote,118            onNewNote = vm::createNote,119        )120        return121    }122123    val e = entry124    var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }125    var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }126127    ShellBackground {128        Column(129            Modifier130                .fillMaxSize()131                .statusBarsPadding()132                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),133        ) {134            Surface(135                modifier = Modifier.fillMaxWidth(),136                color = peachColors.S1,137                contentColor = peachColors.T0,138                shape = RoundedCornerShape(PeachSpacing.lg),139                tonalElevation = 1.dp,140            ) {141                Row(142                    Modifier143                        .fillMaxWidth()144                        .height(56.dp)145                        .padding(horizontal = PeachSpacing.sm),146                    verticalAlignment = Alignment.CenterVertically,147                ) {148                    Icon(149                        Icons.AutoMirrored.Rounded.ArrowBack,150                        contentDescription = "back",151                        tint = peachColors.T0,152                        modifier = Modifier.size(40.dp).clip(CircleShape)153                            .clickable { vm.saveNow(title, body.text); vm.showToc() }.padding(8.dp),154                    )155                    Box(Modifier.weight(1f).padding(horizontal = PeachSpacing.xs)) {156                        if (title.isEmpty()) {157                            Text(158                                "untitled note",159                                color = peachColors.T2,160                                style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),161                            )162                        }163                        BasicTextField(164                            value = title,165                            onValueChange = { title = it; vm.onTitleChanged(it) },166                            singleLine = true,167                            textStyle = TextStyle(168                                color = peachColors.T0,169                                fontFamily = PeachType.Display,170                                fontStyle = FontStyle.Italic,171                                fontSize = 22.sp,172                            ),173                            cursorBrush = SolidColor(accent),174                            modifier = Modifier.fillMaxWidth(),175                        )176                    }177                    Text(178                        "saved.",179                        color = peachColors.T2,180                        style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),181                        modifier = Modifier.padding(end = PeachSpacing.sm).alpha(savedAlpha),182                    )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                }192            }193194            if (e == null) {195                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {196                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)197                }198                return@Column199            }200201            // Formatting toolbar: browser-compatible inline and block actions.202            Row(203                Modifier.fillMaxWidth().padding(vertical = PeachSpacing.sm),204                horizontalArrangement = Arrangement.spacedBy(PeachSpacing.xs),205                verticalAlignment = Alignment.CenterVertically,206            ) {207                FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }208                FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }209                FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }210                Spacer(Modifier.width(4.dp))211                FormatButton("H") { body = applyLinePrefix(body, "## ") }212                FormatButton("\"") { body = applyLinePrefix(body, "> ") }213                FormatButton("•") { body = applyLinePrefix(body, "- ") }214                FormatButton("1.") { body = applyLinePrefix(body, "1. ") }215            }216217            // Content area.218            Box(Modifier.fillMaxSize().padding(vertical = PeachSpacing.sm)) {219                if (body.text.isEmpty()) {220                    Text(221                        "Start writing…",222                        color = peachColors.T2,223                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),224                    )225                }226                BasicTextField(227                    value = body,228                    onValueChange = { body = it; vm.onContentChanged(it.text) },229                    textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),230                    cursorBrush = SolidColor(accent),231                    modifier = Modifier.fillMaxSize(),232                )233            }234        }235    }236}237238@Composable239private fun NotesToc(240    notes: List<VaultEntry>,241    onBack: () -> Unit,242    onOpenNote: (String) -> Unit,243    onNewNote: () -> Unit,244) {245    var searchVisible by remember { mutableStateOf(false) }246    var query by remember { mutableStateOf("") }247    val filtered = remember(notes, query) { filterNotesForToc(notes, query) }248    val accent = LocalPeachAccent.current249250    ShellBackground {251        Column(252            Modifier253                .fillMaxSize()254                .statusBarsPadding()255                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),256        ) {257            if (searchVisible) {258                Surface(259                    modifier = Modifier.fillMaxWidth(),260                    color = peachColors.S1,261                    shape = RoundedCornerShape(PeachSpacing.lg),262                    tonalElevation = 1.dp,263                ) {264                    Row(265                        Modifier266                            .fillMaxWidth()267                            .height(56.dp)268                            .padding(horizontal = PeachSpacing.sm),269                        verticalAlignment = Alignment.CenterVertically,270                    ) {271                        BasicTextField(272                            value = query,273                            onValueChange = { query = it },274                            singleLine = true,275                            textStyle = TextStyle(276                                color = peachColors.T0,277                                fontFamily = PeachType.Mono,278                                fontSize = 14.sp,279                            ),280                            cursorBrush = SolidColor(accent),281                            modifier = Modifier.weight(1f).padding(horizontal = PeachSpacing.sm),282                            decorationBox = { inner ->283                                Box {284                                    if (query.isEmpty()) {285                                        Text("search notes…", color = peachColors.T2, style = PeachType.searchInput)286                                    }287                                    inner()288                                }289                            },290                        )291                        Icon(292                            Icons.Outlined.Close,293                            contentDescription = "clear search",294                            tint = peachColors.T0,295                            modifier = Modifier.size(40.dp).clip(CircleShape)296                                .clickable {297                                    query = ""298                                    searchVisible = false299                                }300                                .padding(9.dp),301                        )302                    }303                }304            } else {305                PeachTopBar(306                    title = "notes.",307                    leading = {308                        Icon(309                            Icons.AutoMirrored.Rounded.ArrowBack,310                            contentDescription = "back to vault",311                            tint = peachColors.T0,312                            modifier = Modifier.size(22.dp).clip(RoundedCornerShape(PeachSpacing.sm))313                                .clickable(onClick = onBack),314                        )315                    },316                    actions = {317                        Icon(318                            Icons.Outlined.Search,319                            contentDescription = "search notes",320                            tint = peachColors.T0,321                            modifier = Modifier.size(28.dp).clip(CircleShape)322                                .clickable { searchVisible = true }323                                .padding(5.dp),324                        )325                    },326                )327            }328            Spacer(Modifier.size(PeachSpacing.lg))329330            LazyColumn(331                Modifier.weight(1f),332                verticalArrangement = Arrangement.spacedBy(PeachSpacing.sm),333            ) {334                if (filtered.isEmpty()) {335                    item {336                        Box(Modifier.fillParentMaxHeight(), contentAlignment = Alignment.Center) {337                            PeachEmptyOrEndState(338                                message = if (query.isBlank()) "No notes yet." else "No notes match.",339                                detail = if (query.isBlank()) "Start writing when you are ready." else "Try a different search.",340                            )341                        }342                    }343                } else {344                    items(filtered, key = { it.id }) { note ->345                        NoteTocRow(note = note, onClick = { onOpenNote(note.id) })346                    }347                }348            }349350            Row(351                Modifier.fillMaxWidth().padding(vertical = PeachSpacing.md)352                    .clip(RoundedCornerShape(8.dp))353                    .background(peachColors.S2)354                    .clickable(onClick = onNewNote)355                    .padding(horizontal = 14.dp, vertical = 12.dp),356                verticalAlignment = Alignment.CenterVertically,357                horizontalArrangement = Arrangement.spacedBy(10.dp),358            ) {359                Icon(Icons.Outlined.Add, contentDescription = null, tint = accent, modifier = Modifier.size(18.dp))360                Text("+ new note", color = peachColors.T0, style = PeachType.bodyMono)361            }362        }363    }364}365366@Composable367private fun NoteTocRow(note: VaultEntry, onClick: () -> Unit) {368    Column(369        Modifier.fillMaxWidth()370            .clip(RoundedCornerShape(8.dp))371            .background(peachColors.S1)372            .clickable(onClick = onClick)373            .padding(horizontal = 14.dp, vertical = 12.dp),374        verticalArrangement = Arrangement.spacedBy(5.dp),375    ) {376        Text(377            note.title.ifBlank { "Untitled" },378            color = peachColors.T0,379            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 20.sp),380        )381        val preview = note.notes?.lineSequence()?.firstOrNull { it.isNotBlank() }?.trim().orEmpty()382        Text(383            preview.ifBlank { "No body text" },384            color = peachColors.T2,385            style = PeachType.bodyMono,386            maxLines = 2,387        )388    }389}390391/**392 * Apply [style] to the current selection of [value], preserving the text and393 * existing spans. If the selection is collapsed, the value is returned unchanged394 * (basic span support — styling needs a non-empty selection).395 */396private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue {397    val sel = value.selection398    if (sel.collapsed) return value399    val start = sel.min400    val end = sel.max401    val existing = value.annotatedString402    val merged: AnnotatedString = buildAnnotatedString {403        append(existing)404        addStyle(style, start, end)405    }406    return value.copy(annotatedString = merged)407}408409internal fun applyLinePrefix(value: TextFieldValue, prefix: String): TextFieldValue {410    val text = value.text411    val sel = value.selection412    val start = text.lastIndexOf('\n', (sel.min - 1).coerceAtLeast(0)).let { if (it == -1) 0 else it + 1 }413    val endExclusive = if (sel.collapsed) {414        text.indexOf('\n', sel.max).let { if (it == -1) text.length else it }415    } else {416        text.indexOf('\n', (sel.max - 1).coerceAtLeast(0)).let { if (it == -1) text.length else it }417    }418    val block = text.substring(start, endExclusive)419    val transformed = block420        .split('\n')421        .joinToString("\n") { line ->422            if (line.startsWith(prefix)) line.removePrefix(prefix) else prefix + line423        }424    val nextText = text.replaceRange(start, endExclusive, transformed)425    val delta = nextText.length - text.length426    return value.copy(427        text = nextText,428        selection = TextRange(429            sel.start.coerceIn(0, nextText.length),430            (sel.end + delta).coerceIn(0, nextText.length),431        ),432    )433}434435internal fun filterNotesForToc(notes: List<VaultEntry>, query: String): List<VaultEntry> {436    val q = query.trim().lowercase()437    if (q.isBlank()) return notes438    return notes.filter { note ->439        note.title.lowercase().contains(q) ||440            note.notes.orEmpty().lowercase().contains(q)441    }442}443444@Composable445private fun FormatButton(446    label: String,447    fontWeight: FontWeight? = null,448    fontStyle: FontStyle? = null,449    underline: Boolean = false,450    onClick: () -> Unit,451) {452    Box(453        Modifier454            .size(30.dp)455            .clip(RoundedCornerShape(6.dp))456            .background(peachColors.S2)457            .clickable(onClick = onClick),458        contentAlignment = Alignment.Center,459    ) {460        Text(461            label,462            color = peachColors.T0,463            style = TextStyle(464                fontFamily = PeachType.Body,465                fontSize = 14.sp,466                fontWeight = fontWeight,467                fontStyle = fontStyle,468                textDecoration = if (underline) TextDecoration.Underline else null,469            ),470        )471    }472}