ContextMenuSheet.kt

6.0 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.background4import androidx.compose.foundation.clickable5import androidx.compose.foundation.interaction.MutableInteractionSource6import androidx.compose.foundation.interaction.collectIsPressedAsState7import androidx.compose.foundation.layout.Arrangement8import androidx.compose.foundation.layout.Box9import androidx.compose.foundation.layout.Column10import androidx.compose.foundation.layout.Row11import androidx.compose.foundation.layout.Spacer12import androidx.compose.foundation.layout.fillMaxWidth13import androidx.compose.foundation.layout.height14import androidx.compose.foundation.layout.heightIn15import androidx.compose.foundation.layout.navigationBarsPadding16import androidx.compose.foundation.layout.padding17import androidx.compose.material3.ExperimentalMaterial3Api18import androidx.compose.material3.ModalBottomSheet19import androidx.compose.material3.Text20import androidx.compose.material3.rememberModalBottomSheetState21import androidx.compose.runtime.Composable22import androidx.compose.runtime.getValue23import androidx.compose.runtime.remember24import androidx.compose.ui.Alignment25import androidx.compose.ui.Modifier26import androidx.compose.ui.draw.drawBehind27import androidx.compose.ui.geometry.Offset28import androidx.compose.ui.geometry.Size29import androidx.compose.ui.graphics.Color30import androidx.compose.ui.text.font.FontStyle31import androidx.compose.ui.text.style.TextAlign32import androidx.compose.ui.unit.dp33import androidx.compose.ui.unit.sp34import com.peachpasswords.ui.theme.LocalPeachAccent35import com.peachpasswords.ui.theme.peachColors36import com.peachpasswords.ui.theme.PeachType37import com.peachpasswords.vault.VaultEntry38import com.peachpasswords.vault.hasTOTP3940/**41 * D12 long-press context menu (spec §9). A glass ModalBottomSheet over an entry42 * row offering quick copy / edit / trash without opening the detail screen.43 *44 * Self-contained, drop-in. Triggered from VaultListScreen on a 500ms long-press45 * (see INTEGRATION.md for the pointerInput anchor). Callbacks are wired to the46 * VM in the host; this composable owns no business logic.47 */48@OptIn(ExperimentalMaterial3Api::class)49@Composable50fun ContextMenuSheet(51    entry: VaultEntry,52    onCopyPassword: () -> Unit,53    onCopyUsername: () -> Unit,54    onCopyTotp: () -> Unit,55    onEdit: () -> Unit,56    onMoveToTrash: () -> Unit,57    onDismiss: () -> Unit,58) {59    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)6061    ModalBottomSheet(62        onDismissRequest = onDismiss,63        sheetState = sheetState,64        containerColor = peachColors.SurfaceElevated,65        dragHandle = null,66    ) {67        Column(68            Modifier69                .fillMaxWidth()70                .padding(top = 8.dp)71                .navigationBarsPadding(),72        ) {73            // Entry name header — non-interactive title.74            Text(75                text = entry.title,76                color = peachColors.T0,77                style = PeachType.entryNameList.copy(fontSize = 15.sp),78                modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),79            )80            Divider()8182            val username = entry.username ?: entry.email83            if (!entry.password.isNullOrEmpty()) {84                ActionRow(label = "Copy password", onClick = { onCopyPassword(); onDismiss() })85            }86            if (!username.isNullOrEmpty()) {87                ActionRow(label = "Copy username", onClick = { onCopyUsername(); onDismiss() })88            }89            if (entry.hasTOTP()) {90                ActionRow(label = "Copy TOTP code", onClick = { onCopyTotp(); onDismiss() })91            }92            ActionRow(label = "Edit", onClick = { onEdit(); onDismiss() })93            ActionRow(94                label = "Move to trash",95                textColor = peachColors.Breach,96                onClick = { onMoveToTrash(); onDismiss() },97            )9899            Divider()100            CancelRow(onClick = onDismiss)101            Spacer(Modifier.height(4.dp))102        }103    }104}105106@Composable107private fun Divider() {108    Box(109        Modifier110            .fillMaxWidth()111            .height(1.dp)112            .background(peachColors.T3),113    )114}115116/**117 * A single action row: 44dp min height, IBM Plex Mono 12sp, with a 2dp accent118 * left-border that fades in on press (the spec's "accent left-border on press").119 */120@Composable121private fun ActionRow(122    label: String,123    textColor: Color = peachColors.T0,124    onClick: () -> Unit,125) {126    val accent = LocalPeachAccent.current127    val interaction = remember { MutableInteractionSource() }128    val pressed by interaction.collectIsPressedAsState()129    Row(130        modifier = Modifier131            .fillMaxWidth()132            .heightIn(min = 44.dp)133            .clickable(interactionSource = interaction, indication = null, onClick = onClick)134            .background(if (pressed) peachColors.T4 else Color.Transparent)135            .drawBehind {136                if (pressed) {137                    drawRect(138                        color = accent,139                        topLeft = Offset.Zero,140                        size = Size(2.dp.toPx(), size.height),141                    )142                }143            }144            .padding(horizontal = 20.dp, vertical = 12.dp),145        verticalAlignment = Alignment.CenterVertically,146    ) {147        Text(148            text = label,149            color = textColor,150            style = PeachType.bodyMono.copy(fontSize = 12.sp),151        )152    }153}154155@Composable156private fun CancelRow(onClick: () -> Unit) {157    Row(158        modifier = Modifier159            .fillMaxWidth()160            .heightIn(min = 44.dp)161            .clickable(onClick = onClick)162            .padding(horizontal = 20.dp, vertical = 12.dp),163        horizontalArrangement = Arrangement.Center,164        verticalAlignment = Alignment.CenterVertically,165    ) {166        Text(167            text = "Cancel",168            color = peachColors.T2,169            style = PeachType.labelMono.copy(fontSize = 11.sp),170            textAlign = TextAlign.Center,171        )172    }173}