diff --git a/app/src/main/java/cn/airnan/a2fair/MainActivity.kt b/app/src/main/java/cn/airnan/a2fair/MainActivity.kt index f05db43..6f3735e 100644 --- a/app/src/main/java/cn/airnan/a2fair/MainActivity.kt +++ b/app/src/main/java/cn/airnan/a2fair/MainActivity.kt @@ -7,6 +7,11 @@ import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -84,13 +89,30 @@ class MainActivity : FragmentActivity() { dynamicColor = dynamicColor, seedColor = seedColor ) { - if (isLocked) { - LockScreen( - onUnlock = { appLockManager.unlock() }, - modifier = Modifier.fillMaxSize() - ) - } else { - AppNavigation() + val animDuration = 300 + AnimatedContent( + targetState = isLocked, + transitionSpec = { + if (targetState) { + // Lock screen fades in (app fades out) + (fadeIn(animationSpec = tween(animDuration))) + .togetherWith(fadeOut(animationSpec = tween(animDuration))) + } else { + // App fades in (lock screen fades out) + (fadeIn(animationSpec = tween(animDuration))) + .togetherWith(fadeOut(animationSpec = tween(animDuration))) + } + }, + label = "lock_transition" + ) { locked -> + if (locked) { + LockScreen( + onUnlock = { appLockManager.unlock() }, + modifier = Modifier.fillMaxSize() + ) + } else { + AppNavigation() + } } } } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt index ddc300c..56f6e91 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/LockScreen.kt @@ -4,16 +4,19 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import cn.airnan.a2fair.R import cn.airnan.a2fair.A2FairApplication import cn.airnan.a2fair.core.security.PIN_LENGTH +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -37,6 +40,7 @@ fun LockScreen( var enteredPin by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } + var isSuccess by remember { mutableStateOf(false) } val incorrectPinMsg = stringResource(R.string.incorrect_pin) val pinHintMsg = stringResource(R.string.pin_digits_hint) var remainingLockOutTime by remember { mutableStateOf(0L) } @@ -78,11 +82,13 @@ fun LockScreen( fun submitPin(candidate: String) { if (pinCode != null && candidate == pinCode) { + isSuccess = true scope.launch { settingsRepo.setFailedAttempts(0) settingsRepo.setLockoutEndTime(0L) + delay(450L) + onUnlock() } - onUnlock() } else { val newAttempts = failedAttempts + 1 errorMessage = incorrectPinMsg @@ -106,10 +112,10 @@ fun LockScreen( verticalArrangement = Arrangement.Center ) { Icon( - imageVector = Icons.Default.Lock, + imageVector = if (isSuccess) Icons.Default.LockOpen else Icons.Default.Lock, contentDescription = stringResource(R.string.lock_icon), modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary + tint = if (isSuccess) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(32.dp)) @@ -122,6 +128,7 @@ fun LockScreen( PinIndicatorRow( filledCount = enteredPin.length, isError = errorMessage != null || remainingLockOutTime > 0L, + isSuccess = isSuccess, modifier = Modifier.fillMaxWidth(0.8f) ) Spacer(modifier = Modifier.height(12.dp)) @@ -146,7 +153,7 @@ fun LockScreen( resetLabel = stringResource(R.string.clear_pin_input), deleteLabel = stringResource(R.string.delete_digit), onDigit = { digit -> - if (remainingLockOutTime > 0L || enteredPin.length >= PIN_LENGTH) { + if (remainingLockOutTime > 0L || isSuccess || enteredPin.length >= PIN_LENGTH) { return@PinNumberPad } val updatedPin = enteredPin + digit diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt index e73716d..8b8e874 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinInputComponents.kt @@ -2,6 +2,7 @@ package cn.airnan.a2fair.ui.settings.security import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,21 +24,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import cn.airnan.a2fair.core.security.PIN_LENGTH +import kotlinx.coroutines.delay @Composable fun PinIndicatorRow( filledCount: Int, modifier: Modifier = Modifier, - isError: Boolean = false + isError: Boolean = false, + isSuccess: Boolean = false ) { - val activeColor = if (isError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary + val activeColor = when { + isSuccess -> Color(0xFF4CAF50) + isError -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary } val inactiveColor = if (isError) { MaterialTheme.colorScheme.error.copy(alpha = 0.24f) @@ -60,13 +64,22 @@ fun PinIndicatorRow( } } + // Success sweep: each dot scales up in sequence + LaunchedEffect(isSuccess) { + if (isSuccess) { + delay(index * 50L) + scale.snapTo(1.4f) + scale.animateTo(1f, tween(120)) + } + } + Box( modifier = Modifier .padding(horizontal = 6.dp) .size(14.dp) .scale(scale.value) .clip(CircleShape) - .background(if (isFilled) activeColor else inactiveColor) + .background(if (isFilled || isSuccess) activeColor else inactiveColor) ) } } diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt index 8f1c098..8d8865b 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/PinSetupScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.unit.dp import cn.airnan.a2fair.R import cn.airnan.a2fair.core.security.PIN_LENGTH import cn.airnan.a2fair.data.repository.SettingsRepository +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -26,6 +27,7 @@ fun PinSetupScreen( var pin by remember { mutableStateOf("") } var confirmPin by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } + var isSuccess by remember { mutableStateOf(false) } val pinTooShortMsg = stringResource(R.string.pin_too_short) val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match) @@ -71,7 +73,8 @@ fun PinSetupScreen( PinIndicatorRow( filledCount = currentPin.length, - isError = errorMessage != null + isError = errorMessage != null, + isSuccess = isSuccess ) Spacer(modifier = Modifier.height(12.dp)) @@ -94,7 +97,7 @@ fun PinSetupScreen( }, deleteLabel = stringResource(R.string.delete_digit), onDigit = { digit -> - if (currentPin.length >= PIN_LENGTH) return@PinNumberPad + if (isSuccess || currentPin.length >= PIN_LENGTH) return@PinNumberPad if (step == 1) { pin += digit } else { @@ -143,8 +146,10 @@ fun PinSetupScreen( if (confirmPin.length != PIN_LENGTH) { errorMessage = pinTooShortMsg } else if (pin == confirmPin) { + isSuccess = true scope.launch { settingsRepository.enablePin(pin) + delay(450L) onNavigateBack() } } else { diff --git a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt index ddb7081..f2ce016 100644 --- a/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt +++ b/app/src/main/java/cn/airnan/a2fair/ui/settings/security/SecurityVerificationDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -21,6 +22,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import cn.airnan.a2fair.R import cn.airnan.a2fair.core.security.PIN_LENGTH +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun SecurityVerificationDialog( @@ -31,11 +34,17 @@ fun SecurityVerificationDialog( ) { var enteredPin by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } + var isSuccess by remember { mutableStateOf(false) } val incorrectPinMessage = stringResource(R.string.incorrect_pin) + val scope = rememberCoroutineScope() fun submitPin(candidate: String) { if (!expectedPin.isNullOrEmpty() && candidate == expectedPin) { - onVerified() + isSuccess = true + scope.launch { + delay(450L) + onVerified() + } } else { enteredPin = "" errorMessage = incorrectPinMessage @@ -61,6 +70,7 @@ fun SecurityVerificationDialog( PinIndicatorRow( filledCount = enteredPin.length, isError = errorMessage != null, + isSuccess = isSuccess, modifier = Modifier.fillMaxWidth() ) @@ -82,7 +92,7 @@ fun SecurityVerificationDialog( resetLabel = stringResource(R.string.clear_pin_input), deleteLabel = stringResource(R.string.delete_digit), onDigit = { digit -> - if (enteredPin.length >= PIN_LENGTH) return@PinNumberPad + if (isSuccess || enteredPin.length >= PIN_LENGTH) return@PinNumberPad val updatedPin = enteredPin + digit enteredPin = updatedPin errorMessage = null @@ -105,6 +115,7 @@ fun SecurityVerificationDialog( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp), + isEnabled = !isSuccess, isDeleteEnabled = enteredPin.isNotEmpty(), isResetEnabled = enteredPin.isNotEmpty() )