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.URI5556575859606162636465@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}191192193fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index"194195196197198199fun 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}215216217internal 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+.-]*:")243244245@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}284285286@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}