From 2d45b717a403bd317a07563df95989fc938ca014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 16 Jun 2026 23:58:22 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=BA=A4=E4=BA=92=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../a2fair/ui/navigation/AppNavigation.kt | 36 +- .../a2fair/ui/tokens/AddTokenBottomSheet.kt | 487 ++++++------------ .../a2fair/ui/tokens/TokenDetailSheet.kt | 333 +++++------- .../a2fair/ui/tokens/TokenEditScreen.kt | 354 +++++++++++++ .../airnan/a2fair/ui/tokens/TokensScreen.kt | 302 ++++++----- .../a2fair/ui/tokens/TokensViewModel.kt | 22 +- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 875 insertions(+), 662 deletions(-) create mode 100644 app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenEditScreen.kt diff --git a/app/src/main/java/cn/airnan/a2fair/ui/navigation/AppNavigation.kt b/app/src/main/java/cn/airnan/a2fair/ui/navigation/AppNavigation.kt index 9310c55..38a7e9e 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/navigation/AppNavigation.kt @@ -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 }, diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt index afa17b0..2eedb6b 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/AddTokenBottomSheet.kt @@ -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,339 +130,162 @@ 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) + // Camera preview + var previewView by remember { mutableStateOf(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 + ) } } - ) - 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)) - } + LaunchedEffect(previewView) { + val pv = previewView ?: return@LaunchedEffect + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(pv.surfaceProvider) + } + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer( + Executors.newSingleThreadExecutor(), + ZxingAnalyzer { uri -> + scanned = true + showScanningText = false + viewModel.addTokenFromUri(uri) + } + ) + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, cameraSelector, preview, imageAnalyzer + ) + } catch (e: Exception) { + e.printStackTrace() + } + }, ContextCompat.getMainExecutor(context)) } - } - } -} -@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) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.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 - var previewView by remember { mutableStateOf(null) } - - AndroidView( - factory = { ctx -> - PreviewView(ctx).also { - previewView = it - } - }, - modifier = Modifier.fillMaxSize() - ) - - LaunchedEffect(previewView) { - val pv = previewView ?: return@LaunchedEffect - val cameraProviderFuture = ProcessCameraProvider.getInstance(context) - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(pv.surfaceProvider) - } - val imageAnalyzer = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { - it.setAnalyzer(Executors.newSingleThreadExecutor(), ZxingAnalyzer(onScan)) + 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 + ) } - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, cameraSelector, preview, imageAnalyzer - ) - } catch (e: Exception) { - e.printStackTrace() + 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 + ) + } } - }, ContextCompat.getMainExecutor(context)) + 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://")) { - scanned = true - onScan(text) - } - } catch (e: NotFoundException) { - // No barcode found - } finally { - image.close() + 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(rawValue) + } + } + .addOnFailureListener { + // Ignored + } + .addOnCompleteListener { + imageProxy.close() + } + } else { + imageProxy.close() } } } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt index 4f3e6f8..77b3bc3 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenDetailSheet.kt @@ -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, - modifier = Modifier.weight(1f), - visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(), - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium - ) - Spacer(modifier = Modifier.width(4.dp)) - IconButton(onClick = { secretVisible = !secretVisible }) { - Icon( - imageVector = if (secretVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (secretVisible) stringResource(R.string.hide_secret) else stringResource(R.string.show_secret) - ) - } - IconButton(onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Secret", token.secret.value)) - Toast.makeText(context, secretCopiedText, Toast.LENGTH_SHORT).show() - }) { - Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_secret)) - } - IconButton(onClick = { showQrCode = true }) { - Icon(Icons.Default.QrCode, contentDescription = stringResource(R.string.show_qr_code)) - } - } + TokenIcon(token) - Spacer(modifier = Modifier.height(24.dp)) - - // Edit button - OutlinedButton( - onClick = { - onDismiss() - onEdit() - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Edit, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.edit)) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Delete button - OutlinedButton( - onClick = { showDeleteConfirm = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Default.Delete, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.delete)) - } - } - } - - // Delete confirmation dialog - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(R.string.confirm_delete)) }, - text = { Text(stringResource(R.string.confirm_delete_message, token.serviceName)) }, - confirmButton = { - TextButton( - onClick = { - showDeleteConfirm = false - onDismiss() - onDelete() - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.delete)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } - - // QR Code dialog - if (showQrCode) { - val uriString = remember(token) { OtpUriBuilder.build(token) } - val qrBitmap = remember(uriString) { generateQrCode(uriString, 512) } - - Dialog( - onDismissRequest = { showQrCode = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - shape = MaterialTheme.shapes.large - ) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = stringResource(R.string.qr_code), - style = MaterialTheme.typography.titleMedium, + text = formatCode(currentCode), + style = MaterialTheme.typography.headlineLarge.copy( + letterSpacing = 4.sp, + fontFeatureSettings = "tnum" + ), + color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold ) - Spacer(modifier = Modifier.height(16.dp)) - qrBitmap?.let { bmp -> - Image( - bitmap = bmp.asImageBitmap(), - contentDescription = "QR Code", - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) + 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) ) } - Spacer(modifier = Modifier.height(16.dp)) - TextButton(onClick = { showQrCode = false }) { - Text(stringResource(R.string.close)) + } + + 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 + ) } } } + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + // Buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + onDismiss() + onEdit() + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Edit, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.edit)) + } + + OutlinedButton( + onClick = { + copyToClipboard(context, currentCode, token.serviceName) + }, + modifier = Modifier.weight(1f) + ) { + 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::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() } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenEditScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenEditScreen.kt new file mode 100644 index 0000000..0d74f73 --- /dev/null +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokenEditScreen.kt @@ -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(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)) + } + } + ) + } +} diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt index 51bf601..4aee2bc 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensScreen.kt @@ -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"}") { - GroupHeader( - group = group, - isCollapsed = isCollapsed, - onToggle = { viewModel.toggleGroupCollapse(groupId) } - ) - } + 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 -> - TokenCard( - token = token, - onIncrement = { viewModel.incrementCounter(token) }, - onDelete = { viewModel.deleteToken(token) }, - onEdit = { - viewModel.startEditToken(token) - showAddBottomSheet = true + if (!isCollapsed) { + tokens.forEachIndexed { index, token -> + TokenCard( + token = token, + onIncrement = { viewModel.incrementCounter(token) }, + 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,94 +278,90 @@ fun TokenCard( } } - Card( - 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), - verticalAlignment = Alignment.CenterVertically - ) { - TokenIcon(token) - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = token.serviceName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - if (token.issuer.isNotBlank()) { - Text( - text = token.issuer, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = formatCode(currentCode), - style = MaterialTheme.typography.headlineSmall.copy( - letterSpacing = 2.sp, - fontFeatureSettings = "tnum" - ), - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - } - - if (token.tokenType == TokenType.HOTP) { - IconButton(onClick = onIncrement) { - Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh_hotp)) - } - } else { - Box( - modifier = Modifier.size(44.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxSize(), - color = if (progress < 0.15f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - strokeWidth = 3.dp, - ) - Text( - text = "$remainingSeconds", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = if (progress < 0.15f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface - ) - } - } - } - - if (showDetailSheet) { - TokenDetailSheet( - token = token, - onDismiss = { showDetailSheet = false }, - onEdit = { - showDetailSheet = false - onEdit() + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + copyToClipboard(context, currentCode, token.serviceName) }, - onDelete = { - showDetailSheet = false - onDelete() - } + onLongClick = { showDetailSheet = true } ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TokenIcon(token) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = token.serviceName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + if (token.issuer.isNotBlank()) { + Text( + text = token.issuer, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatCode(currentCode), + 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) { + IconButton(onClick = onIncrement) { + Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh_hotp)) + } + } else { + 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 + ) + } + } + } + + if (showDetailSheet) { + TokenDetailSheet( + token = token, + onDismiss = { showDetailSheet = false }, + onEdit = { + showDetailSheet = false + onEdit() + } + ) } } } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt index b0d8f3b..f38e722 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/tokens/TokensViewModel.kt @@ -256,14 +256,20 @@ class TokensViewModel( } if (duplicate != null) { pendingToken = token - _addTokenState.update { - it.copy( - isDuplicate = true, - duplicateServiceName = duplicate.serviceName, - isError = false, - errorMessage = null - ) - } + _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 } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index df2000d..00d6396 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -26,6 +26,7 @@ 手动输入 手动输入详情 添加令牌 + 扫描中… 账户名称(必填) 密钥(必填) 发行方(选填) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2b25b17..bf9cecb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -26,6 +26,7 @@ 手動輸入 手動輸入詳情 新增權杖 + 掃描中… 帳戶名稱(必填) 金鑰(必填) 發行方(選填) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79e17fe..2a65b31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ Enter Manually Enter Details Manually Add Token + Scanning… Account Name (Required) Secret Key (Required) Issuer (Optional)