NotesModeScreen.kt

9.5 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.shape.CircleShape21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.foundation.text.BasicTextField23import androidx.compose.material.icons.Icons24import androidx.compose.material.icons.automirrored.rounded.ArrowBack25import androidx.compose.material3.Icon26import androidx.compose.material3.Text27import androidx.compose.runtime.Composable28import androidx.compose.runtime.LaunchedEffect29import androidx.compose.runtime.getValue30import androidx.compose.runtime.mutableStateOf31import androidx.compose.runtime.remember32import androidx.compose.runtime.setValue33import androidx.compose.ui.Alignment34import androidx.compose.ui.Modifier35import androidx.compose.ui.draw.alpha36import androidx.compose.ui.draw.clip37import androidx.compose.ui.graphics.SolidColor38import androidx.compose.ui.platform.LocalContext39import androidx.compose.ui.text.AnnotatedString40import androidx.compose.ui.text.SpanStyle41import androidx.compose.ui.text.TextStyle42import androidx.compose.ui.text.buildAnnotatedString43import androidx.compose.ui.text.font.FontStyle44import androidx.compose.ui.text.font.FontWeight45import androidx.compose.ui.text.input.TextFieldValue46import androidx.compose.ui.text.style.TextDecoration47import androidx.compose.ui.text.withStyle48import androidx.compose.ui.unit.dp49import androidx.compose.ui.unit.sp50import androidx.compose.runtime.collectAsState51import androidx.fragment.app.FragmentActivity52import androidx.hilt.navigation.compose.hiltViewModel53import com.peachpasswords.ui.theme.LocalPeachAccent54import com.peachpasswords.ui.theme.peachColors55import com.peachpasswords.ui.theme.PeachType56import com.peachpasswords.ui.theme.ShellBackground5758/**59 * E2 — full-screen editor for NOTE-type entries. Editable inline title, a B/I/U60 * formatting toolbar that applies [SpanStyle] to the current selection, a61 * full-width Inter body field, and an 800ms-debounced auto-save with a "saved."62 * pulse. FLAG_SECURE blocks screenshots while open (same as EntryDetail).63 *64 * Span support is intentionally basic (no markdown/persistence of styles): the65 * toolbar styles the live [AnnotatedString] selection; only the plain text is66 * persisted via the ViewModel.67 */68@Composable69fun NotesModeScreen(70    onBack: () -> Unit,71    vm: NotesModeViewModel = hiltViewModel(),72) {73    val entry by vm.entry.collectAsState()74    val savedPulse by vm.savedPulse.collectAsState()75    val accent = LocalPeachAccent.current76    val context = LocalContext.current7778    // FLAG_SECURE only while shown.79    androidx.compose.runtime.DisposableEffect(Unit) {80        val window = (context as? FragmentActivity)?.window81        window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)82        onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }83    }8485    val e = entry8687    // "saved." indicator fades in on each save pulse, holds, then fades out.88    var showSaved by remember { mutableStateOf(false) }89    LaunchedEffect(savedPulse) {90        if (savedPulse == 0L) return@LaunchedEffect91        showSaved = true92        kotlinx.coroutines.delay(1300)93        showSaved = false94    }95    val savedAlpha by animateFloatAsState(96        targetValue = if (showSaved) 1f else 0f,97        animationSpec = tween(300),98        label = "saved-fade",99    )100101    var title by remember(e?.id) { mutableStateOf(e?.title ?: "") }102    var body by remember(e?.id) { mutableStateOf(TextFieldValue(e?.notes ?: "")) }103104    ShellBackground {105        Column(Modifier.fillMaxSize().statusBarsPadding()) {106            // Top bar: back · editable title · "saved." indicator107            Row(108                Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),109                verticalAlignment = Alignment.CenterVertically,110            ) {111                Icon(112                    Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,113                    modifier = Modifier.size(40.dp).clip(CircleShape)114                        .clickable { vm.saveTitleNow(title); onBack() }.padding(8.dp),115                )116                Box(Modifier.weight(1f).padding(horizontal = 4.dp)) {117                    if (title.isEmpty()) {118                        Text(119                            "untitled note",120                            color = peachColors.T2,121                            style = TextStyle(fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp),122                        )123                    }124                    BasicTextField(125                        value = title,126                        onValueChange = { title = it; vm.onTitleChanged(it) },127                        singleLine = true,128                        textStyle = TextStyle(129                            color = peachColors.T0,130                            fontFamily = PeachType.Display,131                            fontStyle = FontStyle.Italic,132                            fontSize = 22.sp,133                        ),134                        cursorBrush = SolidColor(accent),135                        modifier = Modifier.fillMaxWidth(),136                    )137                }138                Text(139                    "saved.",140                    color = peachColors.T2,141                    style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),142                    modifier = Modifier.padding(end = 8.dp).alpha(savedAlpha),143                )144            }145146            if (e == null) {147                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {148                    Text("note not found", color = peachColors.T1, style = PeachType.bodyMono)149                }150                return@Column151            }152153            // Formatting toolbar: B / I / U — apply span to current selection.154            Row(155                Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),156                horizontalArrangement = Arrangement.spacedBy(6.dp),157                verticalAlignment = Alignment.CenterVertically,158            ) {159                FormatButton("B", FontWeight.Bold) { body = applySpan(body, SpanStyle(fontWeight = FontWeight.Bold)) }160                FormatButton("I", fontStyle = FontStyle.Italic) { body = applySpan(body, SpanStyle(fontStyle = FontStyle.Italic)) }161                FormatButton("U", underline = true) { body = applySpan(body, SpanStyle(textDecoration = TextDecoration.Underline)) }162            }163164            // Content area.165            Box(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp)) {166                if (body.text.isEmpty()) {167                    Text(168                        "Start writing…",169                        color = peachColors.T2,170                        style = TextStyle(fontFamily = PeachType.Body, fontStyle = FontStyle.Italic, fontSize = 14.sp),171                    )172                }173                BasicTextField(174                    value = body,175                    onValueChange = { body = it; vm.onContentChanged(it.text) },176                    textStyle = TextStyle(color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 14.sp, lineHeight = 22.sp),177                    cursorBrush = SolidColor(accent),178                    modifier = Modifier.fillMaxSize(),179                )180            }181        }182    }183}184185/**186 * Apply [style] to the current selection of [value], preserving the text and187 * existing spans. If the selection is collapsed, the value is returned unchanged188 * (basic span support — styling needs a non-empty selection).189 */190private fun applySpan(value: TextFieldValue, style: SpanStyle): TextFieldValue {191    val sel = value.selection192    if (sel.collapsed) return value193    val start = sel.min194    val end = sel.max195    val existing = value.annotatedString196    val merged: AnnotatedString = buildAnnotatedString {197        append(existing)198        addStyle(style, start, end)199    }200    return value.copy(annotatedString = merged)201}202203@Composable204private fun FormatButton(205    label: String,206    fontWeight: FontWeight? = null,207    fontStyle: FontStyle? = null,208    underline: Boolean = false,209    onClick: () -> Unit,210) {211    Box(212        Modifier213            .size(30.dp)214            .clip(RoundedCornerShape(6.dp))215            .background(peachColors.S2)216            .clickable(onClick = onClick),217        contentAlignment = Alignment.Center,218    ) {219        Text(220            label,221            color = peachColors.T0,222            style = TextStyle(223                fontFamily = PeachType.Body,224                fontSize = 14.sp,225                fontWeight = fontWeight,226                fontStyle = fontStyle,227                textDecoration = if (underline) TextDecoration.Underline else null,228            ),229        )230    }231}