优化应用锁交互动画

This commit is contained in:
2026-06-17 10:57:23 +08:00
parent be613749c4
commit f33247f732
5 changed files with 79 additions and 21 deletions
@@ -7,6 +7,11 @@ import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -84,7 +89,23 @@ class MainActivity : FragmentActivity() {
dynamicColor = dynamicColor, dynamicColor = dynamicColor,
seedColor = seedColor seedColor = seedColor
) { ) {
if (isLocked) { 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( LockScreen(
onUnlock = { appLockManager.unlock() }, onUnlock = { appLockManager.unlock() },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@@ -95,4 +116,5 @@ class MainActivity : FragmentActivity() {
} }
} }
} }
}
} }
@@ -4,16 +4,19 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
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.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.A2FairApplication import cn.airnan.a2fair.A2FairApplication
import cn.airnan.a2fair.core.security.PIN_LENGTH import cn.airnan.a2fair.core.security.PIN_LENGTH
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -37,6 +40,7 @@ fun LockScreen(
var enteredPin by remember { mutableStateOf("") } var enteredPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
var isSuccess by remember { mutableStateOf(false) }
val incorrectPinMsg = stringResource(R.string.incorrect_pin) val incorrectPinMsg = stringResource(R.string.incorrect_pin)
val pinHintMsg = stringResource(R.string.pin_digits_hint) val pinHintMsg = stringResource(R.string.pin_digits_hint)
var remainingLockOutTime by remember { mutableStateOf(0L) } var remainingLockOutTime by remember { mutableStateOf(0L) }
@@ -78,11 +82,13 @@ fun LockScreen(
fun submitPin(candidate: String) { fun submitPin(candidate: String) {
if (pinCode != null && candidate == pinCode) { if (pinCode != null && candidate == pinCode) {
isSuccess = true
scope.launch { scope.launch {
settingsRepo.setFailedAttempts(0) settingsRepo.setFailedAttempts(0)
settingsRepo.setLockoutEndTime(0L) settingsRepo.setLockoutEndTime(0L)
} delay(450L)
onUnlock() onUnlock()
}
} else { } else {
val newAttempts = failedAttempts + 1 val newAttempts = failedAttempts + 1
errorMessage = incorrectPinMsg errorMessage = incorrectPinMsg
@@ -106,10 +112,10 @@ fun LockScreen(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Icon( Icon(
imageVector = Icons.Default.Lock, imageVector = if (isSuccess) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = stringResource(R.string.lock_icon), contentDescription = stringResource(R.string.lock_icon),
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary tint = if (isSuccess) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@@ -122,6 +128,7 @@ fun LockScreen(
PinIndicatorRow( PinIndicatorRow(
filledCount = enteredPin.length, filledCount = enteredPin.length,
isError = errorMessage != null || remainingLockOutTime > 0L, isError = errorMessage != null || remainingLockOutTime > 0L,
isSuccess = isSuccess,
modifier = Modifier.fillMaxWidth(0.8f) modifier = Modifier.fillMaxWidth(0.8f)
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -146,7 +153,7 @@ fun LockScreen(
resetLabel = stringResource(R.string.clear_pin_input), resetLabel = stringResource(R.string.clear_pin_input),
deleteLabel = stringResource(R.string.delete_digit), deleteLabel = stringResource(R.string.delete_digit),
onDigit = { digit -> onDigit = { digit ->
if (remainingLockOutTime > 0L || enteredPin.length >= PIN_LENGTH) { if (remainingLockOutTime > 0L || isSuccess || enteredPin.length >= PIN_LENGTH) {
return@PinNumberPad return@PinNumberPad
} }
val updatedPin = enteredPin + digit val updatedPin = enteredPin + digit
@@ -2,6 +2,7 @@ package cn.airnan.a2fair.ui.settings.security
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -23,21 +24,24 @@ 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.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.core.security.PIN_LENGTH import cn.airnan.a2fair.core.security.PIN_LENGTH
import kotlinx.coroutines.delay
@Composable @Composable
fun PinIndicatorRow( fun PinIndicatorRow(
filledCount: Int, filledCount: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isError: Boolean = false isError: Boolean = false,
isSuccess: Boolean = false
) { ) {
val activeColor = if (isError) { val activeColor = when {
MaterialTheme.colorScheme.error isSuccess -> Color(0xFF4CAF50)
} else { isError -> MaterialTheme.colorScheme.error
MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.primary
} }
val inactiveColor = if (isError) { val inactiveColor = if (isError) {
MaterialTheme.colorScheme.error.copy(alpha = 0.24f) 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( Box(
modifier = Modifier modifier = Modifier
.padding(horizontal = 6.dp) .padding(horizontal = 6.dp)
.size(14.dp) .size(14.dp)
.scale(scale.value) .scale(scale.value)
.clip(CircleShape) .clip(CircleShape)
.background(if (isFilled) activeColor else inactiveColor) .background(if (isFilled || isSuccess) activeColor else inactiveColor)
) )
} }
} }
@@ -13,6 +13,7 @@ import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.core.security.PIN_LENGTH import cn.airnan.a2fair.core.security.PIN_LENGTH
import cn.airnan.a2fair.data.repository.SettingsRepository import cn.airnan.a2fair.data.repository.SettingsRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -26,6 +27,7 @@ fun PinSetupScreen(
var pin by remember { mutableStateOf("") } var pin by remember { mutableStateOf("") }
var confirmPin by remember { mutableStateOf("") } var confirmPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
var isSuccess by remember { mutableStateOf(false) }
val pinTooShortMsg = stringResource(R.string.pin_too_short) val pinTooShortMsg = stringResource(R.string.pin_too_short)
val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match) val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match)
@@ -71,7 +73,8 @@ fun PinSetupScreen(
PinIndicatorRow( PinIndicatorRow(
filledCount = currentPin.length, filledCount = currentPin.length,
isError = errorMessage != null isError = errorMessage != null,
isSuccess = isSuccess
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -94,7 +97,7 @@ fun PinSetupScreen(
}, },
deleteLabel = stringResource(R.string.delete_digit), deleteLabel = stringResource(R.string.delete_digit),
onDigit = { digit -> onDigit = { digit ->
if (currentPin.length >= PIN_LENGTH) return@PinNumberPad if (isSuccess || currentPin.length >= PIN_LENGTH) return@PinNumberPad
if (step == 1) { if (step == 1) {
pin += digit pin += digit
} else { } else {
@@ -143,8 +146,10 @@ fun PinSetupScreen(
if (confirmPin.length != PIN_LENGTH) { if (confirmPin.length != PIN_LENGTH) {
errorMessage = pinTooShortMsg errorMessage = pinTooShortMsg
} else if (pin == confirmPin) { } else if (pin == confirmPin) {
isSuccess = true
scope.launch { scope.launch {
settingsRepository.enablePin(pin) settingsRepository.enablePin(pin)
delay(450L)
onNavigateBack() onNavigateBack()
} }
} else { } else {
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -21,6 +22,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cn.airnan.a2fair.R import cn.airnan.a2fair.R
import cn.airnan.a2fair.core.security.PIN_LENGTH import cn.airnan.a2fair.core.security.PIN_LENGTH
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable @Composable
fun SecurityVerificationDialog( fun SecurityVerificationDialog(
@@ -31,11 +34,17 @@ fun SecurityVerificationDialog(
) { ) {
var enteredPin by remember { mutableStateOf("") } var enteredPin by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
var isSuccess by remember { mutableStateOf(false) }
val incorrectPinMessage = stringResource(R.string.incorrect_pin) val incorrectPinMessage = stringResource(R.string.incorrect_pin)
val scope = rememberCoroutineScope()
fun submitPin(candidate: String) { fun submitPin(candidate: String) {
if (!expectedPin.isNullOrEmpty() && candidate == expectedPin) { if (!expectedPin.isNullOrEmpty() && candidate == expectedPin) {
isSuccess = true
scope.launch {
delay(450L)
onVerified() onVerified()
}
} else { } else {
enteredPin = "" enteredPin = ""
errorMessage = incorrectPinMessage errorMessage = incorrectPinMessage
@@ -61,6 +70,7 @@ fun SecurityVerificationDialog(
PinIndicatorRow( PinIndicatorRow(
filledCount = enteredPin.length, filledCount = enteredPin.length,
isError = errorMessage != null, isError = errorMessage != null,
isSuccess = isSuccess,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -82,7 +92,7 @@ fun SecurityVerificationDialog(
resetLabel = stringResource(R.string.clear_pin_input), resetLabel = stringResource(R.string.clear_pin_input),
deleteLabel = stringResource(R.string.delete_digit), deleteLabel = stringResource(R.string.delete_digit),
onDigit = { digit -> onDigit = { digit ->
if (enteredPin.length >= PIN_LENGTH) return@PinNumberPad if (isSuccess || enteredPin.length >= PIN_LENGTH) return@PinNumberPad
val updatedPin = enteredPin + digit val updatedPin = enteredPin + digit
enteredPin = updatedPin enteredPin = updatedPin
errorMessage = null errorMessage = null
@@ -105,6 +115,7 @@ fun SecurityVerificationDialog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp), .padding(top = 4.dp),
isEnabled = !isSuccess,
isDeleteEnabled = enteredPin.isNotEmpty(), isDeleteEnabled = enteredPin.isNotEmpty(),
isResetEnabled = enteredPin.isNotEmpty() isResetEnabled = enteredPin.isNotEmpty()
) )