优化应用锁交互动画
This commit is contained in:
@@ -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,7 +89,23 @@ class MainActivity : FragmentActivity() {
|
||||
dynamicColor = dynamicColor,
|
||||
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(
|
||||
onUnlock = { appLockManager.unlock() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
@@ -96,3 +117,4 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(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()
|
||||
}
|
||||
} 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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(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 {
|
||||
|
||||
+12
-1
@@ -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<String?>(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) {
|
||||
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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user