重构交互界面

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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import cn.airnan.a2fair.A2FairApplication import cn.airnan.a2fair.A2FairApplication
@@ -33,6 +34,9 @@ fun AppNavigation(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val app = context.applicationContext as A2FairApplication 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 animDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime)
val currentTopLevelDestination = appState.currentTopLevelDestination val currentTopLevelDestination = appState.currentTopLevelDestination
@@ -66,9 +70,39 @@ fun AppNavigation(
androidx.compose.foundation.layout.Box( androidx.compose.foundation.layout.Box(
modifier = Modifier.padding(bottom = padding.calculateBottomPadding()) 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( composable(
route = TopLevelDestination.SETTINGS.route, route = TopLevelDestination.SETTINGS.route,
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
@@ -14,6 +14,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -23,14 +24,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView 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.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.R 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.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage 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 kotlinx.coroutines.launch
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -51,13 +46,14 @@ import java.util.concurrent.Executors
@Composable @Composable
fun AddTokenBottomSheet( fun AddTokenBottomSheet(
viewModel: TokensViewModel, viewModel: TokensViewModel,
onDismissRequest: () -> Unit onDismissRequest: () -> Unit,
onNavigateToManualEntry: () -> Unit
) { ) {
val state by viewModel.addTokenState.collectAsStateWithLifecycle() val state by viewModel.addTokenState.collectAsStateWithLifecycle()
val isEditing = viewModel.isEditing
val context = LocalContext.current val context = LocalContext.current
var showManualEntry by remember { mutableStateOf(isEditing) } val lifecycleOwner = LocalLifecycleOwner.current
var showCameraScanner by remember { mutableStateOf(false) } var scanned by remember { mutableStateOf(false) }
var showScanningText by remember { mutableStateOf(true) }
val photoPickerLauncher = rememberLauncherForActivityResult( val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia() contract = ActivityResultContracts.PickVisualMedia()
@@ -88,10 +84,9 @@ fun AddTokenBottomSheet(
val cameraPermissionLauncher = rememberLauncherForActivityResult( val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { isGranted -> ) { _ ->
if (isGranted) { // Permission result - camera will auto-start when preview composable is shown
showCameraScanner = true // if permission was granted, we just re-compose so PreviewView binds
}
} }
LaunchedEffect(state.isSuccess) { LaunchedEffect(state.isSuccess) {
@@ -101,11 +96,18 @@ fun AddTokenBottomSheet(
} }
} }
LaunchedEffect(state.isDuplicate) {
if (state.isDuplicate) {
onNavigateToManualEntry()
}
}
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
viewModel.resetAddState() viewModel.resetAddState()
onDismissRequest() onDismissRequest()
} },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -115,11 +117,7 @@ fun AddTokenBottomSheet(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = when { text = stringResource(R.string.add_token_title),
isEditing -> stringResource(R.string.edit_token)
showManualEntry -> stringResource(R.string.enter_details_manually)
else -> stringResource(R.string.add_token_title)
},
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
@@ -132,339 +130,162 @@ fun AddTokenBottomSheet(
) )
} }
if (!showManualEntry) { // Camera preview
ListItem( var previewView by remember { mutableStateOf<PreviewView?>(null) }
headlineContent = { Text(stringResource(R.string.scan_qr_code)) },
leadingContent = { Icon(Icons.Default.QrCodeScanner, contentDescription = null) }, Card(
modifier = Modifier.clickable { modifier = Modifier
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { .fillMaxWidth()
showCameraScanner = true .heightIn(min = 200.dp, max = 300.dp)
} else { .aspectRatio(4f / 3f)
cameraPermissionLauncher.launch(Manifest.permission.CAMERA) .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( LaunchedEffect(previewView) {
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp), val pv = previewView ?: return@LaunchedEffect
horizontalArrangement = Arrangement.End val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
) { cameraProviderFuture.addListener({
TextButton(onClick = onCancel) { val cameraProvider = cameraProviderFuture.get()
Text(stringResource(R.string.cancel)) val preview = Preview.Builder().build().also {
} it.setSurfaceProvider(pv.surfaceProvider)
Spacer(modifier = Modifier.width(8.dp)) }
if (!state.isDuplicate) { val imageAnalyzer = ImageAnalysis.Builder()
if (isEditing) { .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
Button(onClick = viewModel::updateToken) { .build()
Text(stringResource(R.string.save)) .also {
} it.setAnalyzer(
} else { Executors.newSingleThreadExecutor(),
Button(onClick = viewModel::addTokenManually) { ZxingAnalyzer { uri ->
Text(stringResource(R.string.add)) 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 Row(
fun CameraScannerDialog( modifier = Modifier
onScan: (String) -> Unit, .fillMaxWidth()
onDismissRequest: () -> Unit .padding(vertical = 8.dp),
) { horizontalArrangement = Arrangement.spacedBy(12.dp)
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) FilledTonalButton(
} onClick = {
} photoPickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
} },
} modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
@Composable contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp)
fun CameraPreview(onScan: (String) -> Unit) { ) {
val context = LocalContext.current Icon(
val lifecycleOwner = LocalLifecycleOwner.current imageVector = Icons.Default.Image,
var previewView by remember { mutableStateOf<PreviewView?>(null) } contentDescription = null,
modifier = Modifier.size(20.dp)
AndroidView( )
factory = { ctx -> Spacer(modifier = Modifier.width(8.dp))
PreviewView(ctx).also { Text(
previewView = it text = stringResource(R.string.import_from_image),
} maxLines = 1
}, )
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))
} }
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA FilledTonalButton(
try { onClick = { onNavigateToManualEntry() },
cameraProvider.unbindAll() modifier = Modifier.weight(1f),
cameraProvider.bindToLifecycle( shape = RoundedCornerShape(12.dp),
lifecycleOwner, cameraSelector, preview, imageAnalyzer contentPadding = PaddingValues(horizontal = 8.dp, vertical = 12.dp)
) ) {
} catch (e: Exception) { Icon(
e.printStackTrace() 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 { class ZxingAnalyzer(private val onScan: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader() private val scanner = BarcodeScanning.getClient()
@Volatile @Volatile
private var scanned = false private var scanned = false
override fun analyze(image: ImageProxy) { @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
override fun analyze(imageProxy: ImageProxy) {
if (scanned) { if (scanned) {
image.close() imageProxy.close()
return return
} }
val buffer = image.planes[0].buffer val mediaImage = imageProxy.image
val data = ByteArray(buffer.capacity()) if (mediaImage != null) {
buffer.get(data) val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
val source = PlanarYUVLuminanceSource( .addOnSuccessListener { barcodes ->
data, val barcode = barcodes.firstOrNull { it.valueType == Barcode.TYPE_WIFI || it.rawValue?.startsWith("otpauth://") == true }
image.width, ?: barcodes.firstOrNull()
image.height, val rawValue = barcode?.rawValue
0, if (rawValue != null && rawValue.startsWith("otpauth://")) {
0, scanned = true
image.width, onScan(rawValue)
image.height, }
false }
) .addOnFailureListener {
val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) // Ignored
}
try { .addOnCompleteListener {
val result = reader.decode(binaryBitmap) imageProxy.close()
val text = result.text }
if (text.startsWith("otpauth://")) { } else {
scanned = true imageProxy.close()
onScan(text)
}
} catch (e: NotFoundException) {
// No barcode found
} finally {
image.close()
} }
} }
} }
@@ -3,37 +3,26 @@ package cn.airnan.a2fair.ui.tokens
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* 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.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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import cn.airnan.a2fair.R 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.Token
import cn.airnan.a2fair.data.entity.TokenType import cn.airnan.a2fair.data.entity.TokenType
import com.google.zxing.BarcodeFormat import kotlinx.coroutines.delay
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import java.util.EnumMap
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -41,14 +30,65 @@ fun TokenDetailSheet(
token: Token, token: Token,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
var showDeleteConfirm by remember { mutableStateOf(false) } val warningColor = Color(0xFFFF1744)
var showQrCode by remember { mutableStateOf(false) } val otpPlaceholder = stringResource(R.string.otp_placeholder)
var secretVisible by remember { mutableStateOf(false) } 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) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column( Column(
@@ -56,9 +96,8 @@ fun TokenDetailSheet(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 32.dp) .padding(bottom = 32.dp)
.verticalScroll(rememberScrollState())
) { ) {
// Header // Title
Text( Text(
text = token.serviceName, text = token.serviceName,
style = MaterialTheme.typography.headlineSmall, 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)) Spacer(modifier = Modifier.height(16.dp))
// Secret field // Main row
Text(
text = stringResource(R.string.secret_key),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
OutlinedTextField( TokenIcon(token)
value = token.secret.value,
onValueChange = {},
readOnly = true,
modifier = Modifier.weight(1f),
visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { secretVisible = !secretVisible }) {
Icon(
imageVector = if (secretVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (secretVisible) stringResource(R.string.hide_secret) else stringResource(R.string.show_secret)
)
}
IconButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Secret", token.secret.value))
Toast.makeText(context, secretCopiedText, Toast.LENGTH_SHORT).show()
}) {
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_secret))
}
IconButton(onClick = { showQrCode = true }) {
Icon(Icons.Default.QrCode, contentDescription = stringResource(R.string.show_qr_code))
}
}
Spacer(modifier = Modifier.height(24.dp))
// Edit button
OutlinedButton(
onClick = {
onDismiss()
onEdit()
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Edit, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.edit))
}
Spacer(modifier = Modifier.height(8.dp))
// Delete button
OutlinedButton(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.delete))
}
}
}
// Delete confirmation dialog
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text(stringResource(R.string.confirm_delete)) },
text = { Text(stringResource(R.string.confirm_delete_message, token.serviceName)) },
confirmButton = {
TextButton(
onClick = {
showDeleteConfirm = false
onDismiss()
onDelete()
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
// QR Code dialog
if (showQrCode) {
val uriString = remember(token) { OtpUriBuilder.build(token) }
val qrBitmap = remember(uriString) { generateQrCode(uriString, 512) }
Dialog(
onDismissRequest = { showQrCode = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
shape = MaterialTheme.shapes.large
) {
Column( Column(
modifier = Modifier.padding(24.dp), modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = stringResource(R.string.qr_code), text = formatCode(currentCode),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.headlineLarge.copy(
letterSpacing = 4.sp,
fontFeatureSettings = "tnum"
),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(16.dp)) if (remainingSeconds <= 5 && (token.tokenType == TokenType.TOTP || token.tokenType == TokenType.STEAM)) {
qrBitmap?.let { bmp -> Text(
Image( text = formatCode(nextCode),
bitmap = bmp.asImageBitmap(), style = MaterialTheme.typography.bodySmall,
contentDescription = "QR Code", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
) )
} }
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 formatCode(code: String): String {
private fun InfoRow(label: String, value: String) { if (code.length == 6) {
Row( return "${code.substring(0, 3)} ${code.substring(3)}"
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
)
} }
if (code.length == 8) {
return "${code.substring(0, 4)} ${code.substring(4)}"
}
return code
} }
private fun generateQrCode(content: String, size: Int): Bitmap? { private fun copyToClipboard(context: Context, code: String, label: String) {
return try { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val hints = EnumMap<EncodeHintType, Any>(EncodeHintType::class.java).apply { val clip = ClipData.newPlainText("OTP Code", code)
this[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M clipboard.setPrimaryClip(clip)
this[EncodeHintType.MARGIN] = 1 Toast.makeText(context, context.getString(R.string.code_copied, label), Toast.LENGTH_SHORT).show()
}
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
}
} }
@@ -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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -31,8 +32,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.core.NativeLib
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.data.entity.Group import cn.airnan.a2fair.data.entity.Group
@@ -42,22 +41,64 @@ import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TokensScreen() { fun TokensScreen(
val context = LocalContext.current viewModel: TokensViewModel,
val app = context.applicationContext as A2FairApplication onNavigateToTokenEdit: (Long?) -> Unit = {}
val viewModel: TokensViewModel = viewModel( ) {
factory = TokensViewModel.Factory(app)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showAddBottomSheet by remember { mutableStateOf(false) } var showAddBottomSheet by remember { mutableStateOf(false) }
var isSearchExpanded by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( 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 = { 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() .fillMaxSize()
.padding(padding) .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 // Token List
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -95,24 +123,32 @@ fun TokensScreen() {
val isCollapsed = uiState.collapsedGroups.contains(groupId ?: -1L) val isCollapsed = uiState.collapsedGroups.contains(groupId ?: -1L)
item(key = "group_${groupId ?: "unassigned"}") { item(key = "group_${groupId ?: "unassigned"}") {
GroupHeader( Card(
group = group, modifier = Modifier
isCollapsed = isCollapsed, .fillMaxWidth()
onToggle = { viewModel.toggleGroupCollapse(groupId) } .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) { if (!isCollapsed) {
items(tokens, key = { it.id }) { token -> tokens.forEachIndexed { index, token ->
TokenCard( TokenCard(
token = token, token = token,
onIncrement = { viewModel.incrementCounter(token) }, onIncrement = { viewModel.incrementCounter(token) },
onDelete = { viewModel.deleteToken(token) }, onEdit = { onNavigateToTokenEdit(token.id) }
onEdit = { )
viewModel.startEditToken(token) if (index < tokens.lastIndex) {
showAddBottomSheet = true HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
} }
) }
} }
} }
} }
@@ -122,7 +158,11 @@ fun TokensScreen() {
if (showAddBottomSheet) { if (showAddBottomSheet) {
AddTokenBottomSheet( AddTokenBottomSheet(
viewModel = viewModel, viewModel = viewModel,
onDismissRequest = { showAddBottomSheet = false } onDismissRequest = { showAddBottomSheet = false },
onNavigateToManualEntry = {
showAddBottomSheet = false
onNavigateToTokenEdit(null)
}
) )
} }
} }
@@ -160,12 +200,13 @@ fun GroupHeader(
fun TokenCard( fun TokenCard(
token: Token, token: Token,
onIncrement: () -> Unit, onIncrement: () -> Unit,
onDelete: () -> Unit,
onEdit: () -> Unit onEdit: () -> Unit
) { ) {
val warningColor = Color(0xFFFF1744)
val otpPlaceholder = stringResource(R.string.otp_placeholder) val otpPlaceholder = stringResource(R.string.otp_placeholder)
val errorText = stringResource(R.string.error) val errorText = stringResource(R.string.error)
var currentCode by remember { mutableStateOf(otpPlaceholder) } var currentCode by remember { mutableStateOf(otpPlaceholder) }
var nextCode by remember { mutableStateOf("") }
var progress by remember { mutableStateOf(1f) } var progress by remember { mutableStateOf(1f) }
var remainingSeconds by remember { mutableStateOf(0) } var remainingSeconds by remember { mutableStateOf(0) }
var showDetailSheet by remember { mutableStateOf(false) } var showDetailSheet by remember { mutableStateOf(false) }
@@ -197,6 +238,13 @@ fun TokenCard(
token.digits, token.digits,
token.algorithm.ordinal token.algorithm.ordinal
) )
nextCode = NativeLib.generateTOTP(
secretBytes,
time + 1,
token.period,
token.digits,
token.algorithm.ordinal
)
val remaining = periodMs - (now % periodMs) val remaining = periodMs - (now % periodMs)
progress = remaining.toFloat() / periodMs.toFloat() progress = remaining.toFloat() / periodMs.toFloat()
remainingSeconds = (remaining / 1000L).toInt() remainingSeconds = (remaining / 1000L).toInt()
@@ -207,6 +255,10 @@ fun TokenCard(
secretBytes, secretBytes,
time time
) )
nextCode = NativeLib.generateSteam(
secretBytes,
time + 1
)
val periodMs = 30000L val periodMs = 30000L
val remaining = periodMs - (now % periodMs) val remaining = periodMs - (now % periodMs)
progress = remaining.toFloat() / periodMs.toFloat() progress = remaining.toFloat() / periodMs.toFloat()
@@ -226,94 +278,90 @@ fun TokenCard(
} }
} }
Card( Column {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 16.dp, vertical = 6.dp) .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onClick = {
copyToClipboard(context, currentCode, token.serviceName) 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()
}, },
onDelete = { onLongClick = { showDetailSheet = true }
showDetailSheet = false
onDelete()
}
) )
.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()
}
)
} }
} }
} }
@@ -256,14 +256,20 @@ class TokensViewModel(
} }
if (duplicate != null) { if (duplicate != null) {
pendingToken = token pendingToken = token
_addTokenState.update { _addTokenState.value = AddTokenUiState(
it.copy( issuer = token.issuer,
isDuplicate = true, accountName = token.serviceName,
duplicateServiceName = duplicate.serviceName, secret = token.secret.value,
isError = false, tokenType = token.tokenType,
errorMessage = null algorithm = token.algorithm,
) digits = token.digits,
} period = token.period,
counter = token.counter,
isDuplicate = true,
duplicateServiceName = duplicate.serviceName,
isError = false,
errorMessage = null
)
return@launch return@launch
} }
@@ -26,6 +26,7 @@
<string name="enter_manually">手动输入</string> <string name="enter_manually">手动输入</string>
<string name="enter_details_manually">手动输入详情</string> <string name="enter_details_manually">手动输入详情</string>
<string name="add_token_title">添加令牌</string> <string name="add_token_title">添加令牌</string>
<string name="scanning_hint">扫描中…</string>
<string name="account_name_required">账户名称(必填)</string> <string name="account_name_required">账户名称(必填)</string>
<string name="secret_key_required">密钥(必填)</string> <string name="secret_key_required">密钥(必填)</string>
<string name="issuer_optional">发行方(选填)</string> <string name="issuer_optional">发行方(选填)</string>
@@ -26,6 +26,7 @@
<string name="enter_manually">手動輸入</string> <string name="enter_manually">手動輸入</string>
<string name="enter_details_manually">手動輸入詳情</string> <string name="enter_details_manually">手動輸入詳情</string>
<string name="add_token_title">新增權杖</string> <string name="add_token_title">新增權杖</string>
<string name="scanning_hint">掃描中…</string>
<string name="account_name_required">帳戶名稱(必填)</string> <string name="account_name_required">帳戶名稱(必填)</string>
<string name="secret_key_required">金鑰(必填)</string> <string name="secret_key_required">金鑰(必填)</string>
<string name="issuer_optional">發行方(選填)</string> <string name="issuer_optional">發行方(選填)</string>
+1
View File
@@ -26,6 +26,7 @@
<string name="enter_manually">Enter Manually</string> <string name="enter_manually">Enter Manually</string>
<string name="enter_details_manually">Enter Details Manually</string> <string name="enter_details_manually">Enter Details Manually</string>
<string name="add_token_title">Add Token</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="account_name_required">Account Name (Required)</string>
<string name="secret_key_required">Secret Key (Required)</string> <string name="secret_key_required">Secret Key (Required)</string>
<string name="issuer_optional">Issuer (Optional)</string> <string name="issuer_optional">Issuer (Optional)</string>