This commit is contained in:
Trey t
2025-11-05 20:31:01 -06:00
parent fe99f67f81
commit bc289d6c88
17 changed files with 819 additions and 203 deletions

View File

@@ -23,6 +23,7 @@ import com.mycrib.android.ui.screens.RegisterScreen
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 org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
@@ -42,14 +43,58 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
@Preview
fun App() {
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
var isVerified by remember { mutableStateOf(false) }
var isCheckingAuth by remember { mutableStateOf(true) }
val navController = rememberNavController()
// Check for stored token on app start and initialize lookups if logged in
// Check for stored token and verification status on app start
LaunchedEffect(Unit) {
isLoggedIn = TokenStorage.hasToken()
if (isLoggedIn) {
LookupsRepository.initialize()
val hasToken = TokenStorage.hasToken()
isLoggedIn = hasToken
if (hasToken) {
// Fetch current user to check verification status
val authApi = com.mycrib.shared.network.AuthApi()
val token = TokenStorage.getToken()
if (token != null) {
when (val result = authApi.getCurrentUser(token)) {
is com.mycrib.shared.network.ApiResult.Success -> {
isVerified = result.data.verified
LookupsRepository.initialize()
}
else -> {
// If fetching user fails, clear token and logout
TokenStorage.clearToken()
isLoggedIn = false
}
}
}
}
isCheckingAuth = false
}
if (isCheckingAuth) {
// Show loading screen while checking auth
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
androidx.compose.foundation.layout.Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.CircularProgressIndicator()
}
}
return
}
val startDestination = when {
!isLoggedIn -> LoginRoute
!isVerified -> VerifyEmailRoute
else -> ResidencesRoute
}
Surface(
@@ -58,16 +103,25 @@ fun App() {
) {
NavHost(
navController = navController,
startDestination = if (isLoggedIn) ResidencesRoute else LoginRoute
startDestination = startDestination
) {
composable<LoginRoute> {
LoginScreen(
onLoginSuccess = {
onLoginSuccess = { user ->
isLoggedIn = true
isVerified = user.verified
// Initialize lookups after successful login
LookupsRepository.initialize()
navController.navigate(ResidencesRoute) {
popUpTo<LoginRoute> { inclusive = true }
// Check if user is verified
if (user.verified) {
navController.navigate(ResidencesRoute) {
popUpTo<LoginRoute> { inclusive = true }
}
} else {
navController.navigate(VerifyEmailRoute) {
popUpTo<LoginRoute> { inclusive = true }
}
}
},
onNavigateToRegister = {
@@ -80,9 +134,10 @@ fun App() {
RegisterScreen(
onRegisterSuccess = {
isLoggedIn = true
isVerified = false
// Initialize lookups after successful registration
LookupsRepository.initialize()
navController.navigate(ResidencesRoute) {
navController.navigate(VerifyEmailRoute) {
popUpTo<RegisterRoute> { inclusive = true }
}
},
@@ -92,6 +147,27 @@ fun App() {
)
}
composable<VerifyEmailRoute> {
VerifyEmailScreen(
onVerifySuccess = {
isVerified = true
navController.navigate(ResidencesRoute) {
popUpTo<VerifyEmailRoute> { inclusive = true }
}
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<VerifyEmailRoute> { inclusive = true }
}
}
)
}
composable<HomeRoute> {
HomeScreen(
onNavigateToResidences = {
@@ -105,6 +181,7 @@ fun App() {
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true }
}
@@ -125,6 +202,7 @@ fun App() {
TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true }
}

View File

@@ -12,7 +12,8 @@ data class User(
@SerialName("last_name") val lastName: String?,
@SerialName("is_staff") val isStaff: Boolean = false,
@SerialName("is_active") val isActive: Boolean = true,
@SerialName("date_joined") val dateJoined: String
@SerialName("date_joined") val dateJoined: String,
val verified: Boolean = false
)
@Serializable
@@ -47,5 +48,17 @@ data class LoginRequest(
@Serializable
data class AuthResponse(
val token: String,
val user: User
val user: User,
val verified: Boolean = false
)
@Serializable
data class VerifyEmailRequest(
val code: String
)
@Serializable
data class VerifyEmailResponse(
val message: String,
val verified: Boolean
)

View File

@@ -9,6 +9,9 @@ object LoginRoute
@Serializable
object RegisterRoute
@Serializable
object VerifyEmailRoute
@Serializable
object HomeRoute

View File

@@ -4,4 +4,5 @@ sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int? = null) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>()
object Idle : ApiResult<Nothing>()
}

View File

@@ -74,4 +74,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
return try {
val response = client.post("$baseUrl/auth/verify/") {
header("Authorization", "Token $token")
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 "Verification failed")
}
ApiResult.Error(errorBody["error"] ?: "Verification failed", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -126,6 +126,8 @@ fun HomeScreen(
is ApiResult.Error -> {
// Don't show error card, just let navigation cards show
}
else -> {}
}
// Residences Card

View File

@@ -21,7 +21,7 @@ import com.mycrib.shared.network.ApiResult
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onLoginSuccess: (com.mycrib.shared.models.User) -> Unit,
onNavigateToRegister: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
@@ -33,7 +33,8 @@ fun LoginScreen(
LaunchedEffect(loginState) {
when (loginState) {
is ApiResult.Success -> {
onLoginSuccess()
val user = (loginState as ApiResult.Success).data.user
onLoginSuccess(user)
}
else -> {}
}

View File

@@ -547,9 +547,13 @@ fun ResidenceDetailScreen(
}
}
}
else -> {}
}
}
}
else -> {}
}
}
}

View File

@@ -298,6 +298,8 @@ fun ResidencesScreen(
}
}
}
else -> {}
}
}
}

