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.PeachType5455565758596061626364@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}190191192fun additionalUrlCopyKey(index: Int): String = "additionalUrl:$index"193194195196197198199fun 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}211212213@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}252253254@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}