package com.peachpasswords.ui.vault import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.LocalReduceMotion import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachType import java.net.URI /** * E3 โ€” additional-URL rows for EntryDetailScreen. Self-contained: it carries its * own field shell, copy button, and open-in-browser control so it can be inserted * into the detail field stack without touching the screen's private `FieldShell` * / `CopyButton`. Each row shows "URL N", the value, copy, and open-in-browser. * * `copiedKeyFor(index)` is the per-row clipboard latch key the caller passes to * `vm.copy(key, value)`; `copiedField` is the screen's current latch. */ @Composable fun AdditionalUrlRows( urls: List, copiedField: String?, onCopy: (key: String, value: String) -> Unit, onSave: (index: Int, value: String) -> Unit = { _, _ -> }, onAdd: (value: String) -> Unit = {}, showAdd: Boolean = false, ) { val context = LocalContext.current urls.forEachIndexed { index, url -> if (url.isNotBlank()) { val key = additionalUrlCopyKey(index) var editing by remember(url) { mutableStateOf(false) } var draft by remember(url) { mutableStateOf(url) } AdditionalUrlFieldShell(label = "URL ${index + 2}") { if (editing) { Column(Modifier.weight(1f)) { AdditionalUrlInlineField( draft = draft, onDraftChange = { draft = it }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) AdditionalUrlSaveCancelRow( onSave = { onSave(index, draft); editing = false }, onCancel = { draft = url; editing = false }, ) } } else { Text( url, color = peachColors.T0, style = PeachType.bodyMono, modifier = Modifier.weight(1f).clickable { draft = url; editing = true }, ) OpenInBrowserButton(onClick = { openUrlInBrowser(context, url) }) Spacer(Modifier.width(4.dp)) AdditionalUrlCopyButton(copied = copiedField == key, onCopy = { onCopy(key, url) }) } } } } if (showAdd) AddAdditionalUrlRow(onAdd = onAdd) } @Composable private fun AdditionalUrlInlineField( draft: String, onDraftChange: (String) -> Unit, modifier: Modifier = Modifier, ) { val accent = LocalPeachAccent.current BasicTextField( value = draft, onValueChange = onDraftChange, singleLine = true, textStyle = PeachType.bodyMono.copy(color = peachColors.T0), cursorBrush = SolidColor(accent), modifier = modifier .padding(bottom = 4.dp) .drawBehind { drawLine( color = accent, start = Offset(0f, size.height), end = Offset(size.width, size.height), strokeWidth = 1.dp.toPx(), ) }, ) } @Composable private fun AdditionalUrlSaveCancelRow(onSave: () -> Unit, onCancel: () -> Unit) { val accent = LocalPeachAccent.current Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { Text( "โœ“ save", color = accent, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onSave).padding(vertical = 4.dp), ) Text( "โœ—", color = peachColors.T2, style = PeachType.labelMono, modifier = Modifier.clip(RoundedCornerShape(6.dp)).clickable(onClick = onCancel).padding(horizontal = 6.dp, vertical = 4.dp), ) } } @Composable private fun AddAdditionalUrlRow(onAdd: (String) -> Unit) { val accent = LocalPeachAccent.current var editing by remember { mutableStateOf(false) } var draft by remember { mutableStateOf("") } if (!editing) { Text( "+ add another url", color = accent, style = PeachType.labelMono, modifier = Modifier .clip(RoundedCornerShape(6.dp)) .clickable { editing = true } .padding(vertical = 6.dp), ) return } AdditionalUrlFieldShell(label = "new url") { Column(Modifier.weight(1f)) { AdditionalUrlInlineField( draft = draft, onDraftChange = { draft = it }, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) AdditionalUrlSaveCancelRow( onSave = { onAdd(draft) draft = "" editing = false }, onCancel = { draft = "" editing = false }, ) } } } /** Per-row clipboard latch key; keeps each URL's copy feedback independent. */ fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index" /** * Launches ACTION_VIEW only for a canonical HTTPS URL. Stored/imported vault * values are untrusted; non-web schemes never cross into another app. */ fun openUrlInBrowser(context: Context, raw: String) { val normalized = normalizeExternalHttpsUrl(raw) if (normalized == null) { Toast.makeText(context, "Only valid HTTPS links can be opened", Toast.LENGTH_SHORT).show() return } try { context.startActivity( Intent(Intent.ACTION_VIEW, Uri.parse(normalized)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }, ) } catch (_: ActivityNotFoundException) { Toast.makeText(context, "No app can open this link", Toast.LENGTH_SHORT).show() } } /** Returns a canonical HTTPS URL or null when external dispatch is unsafe. */ internal fun normalizeExternalHttpsUrl(raw: String): String? { val value = raw.trim() if (value.isEmpty() || value.any { it <= '\u001F' || it == '\u007F' || it == '\\' }) return null val hasScheme = SCHEME_PREFIX.containsMatchIn(value) val candidate = if (hasScheme) value else "https://$value" val parsed = runCatching { URI(candidate) }.getOrNull() ?: return null if (!parsed.scheme.equals("https", ignoreCase = true) || parsed.isOpaque) return null if (parsed.rawAuthority.isNullOrBlank() || parsed.userInfo != null) return null val host = parsed.host?.takeIf { it.isNotBlank() } ?: return null if (parsed.port !in -1..65535) return null return runCatching { URI( "https", null, host.lowercase(), parsed.port, parsed.path.orEmpty(), parsed.query, parsed.fragment, ).toASCIIString() }.getOrNull() } private val SCHEME_PREFIX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:") /** Mirrors EntryDetailScreen's private FieldShell: mono label over a value/action row with a t3 hairline. */ @Composable private fun AdditionalUrlFieldShell( label: String, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit, ) { val hairline = peachColors.T3 Column( Modifier.fillMaxWidth().padding(vertical = 10.dp).drawBehind { drawRect( color = hairline, topLeft = Offset(0f, size.height - 1.dp.toPx()), size = Size(size.width, 1.dp.toPx()), ) }, ) { Text(label.uppercase(), color = peachColors.T2, style = PeachType.labelMono.copy(letterSpacing = 0.08.em)) Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically, content = content) } } @Composable private fun OpenInBrowserButton(onClick: () -> Unit) { Box( Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .background(peachColors.S2) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( Icons.AutoMirrored.Rounded.OpenInNew, contentDescription = "open in browser", tint = peachColors.T2, modifier = Modifier.size(15.dp), ) } } /** Mirrors EntryDetailScreen's private CopyButton (sheen sweep + drain bar, spec ยง4.4). */ @Composable private fun AdditionalUrlCopyButton(copied: Boolean, onCopy: () -> Unit) { val accent = LocalPeachAccent.current val reduceMotion = LocalReduceMotion.current val animate = copied && !reduceMotion val drain by animateFloatAsState( targetValue = if (copied) 1f else 0f, animationSpec = if (animate) tween(1400, easing = LinearEasing) else tween(0), label = "url-copy-drain", ) val sheen by animateFloatAsState( targetValue = if (copied) 1f else 0f, animationSpec = if (animate) tween(600, easing = LinearEasing) else tween(0), label = "url-copy-sheen", ) Box( Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .background(peachColors.S2) .clickable(onClick = onCopy) .drawBehind { if (animate && sheen < 1f) { val sweepX = (sheen * 2f - 0.5f) * size.width val band = size.width * 0.5f drawRect( brush = Brush.linearGradient( colors = listOf(Color.Transparent, accent.copy(alpha = 0.35f), Color.Transparent), start = Offset(sweepX - band, 0f), end = Offset(sweepX + band, size.height), ), ) } if (drain > 0f) { val h = 2.dp.toPx() drawRect( color = accent, topLeft = Offset(0f, size.height - h), size = Size(size.width * drain, h), ) } }, contentAlignment = Alignment.Center, ) { Icon( if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy, contentDescription = "copy", tint = if (copied) accent else peachColors.T1, modifier = Modifier.size(15.dp), ) } }