重构交互界面
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user