重构上传

This commit is contained in:
2026-10-29 14:39:14 +08:00
parent 5739f7f8e7
commit 0cf352cabb
19 changed files with 1398 additions and 190 deletions
@@ -141,7 +141,7 @@ fun CaptureScreen(
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(context, profile)
viewModel.selectProfile(profile)
profileExpanded = false
}
)
@@ -46,9 +46,9 @@ class CaptureViewModel : ViewModel() {
)
)
fun selectProfile(context: Context, profile: CaptureProfile) {
fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile)
SettingsStore.saveSelectedProfileId(context, profile.id)
SettingsStore.selectedProfileId = profile.id
}
fun startCapture(context: Context) {
@@ -18,6 +18,7 @@ import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.util.asState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -28,8 +29,9 @@ fun ProxySettingsScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) }
var useProxy by remember { mutableStateOf(SettingsStore.loadUseProxy(context)) }
var proxy by SettingsStore::upstreamProxy.asState()
var useProxy by SettingsStore::useProxy.asState()
var continueRun by SettingsStore::continueRun.asState()
var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) {
@@ -62,6 +64,33 @@ fun ProxySettingsScreen(
.padding(horizontal = 0.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = continueRun,
onCheckedChange = { continueRun = it }
)
Text(
text = "持续运行",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "抓到包后不自动停止,继续接受同类型的包",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, bottom = 4.dp)
)
}
HorizontalDivider()
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -77,7 +106,6 @@ fun ProxySettingsScreen(
checked = useProxy && canUseProxy,
onCheckedChange = {
useProxy = it
SettingsStore.saveUseProxy(context, it)
},
enabled = canUseProxy
)
@@ -90,13 +118,7 @@ fun ProxySettingsScreen(
OutlinedTextField(
value = proxy,
onValueChange = {
proxy = it
SettingsStore.saveProxy(context, it)
// 地址被清空时不允许再勾着;顺手把偏好也清掉,避免重新填写时自动勾上
if (it.isBlank() && useProxy) {
useProxy = false
SettingsStore.saveUseProxy(context, false)
}
proxy = it.trim()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -106,7 +128,6 @@ fun ProxySettingsScreen(
supportingText = {
Text(
text = when {
proxy.isBlank() -> "填写地址后才能勾选启用;只支持 http / https 代理"
!looksValid -> "格式示例:http://127.0.0.1:7890"
else -> "只支持 http / https 代理"
}
@@ -1,29 +1,65 @@
package net.bluemangoo.indpjsk.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UploadSettingsScreen(onBack: () -> Unit) {
val context = LocalContext.current
var code by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
var useUpload by remember { mutableStateOf(SettingsStore.loadUseUpload(context)) }
var showCode by remember { mutableStateOf(false) }
var entries by remember { mutableStateOf(UploadStore.load()) }
var editing by remember { mutableStateOf<UploadEditTarget?>(null) }
var pendingDelete by remember { mutableStateOf<Int?>(null) }
val canUpload = code.isNotBlank()
val target = editing
if (target != null) {
UploadEditScreen(
initial = target.initial,
takenIds = entries
.filterIndexed { index, _ -> index != target.index }
.map { it.profile.id }
.toSet(),
onCancel = { editing = null },
onSave = { entry ->
val index = target.index
entries = if (index == null) {
entries + entry
} else {
entries.toMutableList().also { it[index] = entry }
}
UploadStore.save(entries)
editing = null
},
)
return
}
Scaffold(
topBar = {
@@ -36,6 +72,14 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
contentDescription = "返回"
)
}
},
actions = {
IconButton(onClick = { editing = UploadEditTarget(null, null) }) {
Icon(
painter = painterResource(R.drawable.add_24px),
contentDescription = "添加"
)
}
}
)
}
@@ -43,53 +87,760 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedTextField(
value = code,
onValueChange = {
code = it.trim()
SettingsStore.saveUploadCode(context, code)
if (code.isBlank() && useUpload) {
useUpload = false
SettingsStore.saveUseUpload(context, false)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Haruki 上传码") },
placeholder = { Text("从 Haruki 工具箱 - 数据上传 - iOS 获取") },
visualTransformation = if (showCode) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
TextButton(onClick = { showCode = !showCode }) {
Text(if (showCode) "隐藏" else "显示")
}
}
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = useUpload && canUpload,
onCheckedChange = {
useUpload = it
SettingsStore.saveUseUpload(context, it)
},
enabled = canUpload
)
if (entries.isEmpty()) {
Text(
text = "上传到 Haruki",
style = MaterialTheme.typography.bodyMedium
text = "还没有上传配置",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
)
} else {
UploadTableHeader()
HorizontalDivider()
entries.forEachIndexed { index, entry ->
UploadTableRow(
entry = entry,
onEnabledChange = { enabled ->
entries = entries.toMutableList().also {
it[index] = entry.copy(enabled = enabled)
}
UploadStore.save(entries)
},
onEdit = { editing = UploadEditTarget(index, entry) },
onDelete = { pendingDelete = index },
)
HorizontalDivider()
}
}
}
}
pendingDelete?.let { index ->
val entry = entries.getOrNull(index)
if (entry != null) {
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("删除上传配置") },
text = { Text("确定删除「${entry.profile.id}」?") },
confirmButton = {
TextButton(onClick = {
entries = entries.filterIndexed { i, _ -> i != index }
UploadStore.save(entries)
pendingDelete = null
}) { Text("删除") }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("取消") }
}
)
}
}
}
private data class UploadEditTarget(
val index: Int?,
val initial: UploadEntry?,
)
@Composable
private fun UploadTableHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.width(48.dp))
Text("ID", style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f))
Text(
"类型",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.width(72.dp)
)
Spacer(Modifier.width(80.dp))
}
}
@Composable
private fun UploadTableRow(
entry: UploadEntry,
onEnabledChange: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = entry.enabled,
onCheckedChange = onEnabledChange,
)
Text(
text = entry.profile.id,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = entry.profile.type.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(72.dp)
)
IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) {
Icon(
painter = painterResource(R.drawable.edit_24px),
contentDescription = "编辑",
modifier = Modifier.size(20.dp)
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(
painter = painterResource(R.drawable.delete_24px),
contentDescription = "删除",
modifier = Modifier.size(20.dp)
)
}
}
}
/**
* 添加 / 编辑共用的界面。
* 只有构造函数需要的字段需要用户填写;额外信息(extraInfo)只在下方展示。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UploadEditScreen(
initial: UploadEntry?,
takenIds: Set<String>,
onCancel: () -> Unit,
onSave: (UploadEntry) -> Unit,
) {
BackHandler(onBack = onCancel)
val context = LocalContext.current
val original = initial?.profile
var id by remember(initial) { mutableStateOf(original?.id ?: "") }
var type by remember(initial) { mutableStateOf(original?.type ?: UploadProfileType.HARUKI) }
var uploadCode by remember(initial) {
mutableStateOf(
when (original) {
is UploadProfileHaruki -> original.uploadCode
is UploadProfileReasona -> original.uploadCode
else -> ""
}
)
}
var urlMode by remember(initial) {
mutableStateOf(
when (val url = (original as? UploadProfileCustom)?.uploadUrl) {
is UploadUrlEach -> UploadStore.URL_MODE_EACH
is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL
null -> UploadStore.URL_MODE_GENERAL
}
)
}
var generalUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlGeneral)?.url ?: "")
}
var suiteUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlEach)?.suite ?: "")
}
var mysekaiUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlEach)?.mysekai ?: "")
}
var birthdayUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlEach)?.birthday ?: "")
}
var headers by remember(initial) {
mutableStateOf(
(original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList()
)
}
var typeExpanded by remember { mutableStateOf(false) }
var urlModeExpanded by remember { mutableStateOf(false) }
var importDialog by remember { mutableStateOf(false) }
var exportDialog by remember { mutableStateOf(false) }
var importText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) }
val trimmedId = id.trim()
val idError = when {
trimmedId.isEmpty() -> "ID 不能为空"
trimmedId.any { it.isWhitespace() } -> "ID 不能包含空白字符"
trimmedId in takenIds -> "ID 已存在"
else -> null
}
val codeError = when (type) {
UploadProfileType.HARUKI, UploadProfileType.REASONA ->
if (uploadCode.isBlank()) "请填写上传码" else null
else -> null
}
val canSave = idError == null && codeError == null
fun buildProfile(): UploadProfile = when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim())
UploadProfileType.REASONA -> UploadProfileReasona(trimmedId, uploadCode.trim())
UploadProfileType._8823 -> UploadProfile8823(trimmedId)
UploadProfileType.CUSTOM -> UploadProfileCustom(
id = trimmedId,
uploadUrl = if (urlMode == UploadStore.URL_MODE_EACH) {
UploadUrlEach(
suite = suiteUrl.trim().takeIf { it.isNotEmpty() },
mysekai = mysekaiUrl.trim().takeIf { it.isNotEmpty() },
birthday = birthdayUrl.trim().takeIf { it.isNotEmpty() },
)
} else {
UploadUrlGeneral(generalUrl.trim())
},
customHeaders = headers
.map { it.first.trim() to it.second.trim() }
.filter { it.first.isNotEmpty() }
.toMap(),
)
}
val preview = runCatching { buildProfile() }.getOrNull()
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (initial == null) "添加上传配置" else "编辑上传配置") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ID 和类型放在同一行
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "ID",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(40.dp)
)
CompactTextField(
value = id,
onValueChange = { id = it },
modifier = Modifier.weight(1f),
placeholder = "唯一 ID",
isError = idError != null,
)
CompactDropdown(
text = type.displayName,
expanded = typeExpanded,
onExpandedChange = { typeExpanded = it },
modifier = Modifier.weight(1f),
) {
UploadProfileType.entries.forEach { item ->
DropdownMenuItem(
text = { Text(item.displayName, style = MaterialTheme.typography.bodySmall) },
onClick = {
type = item
typeExpanded = false
}
)
}
}
}
if (idError != null) {
Text(
text = idError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 48.dp)
)
}
preview?.helpText?.let { HelpText(it) }
when (type) {
UploadProfileType.HARUKI, UploadProfileType.REASONA -> {
FormRow(if (type == UploadProfileType.HARUKI) "上传码" else "JWT") {
CompactTextField(
value = uploadCode,
onValueChange = { uploadCode = it.trim() },
modifier = Modifier.weight(1f),
isError = codeError != null,
)
}
if (codeError != null) {
Text(
text = codeError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 72.dp)
)
}
}
UploadProfileType._8823 -> Unit
UploadProfileType.CUSTOM -> {
FormRow("上传地址") {
CompactDropdown(
text = if (urlMode == UploadStore.URL_MODE_EACH) {
"分类 URL"
} else {
"单一 URL"
},
expanded = urlModeExpanded,
onExpandedChange = { urlModeExpanded = it },
modifier = Modifier.weight(1f),
) {
DropdownMenuItem(
text = { Text("单一 URL", style = MaterialTheme.typography.bodySmall) },
onClick = {
urlMode = UploadStore.URL_MODE_GENERAL
urlModeExpanded = false
}
)
DropdownMenuItem(
text = { Text("分类 URL", style = MaterialTheme.typography.bodySmall) },
onClick = {
urlMode = UploadStore.URL_MODE_EACH
urlModeExpanded = false
}
)
}
}
Column(
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (urlMode == UploadStore.URL_MODE_EACH) {
FormRow("Suite") {
CompactTextField(
value = suiteUrl,
onValueChange = { suiteUrl = it },
modifier = Modifier.weight(1f),
placeholder = "可留空",
)
}
FormRow("烤森") {
CompactTextField(
value = mysekaiUrl,
onValueChange = { mysekaiUrl = it },
modifier = Modifier.weight(1f),
placeholder = "可留空",
)
}
FormRow("生日") {
CompactTextField(
value = birthdayUrl,
onValueChange = { birthdayUrl = it },
modifier = Modifier.weight(1f),
placeholder = "可留空",
)
}
} else {
FormRow("URL") {
CompactTextField(
value = generalUrl,
onValueChange = { generalUrl = it },
modifier = Modifier.weight(1f),
)
}
}
}
HeadersEditor(headers = headers, onChange = { headers = it })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
importText = ""
importError = null
importDialog = true
},
modifier = Modifier
.weight(1f)
.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) { Text("从 JSON 导入", style = MaterialTheme.typography.bodySmall) }
OutlinedButton(
onClick = { exportDialog = true },
modifier = Modifier
.weight(1f)
.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) { Text("导出 JSON", style = MaterialTheme.typography.bodySmall) }
}
}
}
if (preview != null && preview.extraInfo.isNotEmpty()) {
HorizontalDivider()
Text("解析信息", style = MaterialTheme.typography.labelLarge)
preview.extraInfo.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = key,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(text = value, style = MaterialTheme.typography.bodySmall)
}
}
}
Button(
onClick = { onSave(UploadEntry(initial?.enabled ?: true, buildProfile())) },
enabled = canSave,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
.height(44.dp)
) {
Text("保存")
}
}
}
if (importDialog) {
AlertDialog(
onDismissRequest = { importDialog = false },
title = { Text("从 JSON 导入") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = importText,
onValueChange = {
importText = it
importError = null
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp),
placeholder = { Text("{ ... }") },
textStyle = MaterialTheme.typography.bodySmall
)
importError?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(onClick = {
val imported = runCatching {
UploadStore.decodeProfile(JSONObject(importText))
}.getOrNull()
when (imported) {
is UploadProfileCustom -> {
id = imported.id
type = UploadProfileType.CUSTOM
when (val url = imported.uploadUrl) {
is UploadUrlGeneral -> {
urlMode = UploadStore.URL_MODE_GENERAL
generalUrl = url.url
}
is UploadUrlEach -> {
urlMode = UploadStore.URL_MODE_EACH
suiteUrl = url.suite.orEmpty()
mysekaiUrl = url.mysekai.orEmpty()
birthdayUrl = url.birthday.orEmpty()
}
}
headers = imported.customHeaders.map { it.key to it.value }
importDialog = false
}
null -> importError = "JSON 解析失败,或不是上传配置"
else -> importError = "这个 JSON 不是自定义类型的配置"
}
}) { Text("导入") }
},
dismissButton = {
TextButton(onClick = { importDialog = false }) { Text("取消") }
}
)
}
if (exportDialog) {
val json = runCatching {
UploadStore.encodeProfile(buildProfile()).toString(2)
}.getOrDefault("")
AlertDialog(
onDismissRequest = { exportDialog = false },
title = { Text("导出 JSON") },
text = {
OutlinedTextField(
value = json,
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
textStyle = MaterialTheme.typography.bodySmall
)
},
confirmButton = {
TextButton(onClick = {
copyToClipboard(context, json)
exportDialog = false
}) { Text("复制") }
},
dismissButton = {
TextButton(onClick = { exportDialog = false }) { Text("关闭") }
}
)
}
}
@Composable
private fun HeadersEditor(
headers: List<Pair<String, String>>,
onChange: (List<Pair<String, String>>) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Headers", style = MaterialTheme.typography.labelLarge)
if (headers.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"名称",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
"值",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1.4f)
)
Spacer(Modifier.width(36.dp))
}
}
headers.forEachIndexed { index, (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CompactTextField(
value = key,
onValueChange = { newKey ->
onChange(headers.toMutableList().also { it[index] = newKey to value })
},
modifier = Modifier.weight(1f),
)
CompactTextField(
value = value,
onValueChange = { newValue ->
onChange(headers.toMutableList().also { it[index] = key to newValue })
},
modifier = Modifier.weight(1.4f),
)
IconButton(
onClick = { onChange(headers.filterIndexed { i, _ -> i != index }) },
modifier = Modifier.size(36.dp)
) {
Icon(
painter = painterResource(R.drawable.delete_24px),
contentDescription = "删除 Header",
modifier = Modifier.size(18.dp)
)
}
}
}
TextButton(
onClick = { onChange(headers + ("" to "")) },
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Text("添加 Header", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun FormRow(
label: String,
labelWidth: Dp = 64.dp,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(labelWidth)
)
content()
}
}
@Composable
private fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
isError: Boolean = false,
) {
val colors = MaterialTheme.colorScheme
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.height(40.dp),
textStyle = MaterialTheme.typography.bodySmall.copy(color = colors.onSurface),
singleLine = true,
cursorBrush = SolidColor(colors.primary),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxSize()
.border(
width = 1.dp,
color = if (isError) colors.error else colors.outline,
shape = RoundedCornerShape(6.dp)
)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodySmall,
color = colors.onSurfaceVariant
)
}
innerTextField()
}
}
)
}
@Composable
private fun CompactDropdown(
text: String,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
items: @Composable ColumnScope.() -> Unit,
) {
Box(modifier = modifier) {
OutlinedButton(
onClick = { onExpandedChange(true) },
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp),
shape = RoundedCornerShape(6.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(R.drawable.arrow_drop_down_24px),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) },
) {
items()
}
}
}
private val helpLinkRegex = Regex("""\[([^\]]+)]\(([^)]+)\)""")
@Composable
private fun HelpText(text: String) {
val linkColor = MaterialTheme.colorScheme.primary
val annotated = remember(text, linkColor) {
buildAnnotatedString {
var last = 0
helpLinkRegex.findAll(text).forEach { match ->
append(text.substring(last, match.range.first))
withLink(
LinkAnnotation.Url(
url = match.groupValues[2],
styles = TextLinkStyles(
style = SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline
)
)
)
) {
append(match.groupValues[1])
}
last = match.range.last + 1
}
append(text.substring(last))
}
}
Text(
text = annotated,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 48.dp)
)
}
private fun copyToClipboard(context: Context, text: String) {
val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(ClipData.newPlainText("upload profile", text))
Toast.makeText(context, "已复制", Toast.LENGTH_SHORT).show()
}