重构交互界面

This commit is contained in:
2026-06-16 23:58:22 +08:00
parent d60b102f83
commit 2d45b717a4
9 changed files with 875 additions and 662 deletions
@@ -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>
+1
View File
@@ -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>