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:
Trey t
2025-11-09 18:29:29 -06:00
parent e6dc54017b
commit fdcc2a2e16
19 changed files with 2196 additions and 4 deletions

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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 = {

View File

@@ -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
)

View File

@@ -85,3 +85,12 @@ object MainTabTasksRoute
@Serializable
object MainTabProfileRoute
@Serializable
object ForgotPasswordRoute
@Serializable
object VerifyResetCodeRoute
@Serializable
object ResetPasswordRoute

View File

@@ -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")
}
}
}

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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()

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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
}
}