AdditionalUrlsDetail.kt

11.4 KB · raw

1package com.peachpasswords.ui.vault23import android.content.ActivityNotFoundException4import android.content.Context5import android.content.Intent6import android.net.Uri7import android.widget.Toast8import androidx.compose.animation.core.LinearEasing9import androidx.compose.animation.core.animateFloatAsState10import androidx.compose.animation.core.tween11import androidx.compose.foundation.background12import androidx.compose.foundation.clickable13import androidx.compose.foundation.layout.Arrangement14import androidx.compose.foundation.layout.Box15import androidx.compose.foundation.layout.Column16import androidx.compose.foundation.layout.Row17import androidx.compose.foundation.layout.Spacer18import androidx.compose.foundation.layout.fillMaxWidth19import androidx.compose.foundation.layout.height20import androidx.compose.foundation.layout.padding21import androidx.compose.foundation.layout.size22import androidx.compose.foundation.layout.width23import androidx.compose.foundation.shape.RoundedCornerShape24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.material.icons.Icons26import androidx.compose.material.icons.automirrored.rounded.OpenInNew27import androidx.compose.material.icons.rounded.Check28import androidx.compose.material.icons.rounded.ContentCopy29import androidx.compose.material3.Icon30import androidx.compose.material3.Text31import androidx.compose.runtime.Composable32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateOf34import androidx.compose.ui.Alignment35import androidx.compose.ui.Modifier36import androidx.compose.ui.draw.clip37import androidx.compose.ui.draw.drawBehind38import androidx.compose.ui.geometry.Offset39import androidx.compose.ui.geometry.Size40import androidx.compose.ui.graphics.Brush41import androidx.compose.ui.graphics.Color42import androidx.compose.ui.graphics.SolidColor43import androidx.compose.ui.platform.LocalContext44import androidx.compose.ui.text.TextStyle45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.em47import androidx.compose.ui.unit.sp48import androidx.compose.runtime.remember49import androidx.compose.runtime.setValue50import com.peachpasswords.ui.theme.LocalPeachAccent51import com.peachpasswords.ui.theme.LocalReduceMotion52import com.peachpasswords.ui.theme.peachColors53import com.peachpasswords.ui.theme.PeachType5455/**56 * E3 — additional-URL rows for EntryDetailScreen. Self-contained: it carries its57 * own field shell, copy button, and open-in-browser control so it can be inserted58 * into the detail field stack without touching the screen's private `FieldShell`59 * / `CopyButton`. Each row shows "URL N", the value, copy, and open-in-browser.60 *61 * `copiedKeyFor(index)` is the per-row clipboard latch key the caller passes to62 * `vm.copy(key, value)`; `copiedField` is the screen's current latch.63 */64@Composable65fun AdditionalUrlRows(66    urls: List<String>,67    copiedField: String?,68    onCopy: (key: String, value: String) -> Unit,69    onSave: (index: Int, value: String) -> Unit = { _, _ -> },70    onAdd: (value: String) -> Unit = {},71) {72    val context = LocalContext.current73    urls.forEachIndexed { index, url ->74        if (url.isNotBlank()) {75            val key = additionalUrlCopyKey(index)76            var editing by remember(url) { mutableStateOf(false) }77            var draft by remember(url) { mutableStateOf(url) }78            AdditionalUrlFieldShell(label = "URL ${index + 2}") {79                if (editing) {80                    Column(Modifier.weight(1f)) {81                        AdditionalUrlInlineField(82                            draft = draft,83                            onDraftChange = { draft = it },84                            modifier = Modifier.fillMaxWidth(),85                        )86                        Spacer(Modifier.height(8.dp))87                        AdditionalUrlSaveCancelRow(88                            onSave = { onSave(index, draft); editing = false },89                            onCancel = { draft = url; editing = false },90                        )91                    }92                } else {93                    Text(94                        url,95                        color = peachColors.T0,96                        style = PeachType.bodyMono,97                        modifier = Modifier.weight(1f).clickable { draft = url; editing = true },98                    )99                    OpenInBrowserButton(onClick = { openUrlInBrowser(context, url) })100                    Spacer(Modifier.width(4.dp))101                    AdditionalUrlCopyButton(copied = copiedField == key, onCopy = { onCopy(key, url) })102                }103            }104        }105    }106    AddAdditionalUrlRow(onAdd = onAdd)107}108109@Composable110private fun AdditionalUrlInlineField(111    draft: String,112    onDraftChange: (String) -> Unit,113    modifier: Modifier = Modifier,114) {115    val accent = LocalPeachAccent.current116    BasicTextField(117        value = draft,118        onValueChange = onDraftChange,119        singleLine = true,120        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),121        cursorBrush = SolidColor(accent),122        modifier = modifier123            .padding(bottom = 4.dp)124            .drawBehind {125                drawLine(126                    color = accent,127                    start = Offset(0f, size.height),128                    end = Offset(size.width, size.height),129                    strokeWidth = 1.dp.toPx(),130                )131            },132    )133}134135@Composable136private fun AdditionalUrlSaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {137    val accent = LocalPeachAccent.current138    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {139        Text(140            "✓ save", color = accent, style = PeachType.labelMono,141            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),142        )143        Text(144            "✗", color = peachColors.T2, style = PeachType.labelMono,145            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),146        )147    }148}149150@Composable151private fun AddAdditionalUrlRow(onAdd: (String) -> Unit) {152    val accent = LocalPeachAccent.current153    var editing by remember { mutableStateOf(false) }154    var draft by remember { mutableStateOf("") }155    if (!editing) {156        Text(157            "+ add another url",158            color = accent,159            style = PeachType.labelMono,160            modifier = Modifier161                .clip(RoundedCornerShape(6.dp))162                .clickable { editing = true }163                .padding(vertical = 6.dp),164        )165        return166    }167    AdditionalUrlFieldShell(label = "new url") {168        Column(Modifier.weight(1f)) {169            AdditionalUrlInlineField(170                draft = draft,171                onDraftChange = { draft = it },172                modifier = Modifier.fillMaxWidth(),173            )174            Spacer(Modifier.height(8.dp))175            AdditionalUrlSaveCancelRow(176                onSave = {177                    onAdd(draft)178                    draft = ""179                    editing = false180                },181                onCancel = {182                    draft = ""183                    editing = false184                },185            )186        }187    }188}189190/** Per-row clipboard latch key; keeps each URL's copy feedback independent. */191fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index"192193/**194 * Launches ACTION_VIEW for the URL, prefixing a scheme when the stored value is195 * bare (so "example.com" still resolves). Falls back to a toast if no browser196 * handles the intent.197 */198fun openUrlInBrowser(context: Context, raw: String) {199    val normalized = if (raw.matches(Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://.*"))) raw else "https://$raw"200    try {201        context.startActivity(202            Intent(Intent.ACTION_VIEW, Uri.parse(normalized)).apply {203                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)204            },205        )206    } catch (_: ActivityNotFoundException) {207        Toast.makeText(context, "No app can open this link", Toast.LENGTH_SHORT).show()208    }209}210211/** Mirrors EntryDetailScreen's private FieldShell: mono label over a value/action row with a t3 hairline. */212@Composable213private fun AdditionalUrlFieldShell(214    label: String,215    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,216) {217    val hairline = peachColors.T3218    Column(219        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {220            drawRect(221                color = hairline,222                topLeft = Offset(0f, size.height - 1.dp.toPx()),223                size = Size(size.width, 1.dp.toPx()),224            )225        },226    ) {227        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))228        Spacer(Modifier.height(6.dp))229        Row(verticalAlignment = Alignment.CenterVertically, content = content)230    }231}232233@Composable234private fun OpenInBrowserButton(onClick: () -> Unit) {235    Box(236        Modifier237            .size(36.dp)238            .clip(RoundedCornerShape(6.dp))239            .background(peachColors.S2)240            .clickable(onClick = onClick),241        contentAlignment = Alignment.Center,242    ) {243        Icon(244            Icons.AutoMirrored.Rounded.OpenInNew,245            contentDescription = "open in browser",246            tint = peachColors.T2,247            modifier = Modifier.size(15.dp),248        )249    }250}251252/** Mirrors EntryDetailScreen's private CopyButton (sheen sweep + drain bar, spec §4.4). */253@Composable254private fun AdditionalUrlCopyButton(copied: Boolean, onCopy: () -> Unit) {255    val accent = LocalPeachAccent.current256    val reduceMotion = LocalReduceMotion.current257    val animate = copied && !reduceMotion258    val drain by animateFloatAsState(259        targetValue = if (copied) 1f else 0f,260        animationSpec = if (animate) tween(1400, easing = LinearEasing) else tween(0),261        label = "url-copy-drain",262    )263    val sheen by animateFloatAsState(264        targetValue = if (copied) 1f else 0f,265        animationSpec = if (animate) tween(600, easing = LinearEasing) else tween(0),266        label = "url-copy-sheen",267    )268    Box(269        Modifier270            .size(36.dp)271            .clip(RoundedCornerShape(6.dp))272            .background(peachColors.S2)273            .clickable(onClick = onCopy)274            .drawBehind {275                if (animate && sheen < 1f) {276                    val sweepX = (sheen * 2f - 0.5f) * size.width277                    val band = size.width * 0.5f278                    drawRect(279                        brush = Brush.linearGradient(280                            colors = listOf(Color.Transparent, accent.copy(alpha = 0.35f), Color.Transparent),281                            start = Offset(sweepX - band, 0f),282                            end = Offset(sweepX + band, size.height),283                        ),284                    )285                }286                if (drain > 0f) {287                    val h = 2.dp.toPx()288                    drawRect(289                        color = accent,290                        topLeft = Offset(0f, size.height - h),291                        size = Size(size.width * drain, h),292                    )293                }294            },295        contentAlignment = Alignment.Center,296    ) {297        Icon(298            if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,299            contentDescription = "copy",300            tint = if (copied) accent else peachColors.T1,301            modifier = Modifier.size(15.dp),302        )303    }304}