Add password reset feature for iOS and Android with deep link support
Implemented complete password reset flow with email verification and deep linking: iOS (SwiftUI): - PasswordResetViewModel: Manages 3-step flow (request, verify, reset) with deep link support - ForgotPasswordView: Email entry screen with success/error states - VerifyResetCodeView: 6-digit code verification with resend option - ResetPasswordView: Password reset with live validation and strength indicators - PasswordResetFlow: Container managing navigation between screens - Deep link handling in iOSApp.swift for mycrib://reset-password?token=xxx - Info.plist: Added CFBundleURLTypes for deep link scheme Android (Compose): - PasswordResetViewModel: StateFlow-based state management with coroutines - ForgotPasswordScreen: Material3 email entry with auto-navigation - VerifyResetCodeScreen: Code verification with Material3 design - ResetPasswordScreen: Password reset with live validation checklist - Deep link handling in MainActivity.kt and AndroidManifest.xml - Token cleanup callback to prevent reusing expired deep link tokens - Shared ViewModel scoping across all password reset screens - Improved error handling for Django validation errors Shared: - Routes: Added ForgotPasswordRoute, VerifyResetCodeRoute, ResetPasswordRoute - AuthApi: Enhanced resetPassword with Django validation error parsing - User models: Added password reset request/response models Security features: - Deep link tokens expire after use - Proper token validation on backend - Session invalidation after password change - Password strength requirements enforced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,18 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link for password reset -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="mycrib"
|
||||
android:host="reset-password" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.example.mycrib
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.mycrib.storage.TokenManager
|
||||
import com.mycrib.storage.TokenStorage
|
||||
@@ -12,6 +17,8 @@ import com.mycrib.storage.TaskCacheManager
|
||||
import com.mycrib.storage.TaskCacheStorage
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -22,8 +29,33 @@ class MainActivity : ComponentActivity() {
|
||||
// Initialize TaskCacheStorage for offline task caching
|
||||
TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext))
|
||||
|
||||
// Handle deep link from intent
|
||||
handleDeepLink(intent)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
App(
|
||||
deepLinkResetToken = deepLinkResetToken,
|
||||
onClearDeepLinkToken = {
|
||||
deepLinkResetToken = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleDeepLink(intent)
|
||||
}
|
||||
|
||||
private fun handleDeepLink(intent: Intent?) {
|
||||
val data: Uri? = intent?.data
|
||||
if (data != null && data.scheme == "mycrib" && data.host == "reset-password") {
|
||||
// Extract token from query parameter
|
||||
val token = data.getQueryParameter("token")
|
||||
if (token != null) {
|
||||
deepLinkResetToken = token
|
||||
println("Deep link received with token: $token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,5 +63,5 @@ class MainActivity : ComponentActivity() {
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
App()
|
||||
App(deepLinkResetToken = null)
|
||||
}
|
||||
@@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier
|
||||
import com.mycrib.android.ui.screens.AddResidenceScreen
|
||||
import com.mycrib.android.ui.screens.EditResidenceScreen
|
||||
import com.mycrib.android.ui.screens.EditTaskScreen
|
||||
import com.mycrib.android.ui.screens.ForgotPasswordScreen
|
||||
import com.mycrib.android.ui.screens.HomeScreen
|
||||
import com.mycrib.android.ui.screens.LoginScreen
|
||||
import com.mycrib.android.ui.screens.RegisterScreen
|
||||
import com.mycrib.android.ui.screens.ResetPasswordScreen
|
||||
import com.mycrib.android.ui.screens.ResidenceDetailScreen
|
||||
import com.mycrib.android.ui.screens.ResidencesScreen
|
||||
import com.mycrib.android.ui.screens.TasksScreen
|
||||
import com.mycrib.android.ui.screens.VerifyEmailScreen
|
||||
import com.mycrib.android.ui.screens.VerifyResetCodeScreen
|
||||
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
@@ -53,7 +58,10 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
fun App(
|
||||
deepLinkResetToken: String? = null,
|
||||
onClearDeepLinkToken: () -> Unit = {}
|
||||
) {
|
||||
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
|
||||
var isVerified by remember { mutableStateOf(false) }
|
||||
var isCheckingAuth by remember { mutableStateOf(true) }
|
||||
@@ -105,6 +113,7 @@ fun App() {
|
||||
}
|
||||
|
||||
val startDestination = when {
|
||||
deepLinkResetToken != null -> ForgotPasswordRoute
|
||||
!isLoggedIn -> LoginRoute
|
||||
!isVerified -> VerifyEmailRoute
|
||||
else -> MainRoute
|
||||
@@ -139,6 +148,9 @@ fun App() {
|
||||
},
|
||||
onNavigateToRegister = {
|
||||
navController.navigate(RegisterRoute)
|
||||
},
|
||||
onNavigateToForgotPassword = {
|
||||
navController.navigate(ForgotPasswordRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -160,6 +172,71 @@ fun App() {
|
||||
)
|
||||
}
|
||||
|
||||
composable<ForgotPasswordRoute> { backStackEntry ->
|
||||
// Create shared ViewModel for all password reset screens
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<ForgotPasswordRoute>()
|
||||
}
|
||||
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) {
|
||||
PasswordResetViewModel(deepLinkToken = deepLinkResetToken)
|
||||
}
|
||||
|
||||
ForgotPasswordScreen(
|
||||
onNavigateBack = {
|
||||
// Clear deep link token when navigating back to login
|
||||
onClearDeepLinkToken()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToVerify = {
|
||||
navController.navigate(VerifyResetCodeRoute)
|
||||
},
|
||||
onNavigateToReset = {
|
||||
navController.navigate(ResetPasswordRoute)
|
||||
},
|
||||
viewModel = passwordResetViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable<VerifyResetCodeRoute> { backStackEntry ->
|
||||
// Use shared ViewModel from ForgotPasswordRoute
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<ForgotPasswordRoute>()
|
||||
}
|
||||
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
|
||||
|
||||
VerifyResetCodeScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToReset = {
|
||||
navController.navigate(ResetPasswordRoute)
|
||||
},
|
||||
viewModel = passwordResetViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable<ResetPasswordRoute> { backStackEntry ->
|
||||
// Use shared ViewModel from ForgotPasswordRoute
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<ForgotPasswordRoute>()
|
||||
}
|
||||
val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() }
|
||||
|
||||
ResetPasswordScreen(
|
||||
onPasswordResetSuccess = {
|
||||
// Clear deep link token and navigate back to login after successful password reset
|
||||
onClearDeepLinkToken()
|
||||
navController.navigate(LoginRoute) {
|
||||
popUpTo<ForgotPasswordRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
viewModel = passwordResetViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable<VerifyEmailRoute> {
|
||||
VerifyEmailScreen(
|
||||
onVerifySuccess = {
|
||||
|
||||
@@ -69,3 +69,38 @@ data class UpdateProfileRequest(
|
||||
@SerialName("last_name") val lastName: String? = null,
|
||||
val email: String? = null
|
||||
)
|
||||
|
||||
// Password Reset Models
|
||||
@Serializable
|
||||
data class ForgotPasswordRequest(
|
||||
val email: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForgotPasswordResponse(
|
||||
val message: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerifyResetCodeRequest(
|
||||
val email: String,
|
||||
val code: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerifyResetCodeResponse(
|
||||
val message: String,
|
||||
@SerialName("reset_token") val resetToken: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordRequest(
|
||||
@SerialName("reset_token") val resetToken: String,
|
||||
@SerialName("new_password") val newPassword: String,
|
||||
@SerialName("confirm_password") val confirmPassword: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordResponse(
|
||||
val message: String
|
||||
)
|
||||
|
||||
@@ -85,3 +85,12 @@ object MainTabTasksRoute
|
||||
|
||||
@Serializable
|
||||
object MainTabProfileRoute
|
||||
|
||||
@Serializable
|
||||
object ForgotPasswordRoute
|
||||
|
||||
@Serializable
|
||||
object VerifyResetCodeRoute
|
||||
|
||||
@Serializable
|
||||
object ResetPasswordRoute
|
||||
|
||||
@@ -120,4 +120,84 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// Password Reset Methods
|
||||
suspend fun forgotPassword(request: ForgotPasswordRequest): ApiResult<ForgotPasswordResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/forgot-password/") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorBody = try {
|
||||
response.body<Map<String, String>>()
|
||||
} catch (e: Exception) {
|
||||
mapOf("error" to "Failed to send reset code")
|
||||
}
|
||||
ApiResult.Error(errorBody["error"] ?: "Failed to send reset code", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/verify-reset-code/") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
val errorBody = try {
|
||||
response.body<Map<String, String>>()
|
||||
} catch (e: Exception) {
|
||||
mapOf("error" to "Invalid code")
|
||||
}
|
||||
ApiResult.Error(errorBody["error"] ?: "Invalid code", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/auth/reset-password/") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
// Try to parse Django validation errors (Map<String, List<String>>)
|
||||
val errorMessage = try {
|
||||
val validationErrors = response.body<Map<String, List<String>>>()
|
||||
// Flatten all error messages into a single string
|
||||
validationErrors.flatMap { (field, errors) ->
|
||||
errors.map { error ->
|
||||
if (field == "non_field_errors") error else "$field: $error"
|
||||
}
|
||||
}.joinToString(". ")
|
||||
} catch (e: Exception) {
|
||||
// Try simple error format {error: "message"}
|
||||
try {
|
||||
val simpleError = response.body<Map<String, String>>()
|
||||
simpleError["error"] ?: "Failed to reset password"
|
||||
} catch (e2: Exception) {
|
||||
"Failed to reset password"
|
||||
}
|
||||
}
|
||||
ApiResult.Error(errorMessage, response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVerify: () -> Unit,
|
||||
onNavigateToReset: () -> Unit = {},
|
||||
viewModel: PasswordResetViewModel
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
val forgotPasswordState by viewModel.forgotPasswordState.collectAsState()
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
|
||||
// Handle automatic navigation to next step
|
||||
LaunchedEffect(currentStep) {
|
||||
when (currentStep) {
|
||||
com.mycrib.android.viewmodel.PasswordResetStep.VERIFY_CODE -> onNavigateToVerify()
|
||||
com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD -> onNavigateToReset()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = when (forgotPasswordState) {
|
||||
is ApiResult.Error -> (forgotPasswordState as ApiResult.Error).message
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val isLoading = forgotPasswordState is ApiResult.Loading
|
||||
val isSuccess = forgotPasswordState is ApiResult.Success
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Forgot Password") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
AuthHeader(
|
||||
icon = Icons.Default.Key,
|
||||
title = "Forgot Password?",
|
||||
subtitle = "Enter your email address and we'll send you a code to reset your password"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {
|
||||
email = it
|
||||
viewModel.resetForgotPasswordState()
|
||||
},
|
||||
label = { Text("Email Address") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Email, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Text(
|
||||
"We'll send a 6-digit verification code to this address",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
ErrorCard(message = errorMessage)
|
||||
|
||||
if (isSuccess) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
"Check your email for a 6-digit verification code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setEmail(email)
|
||||
viewModel.requestPasswordReset(email)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = email.isNotEmpty() && !isLoading,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Send, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"Send Reset Code",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Remember your password? Back to Login",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.mycrib.shared.network.ApiResult
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: (com.mycrib.shared.models.User) -> Unit,
|
||||
onNavigateToRegister: () -> Unit,
|
||||
onNavigateToForgotPassword: () -> Unit = {},
|
||||
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
@@ -140,6 +141,17 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNavigateToForgotPassword,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Forgot Password?",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNavigateToRegister,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ResetPasswordScreen(
|
||||
onPasswordResetSuccess: () -> Unit,
|
||||
onNavigateBack: (() -> Unit)? = null,
|
||||
viewModel: PasswordResetViewModel
|
||||
) {
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var newPasswordVisible by remember { mutableStateOf(false) }
|
||||
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val resetPasswordState by viewModel.resetPasswordState.collectAsState()
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
|
||||
val errorMessage = when (resetPasswordState) {
|
||||
is ApiResult.Error -> (resetPasswordState as ApiResult.Error).message
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val isLoading = resetPasswordState is ApiResult.Loading
|
||||
val isSuccess = currentStep == com.mycrib.android.viewmodel.PasswordResetStep.SUCCESS
|
||||
|
||||
// Password validation
|
||||
val hasLetter = newPassword.any { it.isLetter() }
|
||||
val hasNumber = newPassword.any { it.isDigit() }
|
||||
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
|
||||
val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Reset Password") },
|
||||
navigationIcon = {
|
||||
onNavigateBack?.let { callback ->
|
||||
IconButton(onClick = callback) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
if (isSuccess) {
|
||||
// Success State
|
||||
AuthHeader(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
title = "Success!",
|
||||
subtitle = "Your password has been reset successfully"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"You can now log in with your new password",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onPasswordResetSuccess,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
"Return to Login",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Reset Password Form
|
||||
AuthHeader(
|
||||
icon = Icons.Default.LockReset,
|
||||
title = "Set New Password",
|
||||
subtitle = "Create a strong password to secure your account"
|
||||
)
|
||||
|
||||
// Password Requirements
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Password Requirements",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
RequirementItem(
|
||||
"At least 8 characters",
|
||||
newPassword.length >= 8
|
||||
)
|
||||
RequirementItem(
|
||||
"Contains letters",
|
||||
hasLetter
|
||||
)
|
||||
RequirementItem(
|
||||
"Contains numbers",
|
||||
hasNumber
|
||||
)
|
||||
RequirementItem(
|
||||
"Passwords match",
|
||||
passwordsMatch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = newPassword,
|
||||
onValueChange = {
|
||||
newPassword = it
|
||||
viewModel.resetResetPasswordState()
|
||||
},
|
||||
label = { Text("New Password") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Lock, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) {
|
||||
Icon(
|
||||
if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (newPasswordVisible) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (newPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = {
|
||||
confirmPassword = it
|
||||
viewModel.resetResetPasswordState()
|
||||
},
|
||||
label = { Text("Confirm Password") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Lock, contentDescription = null)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||
Icon(
|
||||
if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
ErrorCard(message = errorMessage)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.resetPassword(newPassword, confirmPassword)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = isFormValid && !isLoading,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.LockReset, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"Reset Password",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequirementItem(text: String, satisfied: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
if (satisfied) Icons.Default.CheckCircle else Icons.Default.Circle,
|
||||
contentDescription = null,
|
||||
tint = if (satisfied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (satisfied) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.mycrib.android.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mycrib.android.ui.components.auth.AuthHeader
|
||||
import com.mycrib.android.ui.components.common.ErrorCard
|
||||
import com.mycrib.android.viewmodel.PasswordResetViewModel
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VerifyResetCodeScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToReset: () -> Unit,
|
||||
viewModel: PasswordResetViewModel
|
||||
) {
|
||||
var code by remember { mutableStateOf("") }
|
||||
val email by viewModel.email.collectAsState()
|
||||
val verifyCodeState by viewModel.verifyCodeState.collectAsState()
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
|
||||
// Handle automatic navigation to next step
|
||||
LaunchedEffect(currentStep) {
|
||||
if (currentStep == com.mycrib.android.viewmodel.PasswordResetStep.RESET_PASSWORD) {
|
||||
onNavigateToReset()
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = when (verifyCodeState) {
|
||||
is ApiResult.Error -> (verifyCodeState as ApiResult.Error).message
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val isLoading = verifyCodeState is ApiResult.Loading
|
||||
val isSuccess = verifyCodeState is ApiResult.Success
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Verify Code") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
AuthHeader(
|
||||
icon = Icons.Default.MarkEmailRead,
|
||||
title = "Check Your Email",
|
||||
subtitle = "We sent a 6-digit code to"
|
||||
)
|
||||
|
||||
Text(
|
||||
email,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Timer,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
"Code expires in 15 minutes",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = {
|
||||
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
|
||||
code = it
|
||||
viewModel.resetVerifyCodeState()
|
||||
}
|
||||
},
|
||||
label = { Text("Verification Code") },
|
||||
placeholder = { Text("000000") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
enabled = !isLoading,
|
||||
textStyle = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
"Enter the 6-digit code from your email",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
ErrorCard(message = errorMessage)
|
||||
|
||||
if (isSuccess) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
"Code verified! Now set your new password",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.verifyResetCode(email, code)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = code.length == 6 && !isLoading,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
"Verify Code",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Didn't receive the code?",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
TextButton(onClick = {
|
||||
code = ""
|
||||
viewModel.resetVerifyCodeState()
|
||||
viewModel.moveToPreviousStep()
|
||||
onNavigateBack()
|
||||
}) {
|
||||
Text(
|
||||
"Send New Code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Check your spam folder if you don't see it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.mycrib.android.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mycrib.shared.models.*
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import com.mycrib.shared.network.AuthApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class PasswordResetStep {
|
||||
REQUEST_CODE,
|
||||
VERIFY_CODE,
|
||||
RESET_PASSWORD,
|
||||
SUCCESS
|
||||
}
|
||||
|
||||
class PasswordResetViewModel(
|
||||
private val deepLinkToken: String? = null
|
||||
) : ViewModel() {
|
||||
private val authApi = AuthApi()
|
||||
|
||||
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
|
||||
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
||||
|
||||
private val _verifyCodeState = MutableStateFlow<ApiResult<VerifyResetCodeResponse>>(ApiResult.Idle)
|
||||
val verifyCodeState: StateFlow<ApiResult<VerifyResetCodeResponse>> = _verifyCodeState
|
||||
|
||||
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
|
||||
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
|
||||
|
||||
private val _currentStep = MutableStateFlow(
|
||||
if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE
|
||||
)
|
||||
val currentStep: StateFlow<PasswordResetStep> = _currentStep
|
||||
|
||||
private val _resetToken = MutableStateFlow(deepLinkToken)
|
||||
val resetToken: StateFlow<String?> = _resetToken
|
||||
|
||||
private val _email = MutableStateFlow("")
|
||||
val email: StateFlow<String> = _email
|
||||
|
||||
fun setEmail(email: String) {
|
||||
_email.value = email
|
||||
}
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
viewModelScope.launch {
|
||||
_forgotPasswordState.value = ApiResult.Loading
|
||||
val result = authApi.forgotPassword(ForgotPasswordRequest(email))
|
||||
_forgotPasswordState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_email.value = email
|
||||
// Move to next step after short delay
|
||||
kotlinx.coroutines.delay(1500)
|
||||
_currentStep.value = PasswordResetStep.VERIFY_CODE
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyResetCode(email: String, code: String) {
|
||||
viewModelScope.launch {
|
||||
_verifyCodeState.value = ApiResult.Loading
|
||||
val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code))
|
||||
_verifyCodeState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_resetToken.value = result.data.resetToken
|
||||
// Move to next step after short delay
|
||||
kotlinx.coroutines.delay(1500)
|
||||
_currentStep.value = PasswordResetStep.RESET_PASSWORD
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPassword(newPassword: String, confirmPassword: String) {
|
||||
val token = _resetToken.value
|
||||
if (token == null) {
|
||||
_resetPasswordState.value = ApiResult.Error("Invalid reset token. Please start over.")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_resetPasswordState.value = ApiResult.Loading
|
||||
val result = authApi.resetPassword(
|
||||
ResetPasswordRequest(
|
||||
resetToken = token,
|
||||
newPassword = newPassword,
|
||||
confirmPassword = confirmPassword
|
||||
)
|
||||
)
|
||||
_resetPasswordState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_currentStep.value = PasswordResetStep.SUCCESS
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun moveToNextStep() {
|
||||
_currentStep.value = when (_currentStep.value) {
|
||||
PasswordResetStep.REQUEST_CODE -> PasswordResetStep.VERIFY_CODE
|
||||
PasswordResetStep.VERIFY_CODE -> PasswordResetStep.RESET_PASSWORD
|
||||
PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.SUCCESS
|
||||
PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fun moveToPreviousStep() {
|
||||
_currentStep.value = when (_currentStep.value) {
|
||||
PasswordResetStep.REQUEST_CODE -> PasswordResetStep.REQUEST_CODE
|
||||
PasswordResetStep.VERIFY_CODE -> PasswordResetStep.REQUEST_CODE
|
||||
PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.VERIFY_CODE
|
||||
PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fun resetForgotPasswordState() {
|
||||
_forgotPasswordState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun resetVerifyCodeState() {
|
||||
_verifyCodeState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun resetResetPasswordState() {
|
||||
_resetPasswordState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun resetAll() {
|
||||
_email.value = ""
|
||||
_resetToken.value = null
|
||||
_forgotPasswordState.value = ApiResult.Idle
|
||||
_verifyCodeState.value = ApiResult.Idle
|
||||
_resetPasswordState.value = ApiResult.Idle
|
||||
_currentStep.value = PasswordResetStep.REQUEST_CODE
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user