View File

@@ -105,6 +105,8 @@ fun TasksScreen(
}
}
}
else -> {}
}
}
}

View File

@@ -0,0 +1,195 @@
package com.mycrib.android.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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 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.AuthViewModel
import com.mycrib.shared.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyEmailScreen(
onVerifySuccess: () -> Unit,
onLogout: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var code by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val verifyState by viewModel.verifyEmailState.collectAsState()
LaunchedEffect(verifyState) {
when (verifyState) {
is ApiResult.Success -> {
viewModel.resetVerifyEmailState()
onVerifySuccess()
}
is ApiResult.Error -> {
errorMessage = (verifyState as ApiResult.Error).message
isLoading = false
}
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verify Email", fontWeight = FontWeight.SemiBold) },
actions = {
TextButton(onClick = onLogout) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Logout,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Text("Logout")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = "Verify Your Email",
subtitle = "You must verify your email address to continue"
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Email verification is required. Check your inbox for a 6-digit code.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
}
}
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
}
},
label = { Text("Verification Code") },
leadingIcon = {
Icon(Icons.Default.Pin, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = { Text("000000") }
)
if (errorMessage.isNotEmpty()) {
ErrorCard(
message = errorMessage
)
}
Button(
onClick = {
if (code.length == 6) {
isLoading = true
viewModel.verifyEmail(code)
} else {
errorMessage = "Please enter a valid 6-digit code"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading && code.length == 6
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Text(
"Verify Email",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Didn't receive the code? Check your spam folder or contact support.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -6,6 +6,9 @@ import com.mycrib.shared.models.AuthResponse
import com.mycrib.shared.models.LoginRequest
import com.mycrib.shared.models.RegisterRequest
import com.mycrib.shared.models.Residence
import com.mycrib.shared.models.User
import com.mycrib.shared.models.VerifyEmailRequest
import com.mycrib.shared.models.VerifyEmailResponse
import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.AuthApi
import com.mycrib.storage.TokenStorage
@@ -16,12 +19,15 @@ import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() {
private val authApi = AuthApi()
private val _loginState = MutableStateFlow<ApiResult<String>>(ApiResult.Loading)
val loginState: StateFlow<ApiResult<String>> = _loginState
private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Loading)
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState
private val _verifyEmailState = MutableStateFlow<ApiResult<VerifyEmailResponse>>(ApiResult.Idle)
val verifyEmailState: StateFlow<ApiResult<VerifyEmailResponse>> = _verifyEmailState
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
@@ -30,7 +36,7 @@ class AuthViewModel : ViewModel() {
is ApiResult.Success -> {
// Store token for future API calls
TokenStorage.saveToken(result.data.token)
ApiResult.Success(result.data.token)
ApiResult.Success(result.data)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
@@ -61,7 +67,31 @@ class AuthViewModel : ViewModel() {
}
fun resetRegisterState() {
_registerState.value = ApiResult.Loading
_registerState.value = ApiResult.Idle
}
fun verifyEmail(code: String) {
viewModelScope.launch {
_verifyEmailState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
val result = authApi.verifyEmail(
token = token,
request = VerifyEmailRequest(code = code)
)
_verifyEmailState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
} else {
_verifyEmailState.value = ApiResult.Error("Not authenticated")
}
}
}
fun resetVerifyEmailState() {
_verifyEmailState.value = ApiResult.Idle
}
fun logout() {