AdditionalUrlsDetail.kt

12.6 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.PeachType54import java.net.URI5556/**57 * E3 — additional-URL rows for EntryDetailScreen. Self-contained: it carries its58 * own field shell, copy button, and open-in-browser control so it can be inserted59 * into the detail field stack without touching the screen's private `FieldShell`60 * / `CopyButton`. Each row shows "URL N", the value, copy, and open-in-browser.61 *62 * `copiedKeyFor(index)` is the per-row clipboard latch key the caller passes to63 * `vm.copy(key, value)`; `copiedField` is the screen's current latch.64 */65@Composable66fun AdditionalUrlRows(67    urls: List<String>,68    copiedField: String?,69    onCopy: (key: String, value: String) -> Unit,70    onSave: (index: Int, value: String) -> Unit = { _, _ -> },71    onAdd: (value: String) -> Unit = {},72    showAdd: Boolean = false,73) {74    val context = LocalContext.current75    urls.forEachIndexed { index, url ->76        if (url.isNotBlank()) {77            val key = additionalUrlCopyKey(index)78            var editing by remember(url) { mutableStateOf(false) }79            var draft by remember(url) { mutableStateOf(url) }80            AdditionalUrlFieldShell(label = "URL ${index + 2}") {81                if (editing) {82                    Column(Modifier.weight(1f)) {83                        AdditionalUrlInlineField(84                            draft = draft,85                            onDraftChange = { draft = it },86                            modifier = Modifier.fillMaxWidth(),87                        )88                        Spacer(Modifier.height(8.dp))89                        AdditionalUrlSaveCancelRow(90                            onSave = { onSave(index, draft); editing = false },91                            onCancel = { draft = url; editing = false },92                        )93                    }94                } else {95                    Text(96                        url,97                        color = peachColors.T0,98                        style = PeachType.bodyMono,99                        modifier = Modifier.weight(1f).clickable { draft = url; editing = true },100                    )101                    OpenInBrowserButton(onClick = { openUrlInBrowser(context, url) })102                    Spacer(Modifier.width(4.dp))103                    AdditionalUrlCopyButton(copied = copiedField == key, onCopy = { onCopy(key, url) })104                }105            }106        }107    }108    if (showAdd) AddAdditionalUrlRow(onAdd = onAdd)109}110111@Composable112private fun AdditionalUrlInlineField(113    draft: String,114    onDraftChange: (String) -> Unit,115    modifier: Modifier = Modifier,116) {117    val accent = LocalPeachAccent.current118    BasicTextField(119        value = draft,120        onValueChange = onDraftChange,121        singleLine = true,122        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),123        cursorBrush = SolidColor(accent),124        modifier = modifier125            .padding(bottom = 4.dp)126            .drawBehind {127                drawLine(128                    color = accent,129                    start = Offset(0f, size.height),130                    end = Offset(size.width, size.height),131                    strokeWidth = 1.dp.toPx(),132                )133            },134    )135}136137@Composable138private fun AdditionalUrlSaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {139    val accent = LocalPeachAccent.current140    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {141        Text(142            "✓ save", color = accent, style = PeachType.labelMono,143            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),144        )145        Text(146            "✗", color = peachColors.T2, style = PeachType.labelMono,147            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),148        )149    }150}151152@Composable153private fun AddAdditionalUrlRow(onAdd: (String) -> Unit) {154    val accent = LocalPeachAccent.current155    var editing by remember { mutableStateOf(false) }156    var draft by remember { mutableStateOf("") }157    if (!editing) {158        Text(159            "+ add another url",160            color = accent,161            style = PeachType.labelMono,162            modifier = Modifier163                .clip(RoundedCornerShape(6.dp))164                .clickable { editing = true }165                .padding(vertical = 6.dp),166        )167        return168    }169    AdditionalUrlFieldShell(label = "new url") {170        Column(Modifier.weight(1f)) {171            AdditionalUrlInlineField(172                draft = draft,173                onDraftChange = { draft = it },174                modifier = Modifier.fillMaxWidth(),175            )176            Spacer(Modifier.height(8.dp))177            AdditionalUrlSaveCancelRow(178                onSave = {179                    onAdd(draft)180                    draft = ""181                    editing = false182                },183                onCancel = {184                    draft = ""185                    editing = false186                },187            )188        }189    }190}191192/** Per-row clipboard latch key; keeps each URL's copy feedback independent. */193fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index"194195/**196 * Launches ACTION_VIEW only for a canonical HTTPS URL. Stored/imported vault197 * values are untrusted; non-web schemes never cross into another app.198 */199fun openUrlInBrowser(context: Context, raw: String) {200    val normalized = normalizeExternalHttpsUrl(raw)201    if (normalized == null) {202        Toast.makeText(context, "Only valid HTTPS links can be opened", Toast.LENGTH_SHORT).show()203        return204    }205    try {206        context.startActivity(207            Intent(Intent.ACTION_VIEW, Uri.parse(normalized)).apply {208                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)209            },210        )211    } catch (_: ActivityNotFoundException) {212        Toast.makeText(context, "No app can open this link", Toast.LENGTH_SHORT).show()213    }214}215216/** Returns a canonical HTTPS URL or null when external dispatch is unsafe. */217internal fun normalizeExternalHttpsUrl(raw: String): String? {218    val value = raw.trim()219    if (value.isEmpty() || value.any { it <= '\u001F' || it == '\u007F' || it == '\\' }) return null220221    val hasScheme = SCHEME_PREFIX.containsMatchIn(value)222    val candidate = if (hasScheme) value else "https://$value"223    val parsed = runCatching { URI(candidate) }.getOrNull() ?: return null224    if (!parsed.scheme.equals("https", ignoreCase = true) || parsed.isOpaque) return null225    if (parsed.rawAuthority.isNullOrBlank() || parsed.userInfo != null) return null226    val host = parsed.host?.takeIf { it.isNotBlank() } ?: return null227    if (parsed.port !in -1..65535) return null228229    return runCatching {230        URI(231            "https",232            null,233            host.lowercase(),234            parsed.port,235            parsed.path.orEmpty(),236            parsed.query,237            parsed.fragment,238        ).toASCIIString()239    }.getOrNull()240}241242private val SCHEME_PREFIX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:")243244/** Mirrors EntryDetailScreen's private FieldShell: mono label over a value/action row with a t3 hairline. */245@Composable246private fun AdditionalUrlFieldShell(247    label: String,248    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,249) {250    val hairline = peachColors.T3251    Column(252        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {253            drawRect(254                color = hairline,255                topLeft = Offset(0f, size.height - 1.dp.toPx()),256                size = Size(size.width, 1.dp.toPx()),257            )258        },259    ) {260        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))261        Spacer(Modifier.height(6.dp))262        Row(verticalAlignment = Alignment.CenterVertically, content = content)263    }264}265266@Composable267private fun OpenInBrowserButton(onClick: () -> Unit) {268    Box(269        Modifier270            .size(36.dp)271            .clip(RoundedCornerShape(6.dp))272            .background(peachColors.S2)273            .clickable(onClick = onClick),274        contentAlignment = Alignment.Center,275    ) {276        Icon(277            Icons.AutoMirrored.Rounded.OpenInNew,278            contentDescription = "open in browser",279            tint = peachColors.T2,280            modifier = Modifier.size(15.dp),281        )282    }283}284285/** Mirrors EntryDetailScreen's private CopyButton (sheen sweep + drain bar, spec §4.4). */286@Composable287private fun AdditionalUrlCopyButton(copied: Boolean, onCopy: () -> Unit) {288    val accent = LocalPeachAccent.current289    val reduceMotion = LocalReduceMotion.current290    val animate = copied && !reduceMotion291    val drain by animateFloatAsState(292        targetValue = if (copied) 1f else 0f,293        animationSpec = if (animate) tween(1400, easing = LinearEasing) else tween(0),294        label = "url-copy-drain",295    )296    val sheen by animateFloatAsState(297        targetValue = if (copied) 1f else 0f,298        animationSpec = if (animate) tween(600, easing = LinearEasing) else tween(0),299        label = "url-copy-sheen",300    )301    Box(302        Modifier303            .size(36.dp)304            .clip(RoundedCornerShape(6.dp))305            .background(peachColors.S2)306            .clickable(onClick = onCopy)307            .drawBehind {308                if (animate && sheen < 1f) {309                    val sweepX = (sheen * 2f - 0.5f) * size.width310                    val band = size.width * 0.5f311                    drawRect(312                        brush = Brush.linearGradient(313                            colors = listOf(Color.Transparent, accent.copy(alpha = 0.35f), Color.Transparent),314                            start = Offset(sweepX - band, 0f),315                            end = Offset(sweepX + band, size.height),316                        ),317                    )318                }319                if (drain > 0f) {320                    val h = 2.dp.toPx()321                    drawRect(322                        color = accent,323                        topLeft = Offset(0f, size.height - h),324                        size = Size(size.width * drain, h),325                    )326                }327            },328        contentAlignment = Alignment.Center,329    ) {330        Icon(331            if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,332            contentDescription = "copy",333            tint = if (copied) accent else peachColors.T1,334            modifier = Modifier.size(15.dp),335        )336    }337}