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    showAdd: Boolean = false,72) {73    val context = LocalContext.current74    urls.forEachIndexed { index, url ->75        if (url.isNotBlank()) {76            val key = additionalUrlCopyKey(index)77            var editing by remember(url) { mutableStateOf(false) }78            var draft by remember(url) { mutableStateOf(url) }79            AdditionalUrlFieldShell(label = "URL ${index + 2}") {80                if (editing) {81                    Column(Modifier.weight(1f)) {82                        AdditionalUrlInlineField(83                            draft = draft,84                            onDraftChange = { draft = it },85                            modifier = Modifier.fillMaxWidth(),86                        )87                        Spacer(Modifier.height(8.dp))88                        AdditionalUrlSaveCancelRow(89                            onSave = { onSave(index, draft); editing = false },90                            onCancel = { draft = url; editing = false },91                        )92                    }93                } else {94                    Text(95                        url,96                        color = peachColors.T0,97                        style = PeachType.bodyMono,98                        modifier = Modifier.weight(1f).clickable { draft = url; editing = true },99                    )100                    OpenInBrowserButton(onClick = { openUrlInBrowser(context, url) })101                    Spacer(Modifier.width(4.dp))102                    AdditionalUrlCopyButton(copied = copiedField == key, onCopy = { onCopy(key, url) })103                }104            }105        }106    }107    if (showAdd) AddAdditionalUrlRow(onAdd = onAdd)108}109110@Composable111private fun AdditionalUrlInlineField(112    draft: String,113    onDraftChange: (String) -> Unit,114    modifier: Modifier = Modifier,115) {116    val accent = LocalPeachAccent.current117    BasicTextField(118        value = draft,119        onValueChange = onDraftChange,120        singleLine = true,121        textStyle = PeachType.bodyMono.copy(color = peachColors.T0),122        cursorBrush = SolidColor(accent),123        modifier = modifier124            .padding(bottom = 4.dp)125            .drawBehind {126                drawLine(127                    color = accent,128                    start = Offset(0f, size.height),129                    end = Offset(size.width, size.height),130                    strokeWidth = 1.dp.toPx(),131                )132            },133    )134}135136@Composable137private fun AdditionalUrlSaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) {138    val accent = LocalPeachAccent.current139    Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {140        Text(141            "✓ save", color = accent, style = PeachType.labelMono,142            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp),143        )144        Text(145            "✗", color = peachColors.T2, style = PeachType.labelMono,146            modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp),147        )148    }149}150151@Composable152private fun AddAdditionalUrlRow(onAdd: (String) -> Unit) {153    val accent = LocalPeachAccent.current154    var editing by remember { mutableStateOf(false) }155    var draft by remember { mutableStateOf("") }156    if (!editing) {157        Text(158            "+ add another url",159            color = accent,160            style = PeachType.labelMono,161            modifier = Modifier162                .clip(RoundedCornerShape(6.dp))163                .clickable { editing = true }164                .padding(vertical = 6.dp),165        )166        return167    }168    AdditionalUrlFieldShell(label = "new url") {169        Column(Modifier.weight(1f)) {170            AdditionalUrlInlineField(171                draft = draft,172                onDraftChange = { draft = it },173                modifier = Modifier.fillMaxWidth(),174            )175            Spacer(Modifier.height(8.dp))176            AdditionalUrlSaveCancelRow(177                onSave = {178                    onAdd(draft)179                    draft = ""180                    editing = false181                },182                onCancel = {183                    draft = ""184                    editing = false185                },186            )187        }188    }189}190191/** Per-row clipboard latch key; keeps each URL's copy feedback independent. */192fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index"193194/**195 * Launches ACTION_VIEW for the URL, prefixing a scheme when the stored value is196 * bare (so "example.com" still resolves). Falls back to a toast if no browser197 * handles the intent.198 */199fun openUrlInBrowser(context: Context, raw: String) {200    val normalized = if (raw.matches(Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://.*"))) raw else "https://$raw"201    try {202        context.startActivity(203            Intent(Intent.ACTION_VIEW, Uri.parse(normalized)).apply {204                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)205            },206        )207    } catch (_: ActivityNotFoundException) {208        Toast.makeText(context, "No app can open this link", Toast.LENGTH_SHORT).show()209    }210}211212/** Mirrors EntryDetailScreen's private FieldShell: mono label over a value/action row with a t3 hairline. */213@Composable214private fun AdditionalUrlFieldShell(215    label: String,216    content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,217) {218    val hairline = peachColors.T3219    Column(220        Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind {221            drawRect(222                color = hairline,223                topLeft = Offset(0f, size.height - 1.dp.toPx()),224                size = Size(size.width, 1.dp.toPx()),225            )226        },227    ) {228        Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em))229        Spacer(Modifier.height(6.dp))230        Row(verticalAlignment = Alignment.CenterVertically, content = content)231    }232}233234@Composable235private fun OpenInBrowserButton(onClick: () -> Unit) {236    Box(237        Modifier238            .size(36.dp)239            .clip(RoundedCornerShape(6.dp))240            .background(peachColors.S2)241            .clickable(onClick = onClick),242        contentAlignment = Alignment.Center,243    ) {244        Icon(245            Icons.AutoMirrored.Rounded.OpenInNew,246            contentDescription = "open in browser",247            tint = peachColors.T2,248            modifier = Modifier.size(15.dp),249        )250    }251}252253/** Mirrors EntryDetailScreen's private CopyButton (sheen sweep + drain bar, spec §4.4). */254@Composable255private fun AdditionalUrlCopyButton(copied: Boolean, onCopy: () -> Unit) {256    val accent = LocalPeachAccent.current257    val reduceMotion = LocalReduceMotion.current258    val animate = copied && !reduceMotion259    val drain by animateFloatAsState(260        targetValue = if (copied) 1f else 0f,261        animationSpec = if (animate) tween(1400, easing = LinearEasing) else tween(0),262        label = "url-copy-drain",263    )264    val sheen by animateFloatAsState(265        targetValue = if (copied) 1f else 0f,266        animationSpec = if (animate) tween(600, easing = LinearEasing) else tween(0),267        label = "url-copy-sheen",268    )269    Box(270        Modifier271            .size(36.dp)272            .clip(RoundedCornerShape(6.dp))273            .background(peachColors.S2)274            .clickable(onClick = onCopy)275            .drawBehind {276                if (animate && sheen < 1f) {277                    val sweepX = (sheen * 2f - 0.5f) * size.width278                    val band = size.width * 0.5f279                    drawRect(280                        brush = Brush.linearGradient(281                            colors = listOf(Color.Transparent, accent.copy(alpha = 0.35f), Color.Transparent),282                            start = Offset(sweepX - band, 0f),283                            end = Offset(sweepX + band, size.height),284                        ),285                    )286                }287                if (drain > 0f) {288                    val h = 2.dp.toPx()289                    drawRect(290                        color = accent,291                        topLeft = Offset(0f, size.height - h),292                        size = Size(size.width * drain, h),293                    )294                }295            },296        contentAlignment = Alignment.Center,297    ) {298        Icon(299            if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,300            contentDescription = "copy",301            tint = if (copied) accent else peachColors.T1,302            modifier = Modifier.size(15.dp),303        )304    }305}