重构交互界面
This commit is contained in:
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
@@ -33,6 +34,9 @@ fun AppNavigation(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val tokensViewModel: cn.airnan.a2fair.ui.tokens.TokensViewModel = viewModel(
|
||||
factory = cn.airnan.a2fair.ui.tokens.TokensViewModel.Factory(app)
|
||||
)
|
||||
val animDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime)
|
||||
|
||||
val currentTopLevelDestination = appState.currentTopLevelDestination
|
||||
@@ -66,9 +70,39 @@ fun AppNavigation(
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier.padding(bottom = padding.calculateBottomPadding())
|
||||
) {
|
||||
TokensScreen()
|
||||
TokensScreen(
|
||||
viewModel = tokensViewModel,
|
||||
onNavigateToTokenEdit = { tokenId ->
|
||||
if (tokenId == null) {
|
||||
appState.navController.navigate("token_edit")
|
||||
} else {
|
||||
appState.navController.navigate("token_edit?tokenId=$tokenId")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = "token_edit?tokenId={tokenId}",
|
||||
arguments = listOf(
|
||||
androidx.navigation.navArgument("tokenId") {
|
||||
type = androidx.navigation.NavType.StringType
|
||||
nullable = true
|
||||
}
|
||||
),
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration)) },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration)) },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration)) }
|
||||
) { backStackEntry ->
|
||||
val tokenIdStr = backStackEntry.arguments?.getString("tokenId")
|
||||
val tokenId = tokenIdStr?.toLongOrNull()
|
||||
cn.airnan.a2fair.ui.tokens.TokenEditScreen(
|
||||
tokenId = tokenId,
|
||||
viewModel = tokensViewModel,
|
||||
onNavigateBack = { appState.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = TopLevelDestination.SETTINGS.route,
|
||||
enterTransition = { EnterTransition.None },
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -23,14 +24,13 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.R
|
||||
@@ -39,11 +39,6 @@ import cn.airnan.a2fair.data.entity.TokenType
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -51,13 +46,14 @@ import java.util.concurrent.Executors
|
||||
@Composable
|
||||
fun AddTokenBottomSheet(
|
||||
viewModel: TokensViewModel,
|
||||
onDismissRequest: () -> Unit
|
||||
onDismissRequest: () -> Unit,
|
||||
onNavigateToManualEntry: () -> Unit
|
||||
) {
|
||||
val state by viewModel.addTokenState.collectAsStateWithLifecycle()
|
||||
val isEditing = viewModel.isEditing
|
||||
val context = LocalContext.current
|
||||
var showManualEntry by remember { mutableStateOf(isEditing) }
|
||||
var showCameraScanner by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var scanned by remember { mutableStateOf(false) }
|
||||
var showScanningText by remember { mutableStateOf(true) }
|
||||
|
||||
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
@@ -88,10 +84,9 @@ fun AddTokenBottomSheet(
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
showCameraScanner = true
|
||||
}
|
||||
) { _ ->
|
||||
// Permission result - camera will auto-start when preview composable is shown
|
||||
// if permission was granted, we just re-compose so PreviewView binds
|
||||
}
|
||||
|
||||
LaunchedEffect(state.isSuccess) {
|
||||
@@ -101,11 +96,18 @@ fun AddTokenBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.isDuplicate) {
|
||||
if (state.isDuplicate) {
|
||||
onNavigateToManualEntry()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
viewModel.resetAddState()
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -115,11 +117,7 @@ fun AddTokenBottomSheet(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
isEditing -> stringResource(R.string.edit_token)
|
||||
showManualEntry -> stringResource(R.string.enter_details_manually)
|
||||
else -> stringResource(R.string.add_token_title)
|
||||
},
|
||||
text = stringResource(R.string.add_token_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
@@ -132,274 +130,47 @@ fun AddTokenBottomSheet(
|
||||
)
|
||||
}
|
||||
|
||||
if (!showManualEntry) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.scan_qr_code)) },
|
||||
leadingContent = { Icon(Icons.Default.QrCodeScanner, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
showCameraScanner = true
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.import_from_image)) },
|
||||
leadingContent = { Icon(Icons.Default.Image, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
photoPickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.enter_manually)) },
|
||||
leadingContent = { Icon(Icons.Default.Keyboard, contentDescription = null) },
|
||||
modifier = Modifier.clickable { showManualEntry = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
} else {
|
||||
ManualEntryForm(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onCancel = if (isEditing) {
|
||||
{ viewModel.resetAddState(); onDismissRequest() }
|
||||
} else {
|
||||
{ showManualEntry = false }
|
||||
},
|
||||
isEditing = isEditing
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCameraScanner) {
|
||||
CameraScannerDialog(
|
||||
onScan = { uri ->
|
||||
showCameraScanner = false
|
||||
viewModel.addTokenFromUri(uri)
|
||||
},
|
||||
onDismissRequest = { showCameraScanner = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ManualEntryForm(
|
||||
state: AddTokenUiState,
|
||||
viewModel: TokensViewModel,
|
||||
onCancel: () -> Unit,
|
||||
isEditing: Boolean = false
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.accountName,
|
||||
onValueChange = viewModel::updateAccountName,
|
||||
label = { Text(stringResource(R.string.account_name_required)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.secret,
|
||||
onValueChange = viewModel::updateSecret,
|
||||
label = { Text(stringResource(R.string.secret_key_required)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.issuer,
|
||||
onValueChange = viewModel::updateIssuer,
|
||||
label = { Text(stringResource(R.string.issuer_optional)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Type selection
|
||||
var expandedType by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedType,
|
||||
onExpandedChange = { expandedType = !expandedType },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.tokenType.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedType) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedType,
|
||||
onDismissRequest = { expandedType = false }
|
||||
) {
|
||||
TokenType.entries.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name) },
|
||||
onClick = {
|
||||
viewModel.updateTokenType(type)
|
||||
expandedType = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced options could be hidden behind a toggle, but for simplicity we show them
|
||||
var expandedAlgorithm by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedAlgorithm,
|
||||
onExpandedChange = { expandedAlgorithm = !expandedAlgorithm },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.algorithm.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.algorithm_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedAlgorithm) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedAlgorithm,
|
||||
onDismissRequest = { expandedAlgorithm = false }
|
||||
) {
|
||||
HashAlgorithm.entries.forEach { alg ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(alg.name) },
|
||||
onClick = {
|
||||
viewModel.updateAlgorithm(alg)
|
||||
expandedAlgorithm = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.digits.toString(),
|
||||
onValueChange = { it.toIntOrNull()?.let { d -> viewModel.updateDigits(d) } },
|
||||
label = { Text(stringResource(R.string.digits_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
|
||||
if (state.tokenType == TokenType.HOTP) {
|
||||
OutlinedTextField(
|
||||
value = state.counter.toString(),
|
||||
onValueChange = { it.toLongOrNull()?.let { c -> viewModel.updateCounter(c) } },
|
||||
label = { Text(stringResource(R.string.counter_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.period.toString(),
|
||||
onValueChange = { it.toIntOrNull()?.let { p -> viewModel.updatePeriod(p) } },
|
||||
label = { Text(stringResource(R.string.period_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isDuplicate) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.duplicate_token_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.duplicate_token_message, state.duplicateServiceName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = { viewModel.dismissDuplicate() }) {
|
||||
Text(stringResource(R.string.skip))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.forceAddToken() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text(stringResource(R.string.force_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (!state.isDuplicate) {
|
||||
if (isEditing) {
|
||||
Button(onClick = viewModel::updateToken) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = viewModel::addTokenManually) {
|
||||
Text(stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraScannerDialog(
|
||||
onScan: (String) -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CameraPreview(onScan = onScan)
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(16.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close), tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraPreview(onScan: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Camera preview
|
||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 200.dp, max = 300.dp)
|
||||
.aspectRatio(4f / 3f)
|
||||
.padding(bottom = 12.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp))
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).also {
|
||||
it.implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
previewView = it
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
if (showScanningText) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(8.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scanning_hint),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(previewView) {
|
||||
val pv = previewView ?: return@LaunchedEffect
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
@@ -412,7 +183,14 @@ fun CameraPreview(onScan: (String) -> Unit) {
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(Executors.newSingleThreadExecutor(), ZxingAnalyzer(onScan))
|
||||
it.setAnalyzer(
|
||||
Executors.newSingleThreadExecutor(),
|
||||
ZxingAnalyzer { uri ->
|
||||
scanned = true
|
||||
showScanningText = false
|
||||
viewModel.addTokenFromUri(uri)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
@@ -426,45 +204,88 @@ fun CameraPreview(onScan: (String) -> Unit) {
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
photoPickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.import_from_image),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = { onNavigateToManualEntry() },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Keyboard,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.enter_manually),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ZxingAnalyzer(private val onScan: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader()
|
||||
private val scanner = BarcodeScanning.getClient()
|
||||
@Volatile
|
||||
private var scanned = false
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
if (scanned) {
|
||||
image.close()
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
val buffer = image.planes[0].buffer
|
||||
val data = ByteArray(buffer.capacity())
|
||||
buffer.get(data)
|
||||
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data,
|
||||
image.width,
|
||||
image.height,
|
||||
0,
|
||||
0,
|
||||
image.width,
|
||||
image.height,
|
||||
false
|
||||
)
|
||||
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
|
||||
try {
|
||||
val result = reader.decode(binaryBitmap)
|
||||
val text = result.text
|
||||
if (text.startsWith("otpauth://")) {
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage != null) {
|
||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val barcode = barcodes.firstOrNull { it.valueType == Barcode.TYPE_WIFI || it.rawValue?.startsWith("otpauth://") == true }
|
||||
?: barcodes.firstOrNull()
|
||||
val rawValue = barcode?.rawValue
|
||||
if (rawValue != null && rawValue.startsWith("otpauth://")) {
|
||||
scanned = true
|
||||
onScan(text)
|
||||
onScan(rawValue)
|
||||
}
|
||||
} catch (e: NotFoundException) {
|
||||
// No barcode found
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
.addOnFailureListener {
|
||||
// Ignored
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
} else {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,26 @@ 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.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
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.graphics.Color
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.core.OtpUriBuilder
|
||||
import cn.airnan.a2fair.core.NativeLib
|
||||
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
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -41,14 +30,65 @@ fun TokenDetailSheet(
|
||||
token: Token,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> 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 warningColor = Color(0xFFFF1744)
|
||||
val otpPlaceholder = stringResource(R.string.otp_placeholder)
|
||||
val errorText = stringResource(R.string.error)
|
||||
var currentCode by remember { mutableStateOf(otpPlaceholder) }
|
||||
var nextCode by remember { mutableStateOf("") }
|
||||
var progress by remember { mutableStateOf(1f) }
|
||||
var remainingSeconds by remember { mutableStateOf(0) }
|
||||
|
||||
val secretCopiedText = stringResource(R.string.secret_copied)
|
||||
val secretBytes = remember(token.secret.value) {
|
||||
try {
|
||||
NativeLib.base32Decode(token.secret.value)
|
||||
} catch (e: Exception) {
|
||||
ByteArray(0)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(token, secretBytes) {
|
||||
if (secretBytes.isEmpty()) {
|
||||
currentCode = errorText
|
||||
return@LaunchedEffect
|
||||
}
|
||||
while (true) {
|
||||
val now = System.currentTimeMillis()
|
||||
when (token.tokenType) {
|
||||
TokenType.TOTP -> {
|
||||
val periodMs = token.period * 1000L
|
||||
val time = now / 1000L
|
||||
currentCode = NativeLib.generateTOTP(
|
||||
secretBytes, time, token.period, token.digits, token.algorithm.ordinal
|
||||
)
|
||||
nextCode = NativeLib.generateTOTP(
|
||||
secretBytes, time + 1, token.period, token.digits, token.algorithm.ordinal
|
||||
)
|
||||
val remaining = periodMs - (now % periodMs)
|
||||
progress = remaining.toFloat() / periodMs.toFloat()
|
||||
remainingSeconds = (remaining / 1000L).toInt()
|
||||
}
|
||||
TokenType.STEAM -> {
|
||||
val time = now / 1000L
|
||||
currentCode = NativeLib.generateSteam(secretBytes, time)
|
||||
nextCode = NativeLib.generateSteam(secretBytes, time + 1)
|
||||
val periodMs = 30000L
|
||||
val remaining = periodMs - (now % periodMs)
|
||||
progress = remaining.toFloat() / periodMs.toFloat()
|
||||
remainingSeconds = (remaining / 1000L).toInt()
|
||||
}
|
||||
TokenType.HOTP -> {
|
||||
currentCode = NativeLib.generateHOTP(
|
||||
secretBytes, token.counter, token.digits, token.algorithm.ordinal
|
||||
)
|
||||
progress = 1f
|
||||
}
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
@@ -56,9 +96,8 @@ fun TokenDetailSheet(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Header
|
||||
// Title
|
||||
Text(
|
||||
text = token.serviceName,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
@@ -72,198 +111,106 @@ fun TokenDetailSheet(
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
// Main row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = token.secret.value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
TokenIcon(token)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = formatCode(currentCode),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
letterSpacing = 4.sp,
|
||||
fontFeatureSettings = "tnum"
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
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)
|
||||
if (remainingSeconds <= 5 && (token.tokenType == TokenType.TOTP || token.tokenType == TokenType.STEAM)) {
|
||||
Text(
|
||||
text = formatCode(nextCode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
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))
|
||||
if (token.tokenType != TokenType.HOTP) {
|
||||
Box(
|
||||
modifier = Modifier.size(44.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = if (remainingSeconds <= 5) warningColor else MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
Text(
|
||||
text = "$remainingSeconds",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (remainingSeconds <= 5) warningColor else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit button
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// Buttons row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onEdit()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
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()
|
||||
copyToClipboard(context, currentCode, token.serviceName)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
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))
|
||||
}
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.copy_code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 formatCode(code: String): String {
|
||||
if (code.length == 6) {
|
||||
return "${code.substring(0, 3)} ${code.substring(3)}"
|
||||
}
|
||||
if (code.length == 8) {
|
||||
return "${code.substring(0, 4)} ${code.substring(4)}"
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
private fun copyToClipboard(context: Context, code: String, label: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("OTP Code", code)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, context.getString(R.string.code_copied, label), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package cn.airnan.a2fair.ui.tokens
|
||||
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TokenEditScreen(
|
||||
tokenId: Long?,
|
||||
viewModel: TokensViewModel,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val state by viewModel.addTokenState.collectAsStateWithLifecycle()
|
||||
|
||||
var currentToken by remember { mutableStateOf<Token?>(null) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
var typeExpanded by remember { mutableStateOf(false) }
|
||||
var algorithmExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Load token for editing
|
||||
LaunchedEffect(tokenId) {
|
||||
val id = tokenId
|
||||
if (id != null) {
|
||||
val token = app.tokenRepository.getTokenById(id)
|
||||
if (token != null) {
|
||||
currentToken = token
|
||||
viewModel.startEditToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back on success
|
||||
LaunchedEffect(state.isSuccess) {
|
||||
if (state.isSuccess) {
|
||||
viewModel.resetAddState()
|
||||
onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state on dispose
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.resetAddState()
|
||||
}
|
||||
}
|
||||
|
||||
val isEditing = tokenId != null
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (isEditing) stringResource(R.string.edit_token)
|
||||
else stringResource(R.string.add_token_title)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
// Error message
|
||||
if (state.isError) {
|
||||
Text(
|
||||
text = state.errorMessage ?: stringResource(R.string.unknown_error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Account Name
|
||||
OutlinedTextField(
|
||||
value = state.accountName,
|
||||
onValueChange = { viewModel.updateAccountName(it) },
|
||||
label = { Text(stringResource(R.string.account_name_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Secret Key
|
||||
OutlinedTextField(
|
||||
value = state.secret,
|
||||
onValueChange = { viewModel.updateSecret(it) },
|
||||
label = { Text(stringResource(R.string.secret_key_required)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Issuer
|
||||
OutlinedTextField(
|
||||
value = state.issuer,
|
||||
onValueChange = { viewModel.updateIssuer(it) },
|
||||
label = { Text(stringResource(R.string.issuer_optional)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Token Type dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = typeExpanded,
|
||||
onExpandedChange = { typeExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.tokenType.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeExpanded) },
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = typeExpanded,
|
||||
onDismissRequest = { typeExpanded = false }
|
||||
) {
|
||||
TokenType.entries.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name) },
|
||||
onClick = {
|
||||
viewModel.updateTokenType(type)
|
||||
typeExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Algorithm dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = algorithmExpanded,
|
||||
onExpandedChange = { algorithmExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.algorithm.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.algorithm_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = algorithmExpanded) },
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = algorithmExpanded,
|
||||
onDismissRequest = { algorithmExpanded = false }
|
||||
) {
|
||||
HashAlgorithm.entries.forEach { algo ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(algo.name) },
|
||||
onClick = {
|
||||
viewModel.updateAlgorithm(algo)
|
||||
algorithmExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Digits + Period/Counter Row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Digits
|
||||
OutlinedTextField(
|
||||
value = state.digits.toString(),
|
||||
onValueChange = { value ->
|
||||
value.toIntOrNull()?.let { viewModel.updateDigits(it) }
|
||||
},
|
||||
label = { Text(stringResource(R.string.digits_label)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Period (for TOTP/STEAM) or Counter (for HOTP)
|
||||
if (state.tokenType == TokenType.HOTP) {
|
||||
OutlinedTextField(
|
||||
value = state.counter.toString(),
|
||||
onValueChange = { value ->
|
||||
value.toLongOrNull()?.let { viewModel.updateCounter(it) }
|
||||
},
|
||||
label = { Text(stringResource(R.string.counter_label)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.period.toString(),
|
||||
onValueChange = { value ->
|
||||
value.toIntOrNull()?.let { viewModel.updatePeriod(it) }
|
||||
},
|
||||
label = { Text(stringResource(R.string.period_label)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Duplicate warning
|
||||
if (state.isDuplicate) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.duplicate_token_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.duplicate_token_message, state.duplicateServiceName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.dismissDuplicate()
|
||||
onNavigateBack()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.skip))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = { viewModel.forceAddToken() }) {
|
||||
Text(stringResource(R.string.force_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Save button
|
||||
if (!state.isDuplicate) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isEditing) {
|
||||
viewModel.updateToken()
|
||||
} else {
|
||||
viewModel.addTokenManually()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button (editing only)
|
||||
if (isEditing) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showDeleteConfirm = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
title = { Text(stringResource(R.string.confirm_delete)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.confirm_delete_message, currentToken?.serviceName ?: state.accountName))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirm = false
|
||||
currentToken?.let { viewModel.deleteToken(it) }
|
||||
onNavigateBack()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
@@ -31,8 +32,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.core.NativeLib
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
@@ -42,22 +41,64 @@ import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TokensScreen() {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val viewModel: TokensViewModel = viewModel(
|
||||
factory = TokensViewModel.Factory(app)
|
||||
)
|
||||
|
||||
fun TokensScreen(
|
||||
viewModel: TokensViewModel,
|
||||
onNavigateToTokenEdit: (Long?) -> Unit = {}
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showAddBottomSheet by remember { mutableStateOf(false) }
|
||||
var isSearchExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tokens)) },
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(visible = !isSearchExpanded) {
|
||||
Text(stringResource(R.string.tokens))
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isSearchExpanded,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = { viewModel.updateSearchQuery(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(R.string.search_tokens)) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.search)) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
viewModel.updateSearchQuery("")
|
||||
isSearchExpanded = false
|
||||
}) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Optional action icons
|
||||
AnimatedVisibility(visible = !isSearchExpanded) {
|
||||
IconButton(onClick = { isSearchExpanded = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.search))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -72,19 +113,6 @@ fun TokensScreen() {
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Search Bar
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = { viewModel.updateSearchQuery(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text(stringResource(R.string.search_tokens)) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.search)) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large
|
||||
)
|
||||
|
||||
// Token List
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -95,24 +123,32 @@ fun TokensScreen() {
|
||||
val isCollapsed = uiState.collapsedGroups.contains(groupId ?: -1L)
|
||||
|
||||
item(key = "group_${groupId ?: "unassigned"}") {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column {
|
||||
GroupHeader(
|
||||
group = group,
|
||||
isCollapsed = isCollapsed,
|
||||
onToggle = { viewModel.toggleGroupCollapse(groupId) }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isCollapsed) {
|
||||
items(tokens, key = { it.id }) { token ->
|
||||
tokens.forEachIndexed { index, token ->
|
||||
TokenCard(
|
||||
token = token,
|
||||
onIncrement = { viewModel.incrementCounter(token) },
|
||||
onDelete = { viewModel.deleteToken(token) },
|
||||
onEdit = {
|
||||
viewModel.startEditToken(token)
|
||||
showAddBottomSheet = true
|
||||
}
|
||||
onEdit = { onNavigateToTokenEdit(token.id) }
|
||||
)
|
||||
if (index < tokens.lastIndex) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +158,11 @@ fun TokensScreen() {
|
||||
if (showAddBottomSheet) {
|
||||
AddTokenBottomSheet(
|
||||
viewModel = viewModel,
|
||||
onDismissRequest = { showAddBottomSheet = false }
|
||||
onDismissRequest = { showAddBottomSheet = false },
|
||||
onNavigateToManualEntry = {
|
||||
showAddBottomSheet = false
|
||||
onNavigateToTokenEdit(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -160,12 +200,13 @@ fun GroupHeader(
|
||||
fun TokenCard(
|
||||
token: Token,
|
||||
onIncrement: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
val warningColor = Color(0xFFFF1744)
|
||||
val otpPlaceholder = stringResource(R.string.otp_placeholder)
|
||||
val errorText = stringResource(R.string.error)
|
||||
var currentCode by remember { mutableStateOf(otpPlaceholder) }
|
||||
var nextCode by remember { mutableStateOf("") }
|
||||
var progress by remember { mutableStateOf(1f) }
|
||||
var remainingSeconds by remember { mutableStateOf(0) }
|
||||
var showDetailSheet by remember { mutableStateOf(false) }
|
||||
@@ -197,6 +238,13 @@ fun TokenCard(
|
||||
token.digits,
|
||||
token.algorithm.ordinal
|
||||
)
|
||||
nextCode = NativeLib.generateTOTP(
|
||||
secretBytes,
|
||||
time + 1,
|
||||
token.period,
|
||||
token.digits,
|
||||
token.algorithm.ordinal
|
||||
)
|
||||
val remaining = periodMs - (now % periodMs)
|
||||
progress = remaining.toFloat() / periodMs.toFloat()
|
||||
remainingSeconds = (remaining / 1000L).toInt()
|
||||
@@ -207,6 +255,10 @@ fun TokenCard(
|
||||
secretBytes,
|
||||
time
|
||||
)
|
||||
nextCode = NativeLib.generateSteam(
|
||||
secretBytes,
|
||||
time + 1
|
||||
)
|
||||
val periodMs = 30000L
|
||||
val remaining = periodMs - (now % periodMs)
|
||||
progress = remaining.toFloat() / periodMs.toFloat()
|
||||
@@ -226,23 +278,17 @@ fun TokenCard(
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
copyToClipboard(context, currentCode, token.serviceName)
|
||||
},
|
||||
onLongClick = { showDetailSheet = true }
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TokenIcon(token)
|
||||
@@ -252,26 +298,33 @@ fun TokenCard(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = token.serviceName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (token.issuer.isNotBlank()) {
|
||||
Text(
|
||||
text = token.issuer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatCode(currentCode),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
letterSpacing = 2.sp,
|
||||
fontFeatureSettings = "tnum"
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (remainingSeconds <= 5 && (token.tokenType == TokenType.TOTP || token.tokenType == TokenType.STEAM)) {
|
||||
Text(
|
||||
text = formatCode(nextCode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (token.tokenType == TokenType.HOTP) {
|
||||
@@ -286,7 +339,7 @@ fun TokenCard(
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = if (progress < 0.15f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
color = if (remainingSeconds <= 5) warningColor else MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
@@ -294,7 +347,7 @@ fun TokenCard(
|
||||
text = "$remainingSeconds",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (progress < 0.15f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
color = if (remainingSeconds <= 5) warningColor else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -307,15 +360,10 @@ fun TokenCard(
|
||||
onEdit = {
|
||||
showDetailSheet = false
|
||||
onEdit()
|
||||
},
|
||||
onDelete = {
|
||||
showDetailSheet = false
|
||||
onDelete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -256,14 +256,20 @@ class TokensViewModel(
|
||||
}
|
||||
if (duplicate != null) {
|
||||
pendingToken = token
|
||||
_addTokenState.update {
|
||||
it.copy(
|
||||
_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,
|
||||
isDuplicate = true,
|
||||
duplicateServiceName = duplicate.serviceName,
|
||||
isError = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="enter_manually">手动输入</string>
|
||||
<string name="enter_details_manually">手动输入详情</string>
|
||||
<string name="add_token_title">添加令牌</string>
|
||||
<string name="scanning_hint">扫描中…</string>
|
||||
<string name="account_name_required">账户名称(必填)</string>
|
||||
<string name="secret_key_required">密钥(必填)</string>
|
||||
<string name="issuer_optional">发行方(选填)</string>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="enter_manually">手動輸入</string>
|
||||
<string name="enter_details_manually">手動輸入詳情</string>
|
||||
<string name="add_token_title">新增權杖</string>
|
||||
<string name="scanning_hint">掃描中…</string>
|
||||
<string name="account_name_required">帳戶名稱(必填)</string>
|
||||
<string name="secret_key_required">金鑰(必填)</string>
|
||||
<string name="issuer_optional">發行方(選填)</string>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="enter_manually">Enter Manually</string>
|
||||
<string name="enter_details_manually">Enter Details Manually</string>
|
||||
<string name="add_token_title">Add Token</string>
|
||||
<string name="scanning_hint">Scanning…</string>
|
||||
<string name="account_name_required">Account Name (Required)</string>
|
||||
<string name="secret_key_required">Secret Key (Required)</string>
|
||||
<string name="issuer_optional">Issuer (Optional)</string>
|
||||
|
||||
Reference in New Issue
Block a user