优化应用锁交互动画
This commit is contained in:
@@ -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,13 +89,30 @@ class MainActivity : FragmentActivity() {
|
|||||||
dynamicColor = dynamicColor,
|
dynamicColor = dynamicColor,
|
||||||
seedColor = seedColor
|
seedColor = seedColor
|
||||||
) {
|
) {
|
||||||
if (isLocked) {
|
val animDuration = 300
|
||||||
LockScreen(
|
AnimatedContent(
|
||||||
onUnlock = { appLockManager.unlock() },
|
targetState = isLocked,
|
||||||
modifier = Modifier.fillMaxSize()
|
transitionSpec = {
|
||||||
)
|
if (targetState) {
|
||||||
} else {
|
// Lock screen fades in (app fades out)
|
||||||
AppNavigation()
|
(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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+13
-2
@@ -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) {
|
||||||
onVerified()
|
isSuccess = true
|
||||||
|
scope.launch {
|
||||||
|
delay(450L)
|
||||||
|
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()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user