修正编辑弹窗无法打开的问题
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
package cn.airnan.a2fair.core
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||||
|
import cn.airnan.a2fair.data.entity.Token
|
||||||
|
import cn.airnan.a2fair.data.entity.TokenType
|
||||||
|
|
||||||
|
object OtpUriBuilder {
|
||||||
|
|
||||||
|
fun build(token: Token): String {
|
||||||
|
val type = when (token.tokenType) {
|
||||||
|
TokenType.TOTP -> "totp"
|
||||||
|
TokenType.HOTP -> "hotp"
|
||||||
|
TokenType.STEAM -> "totp"
|
||||||
|
}
|
||||||
|
|
||||||
|
val label = if (token.issuer.isNotBlank()) {
|
||||||
|
"${Uri.encode(token.issuer)}:${Uri.encode(token.serviceName)}"
|
||||||
|
} else {
|
||||||
|
Uri.encode(token.serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = Uri.Builder()
|
||||||
|
.scheme("otpauth")
|
||||||
|
.authority(type)
|
||||||
|
.path("/$label")
|
||||||
|
.appendQueryParameter("secret", token.secret.value)
|
||||||
|
.appendQueryParameter("digits", token.digits.toString())
|
||||||
|
.appendQueryParameter("period", token.period.toString())
|
||||||
|
|
||||||
|
if (token.issuer.isNotBlank()) {
|
||||||
|
uri.appendQueryParameter("issuer", token.issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (token.algorithm) {
|
||||||
|
HashAlgorithm.SHA1 -> { /* default */ }
|
||||||
|
else -> uri.appendQueryParameter("algorithm", token.algorithm.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.tokenType == TokenType.HOTP) {
|
||||||
|
uri.appendQueryParameter("counter", token.counter.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.build().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import cn.airnan.a2fair.R
|
import cn.airnan.a2fair.R
|
||||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||||
import cn.airnan.a2fair.data.entity.TokenType
|
import cn.airnan.a2fair.data.entity.TokenType
|
||||||
@@ -52,9 +53,10 @@ fun AddTokenBottomSheet(
|
|||||||
viewModel: TokensViewModel,
|
viewModel: TokensViewModel,
|
||||||
onDismissRequest: () -> Unit
|
onDismissRequest: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.addTokenState.collectAsState()
|
val state by viewModel.addTokenState.collectAsStateWithLifecycle()
|
||||||
|
val isEditing = viewModel.isEditing
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showManualEntry by remember { mutableStateOf(false) }
|
var showManualEntry by remember { mutableStateOf(isEditing) }
|
||||||
var showCameraScanner by remember { mutableStateOf(false) }
|
var showCameraScanner by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val photoPickerLauncher = rememberLauncherForActivityResult(
|
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -113,7 +115,11 @@ fun AddTokenBottomSheet(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (showManualEntry) stringResource(R.string.enter_details_manually) else stringResource(R.string.add_token_title),
|
text = when {
|
||||||
|
isEditing -> stringResource(R.string.edit_token)
|
||||||
|
showManualEntry -> stringResource(R.string.enter_details_manually)
|
||||||
|
else -> stringResource(R.string.add_token_title)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
@@ -155,7 +161,12 @@ fun AddTokenBottomSheet(
|
|||||||
ManualEntryForm(
|
ManualEntryForm(
|
||||||
state = state,
|
state = state,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onCancel = { showManualEntry = false }
|
onCancel = if (isEditing) {
|
||||||
|
{ viewModel.resetAddState(); onDismissRequest() }
|
||||||
|
} else {
|
||||||
|
{ showManualEntry = false }
|
||||||
|
},
|
||||||
|
isEditing = isEditing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +188,8 @@ fun AddTokenBottomSheet(
|
|||||||
fun ManualEntryForm(
|
fun ManualEntryForm(
|
||||||
state: AddTokenUiState,
|
state: AddTokenUiState,
|
||||||
viewModel: TokensViewModel,
|
viewModel: TokensViewModel,
|
||||||
onCancel: () -> Unit
|
onCancel: () -> Unit,
|
||||||
|
isEditing: Boolean = false
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.accountName,
|
value = state.accountName,
|
||||||
@@ -339,8 +351,14 @@ fun ManualEntryForm(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
if (!state.isDuplicate) {
|
if (!state.isDuplicate) {
|
||||||
Button(onClick = viewModel::addTokenManually) {
|
if (isEditing) {
|
||||||
Text(stringResource(R.string.add))
|
Button(onClick = viewModel::updateToken) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = viewModel::addTokenManually) {
|
||||||
|
Text(stringResource(R.string.add))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package cn.airnan.a2fair.ui.tokens
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import cn.airnan.a2fair.R
|
||||||
|
import cn.airnan.a2fair.core.OtpUriBuilder
|
||||||
|
import cn.airnan.a2fair.data.entity.Token
|
||||||
|
import cn.airnan.a2fair.data.entity.TokenType
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import java.util.EnumMap
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TokenDetailSheet(
|
||||||
|
token: Token,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showQrCode by remember { mutableStateOf(false) }
|
||||||
|
var secretVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val secretCopiedText = stringResource(R.string.secret_copied)
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Text(
|
||||||
|
text = token.serviceName,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
if (token.issuer.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = token.issuer,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Info row
|
||||||
|
InfoRow(label = stringResource(R.string.type_label), value = token.tokenType.name)
|
||||||
|
InfoRow(label = stringResource(R.string.algorithm_label), value = token.algorithm.name)
|
||||||
|
InfoRow(label = stringResource(R.string.digits_label), value = token.digits.toString())
|
||||||
|
if (token.tokenType == TokenType.HOTP) {
|
||||||
|
InfoRow(label = stringResource(R.string.counter_label), value = token.counter.toString())
|
||||||
|
} else {
|
||||||
|
InfoRow(label = stringResource(R.string.period_label), value = "${token.period}s")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Secret field
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.secret_key),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token.secret.value,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
IconButton(onClick = { secretVisible = !secretVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (secretVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (secretVisible) stringResource(R.string.hide_secret) else stringResource(R.string.show_secret)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("Secret", token.secret.value))
|
||||||
|
Toast.makeText(context, secretCopiedText, Toast.LENGTH_SHORT).show()
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_secret))
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showQrCode = true }) {
|
||||||
|
Icon(Icons.Default.QrCode, contentDescription = stringResource(R.string.show_qr_code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Edit button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onEdit()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.edit))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showDeleteConfirm = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.delete))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation dialog
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirm = false },
|
||||||
|
title = { Text(stringResource(R.string.confirm_delete)) },
|
||||||
|
text = { Text(stringResource(R.string.confirm_delete_message, token.serviceName)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDeleteConfirm = false
|
||||||
|
onDismiss()
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR Code dialog
|
||||||
|
if (showQrCode) {
|
||||||
|
val uriString = remember(token) { OtpUriBuilder.build(token) }
|
||||||
|
val qrBitmap = remember(uriString) { generateQrCode(uriString, 512) }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { showQrCode = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
shape = MaterialTheme.shapes.large
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.qr_code),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
qrBitmap?.let { bmp ->
|
||||||
|
Image(
|
||||||
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = "QR Code",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
TextButton(onClick = { showQrCode = false }) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateQrCode(content: String, size: Int): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val hints = EnumMap<EncodeHintType, Any>(EncodeHintType::class.java).apply {
|
||||||
|
this[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||||
|
this[EncodeHintType.MARGIN] = 1
|
||||||
|
}
|
||||||
|
val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||||
|
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
for (x in 0 until size) {
|
||||||
|
for (y in 0 until size) {
|
||||||
|
bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bitmap
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,6 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
@@ -110,7 +107,11 @@ fun TokensScreen() {
|
|||||||
TokenCard(
|
TokenCard(
|
||||||
token = token,
|
token = token,
|
||||||
onIncrement = { viewModel.incrementCounter(token) },
|
onIncrement = { viewModel.incrementCounter(token) },
|
||||||
onDelete = { viewModel.deleteToken(token) }
|
onDelete = { viewModel.deleteToken(token) },
|
||||||
|
onEdit = {
|
||||||
|
viewModel.startEditToken(token)
|
||||||
|
showAddBottomSheet = true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,14 +160,15 @@ fun GroupHeader(
|
|||||||
fun TokenCard(
|
fun TokenCard(
|
||||||
token: Token,
|
token: Token,
|
||||||
onIncrement: () -> Unit,
|
onIncrement: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit,
|
||||||
|
onEdit: () -> Unit
|
||||||
) {
|
) {
|
||||||
val otpPlaceholder = stringResource(R.string.otp_placeholder)
|
val otpPlaceholder = stringResource(R.string.otp_placeholder)
|
||||||
val errorText = stringResource(R.string.error)
|
val errorText = stringResource(R.string.error)
|
||||||
var currentCode by remember { mutableStateOf(otpPlaceholder) }
|
var currentCode by remember { mutableStateOf(otpPlaceholder) }
|
||||||
var progress by remember { mutableStateOf(1f) }
|
var progress by remember { mutableStateOf(1f) }
|
||||||
var remainingSeconds by remember { mutableStateOf(0) }
|
var remainingSeconds by remember { mutableStateOf(0) }
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showDetailSheet by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val secretBytes = remember(token.secret.value) {
|
val secretBytes = remember(token.secret.value) {
|
||||||
@@ -232,7 +234,7 @@ fun TokenCard(
|
|||||||
onClick = {
|
onClick = {
|
||||||
copyToClipboard(context, currentCode, token.serviceName)
|
copyToClipboard(context, currentCode, token.serviceName)
|
||||||
},
|
},
|
||||||
onLongClick = { showMenu = true }
|
onLongClick = { showDetailSheet = true }
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
) {
|
) {
|
||||||
@@ -298,32 +300,18 @@ fun TokenCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
if (showDetailSheet) {
|
||||||
expanded = showMenu,
|
TokenDetailSheet(
|
||||||
onDismissRequest = { showMenu = false }
|
token = token,
|
||||||
) {
|
onDismiss = { showDetailSheet = false },
|
||||||
DropdownMenuItem(
|
onEdit = {
|
||||||
text = { Text(stringResource(R.string.copy_code)) },
|
showDetailSheet = false
|
||||||
onClick = {
|
onEdit()
|
||||||
showMenu = false
|
|
||||||
copyToClipboard(context, currentCode, token.serviceName)
|
|
||||||
},
|
},
|
||||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) }
|
onDelete = {
|
||||||
)
|
showDetailSheet = false
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.edit)) },
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
},
|
|
||||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.delete)) },
|
|
||||||
onClick = {
|
|
||||||
showMenu = false
|
|
||||||
onDelete()
|
onDelete()
|
||||||
},
|
}
|
||||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ class TokensViewModel(
|
|||||||
private val _addTokenState = MutableStateFlow(AddTokenUiState())
|
private val _addTokenState = MutableStateFlow(AddTokenUiState())
|
||||||
val addTokenState: StateFlow<AddTokenUiState> = _addTokenState.asStateFlow()
|
val addTokenState: StateFlow<AddTokenUiState> = _addTokenState.asStateFlow()
|
||||||
private var pendingToken: Token? = null
|
private var pendingToken: Token? = null
|
||||||
|
private var editingTokenId: Long? = null
|
||||||
|
val isEditing: Boolean get() = editingTokenId != null
|
||||||
|
|
||||||
fun updateIssuer(issuer: String) {
|
fun updateIssuer(issuer: String) {
|
||||||
_addTokenState.update { it.copy(issuer = issuer) }
|
_addTokenState.update { it.copy(issuer = issuer) }
|
||||||
@@ -157,6 +159,53 @@ class TokensViewModel(
|
|||||||
|
|
||||||
fun resetAddState() {
|
fun resetAddState() {
|
||||||
_addTokenState.value = AddTokenUiState()
|
_addTokenState.value = AddTokenUiState()
|
||||||
|
editingTokenId = null
|
||||||
|
pendingToken = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startEditToken(token: Token) {
|
||||||
|
editingTokenId = token.id
|
||||||
|
_addTokenState.value = AddTokenUiState(
|
||||||
|
issuer = token.issuer,
|
||||||
|
accountName = token.serviceName,
|
||||||
|
secret = token.secret.value,
|
||||||
|
tokenType = token.tokenType,
|
||||||
|
algorithm = token.algorithm,
|
||||||
|
digits = token.digits,
|
||||||
|
period = token.period,
|
||||||
|
counter = token.counter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToken() {
|
||||||
|
val state = _addTokenState.value
|
||||||
|
val id = editingTokenId ?: return
|
||||||
|
if (state.secret.isBlank() || state.accountName.isBlank()) {
|
||||||
|
_addTokenState.update { it.copy(isError = true, errorMessage = getApplication<Application>().getString(R.string.secret_and_account_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val token = tokenRepository.getTokenById(id)
|
||||||
|
if (token != null) {
|
||||||
|
val updated = token.copy(
|
||||||
|
issuer = state.issuer,
|
||||||
|
serviceName = state.accountName,
|
||||||
|
secret = SecretString(state.secret),
|
||||||
|
tokenType = state.tokenType,
|
||||||
|
algorithm = state.algorithm,
|
||||||
|
digits = state.digits,
|
||||||
|
period = state.period,
|
||||||
|
counter = state.counter
|
||||||
|
)
|
||||||
|
tokenRepository.updateToken(updated)
|
||||||
|
}
|
||||||
|
resetAddState()
|
||||||
|
_addTokenState.update { it.copy(isSuccess = true) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_addTokenState.update { it.copy(isError = true, errorMessage = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTokenFromUri(uriString: String) {
|
fun addTokenFromUri(uriString: String) {
|
||||||
|
|||||||
@@ -36,6 +36,17 @@
|
|||||||
<string name="period_label">周期</string>
|
<string name="period_label">周期</string>
|
||||||
<string name="cancel">取消</string>
|
<string name="cancel">取消</string>
|
||||||
<string name="add">添加</string>
|
<string name="add">添加</string>
|
||||||
|
<string name="save">保存</string>
|
||||||
|
<string name="edit_token">编辑口令</string>
|
||||||
|
<string name="secret_key">密钥</string>
|
||||||
|
<string name="show_secret">显示密钥</string>
|
||||||
|
<string name="hide_secret">隐藏密钥</string>
|
||||||
|
<string name="copy_secret">复制密钥</string>
|
||||||
|
<string name="secret_copied">密钥已复制</string>
|
||||||
|
<string name="show_qr_code">显示二维码</string>
|
||||||
|
<string name="qr_code">二维码</string>
|
||||||
|
<string name="confirm_delete">确认删除</string>
|
||||||
|
<string name="confirm_delete_message">永久删除 \"%s\"?此操作不可撤销。</string>
|
||||||
<string name="close">关闭</string>
|
<string name="close">关闭</string>
|
||||||
<string name="unknown_error">未知错误</string>
|
<string name="unknown_error">未知错误</string>
|
||||||
<string name="failed_parse_qr">解析二维码失败:%s</string>
|
<string name="failed_parse_qr">解析二维码失败:%s</string>
|
||||||
|
|||||||
@@ -36,6 +36,17 @@
|
|||||||
<string name="period_label">週期</string>
|
<string name="period_label">週期</string>
|
||||||
<string name="cancel">取消</string>
|
<string name="cancel">取消</string>
|
||||||
<string name="add">新增</string>
|
<string name="add">新增</string>
|
||||||
|
<string name="save">儲存</string>
|
||||||
|
<string name="edit_token">編輯權杖</string>
|
||||||
|
<string name="secret_key">金鑰</string>
|
||||||
|
<string name="show_secret">顯示金鑰</string>
|
||||||
|
<string name="hide_secret">隱藏金鑰</string>
|
||||||
|
<string name="copy_secret">複製金鑰</string>
|
||||||
|
<string name="secret_copied">金鑰已複製</string>
|
||||||
|
<string name="show_qr_code">顯示 QR 碼</string>
|
||||||
|
<string name="qr_code">QR 碼</string>
|
||||||
|
<string name="confirm_delete">確認刪除</string>
|
||||||
|
<string name="confirm_delete_message">永久刪除 \"%s\"?此操作無法復原。</string>
|
||||||
<string name="close">關閉</string>
|
<string name="close">關閉</string>
|
||||||
<string name="unknown_error">未知錯誤</string>
|
<string name="unknown_error">未知錯誤</string>
|
||||||
<string name="failed_parse_qr">解析 QR 碼失敗:%s</string>
|
<string name="failed_parse_qr">解析 QR 碼失敗:%s</string>
|
||||||
|
|||||||
@@ -36,6 +36,17 @@
|
|||||||
<string name="period_label">Period</string>
|
<string name="period_label">Period</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="add">Add</string>
|
<string name="add">Add</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="edit_token">Edit Token</string>
|
||||||
|
<string name="secret_key">Secret Key</string>
|
||||||
|
<string name="show_secret">Show secret</string>
|
||||||
|
<string name="hide_secret">Hide secret</string>
|
||||||
|
<string name="copy_secret">Copy secret</string>
|
||||||
|
<string name="secret_copied">Secret copied</string>
|
||||||
|
<string name="show_qr_code">Show QR code</string>
|
||||||
|
<string name="qr_code">QR Code</string>
|
||||||
|
<string name="confirm_delete">Confirm Delete</string>
|
||||||
|
<string name="confirm_delete_message">Delete \"%s\" permanently? This cannot be undone.</string>
|
||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="unknown_error">Unknown error</string>
|
<string name="unknown_error">Unknown error</string>
|
||||||
<string name="failed_parse_qr">Failed to parse QR code: %s</string>
|
<string name="failed_parse_qr">Failed to parse QR code: %s</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user