From 88161f79339e90a4dbffd68bb0eee285353ea855 Mon Sep 17 00:00:00 2001 From: Chandresh Kerkar Date: Sat, 20 Dec 2025 02:08:12 +0530 Subject: [PATCH] Correctly working with correct routes --- .../example/livingai_lg/api/AuthApiClient.kt | 57 ++++- .../example/livingai_lg/api/AuthManager.kt | 4 + .../example/livingai_lg/api/TokenManager.kt | 2 +- .../com/example/livingai_lg/api/models.kt | 22 ++ .../example/livingai_lg/ui/MainViewModel.kt | 237 ++++++++++++++++-- .../ui/navigation/AppNavigation.kt | 64 ++++- .../livingai_lg/ui/navigation/AuthNavGraph.kt | 41 +-- .../livingai_lg/ui/navigation/MainNavGraph.kt | 2 +- .../livingai_lg/ui/screens/auth/OTPScreen.kt | 110 +++++--- .../ui/screens/auth/SignInScreen.kt | 33 ++- .../ui/screens/auth/SignUpScreen.kt | 51 +++- 11 files changed, 504 insertions(+), 119 deletions(-) diff --git a/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt b/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt index de710f3..4db0ae4 100644 --- a/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt +++ b/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt @@ -19,6 +19,12 @@ import kotlinx.serialization.json.Json import java.util.Locale import java.util.TimeZone +// Custom exception for user not found +class UserNotFoundException( + message: String, + val errorCode: String +) : Exception(message) + class AuthApiClient(private val context: Context) { private val tokenManager = TokenManager(context) @@ -33,6 +39,7 @@ class AuthApiClient(private val context: Context) { }) } + install(Auth) { bearer { loadTokens { @@ -68,6 +75,36 @@ class AuthApiClient(private val context: Context) { // --- API Calls --- + suspend fun checkUser(phoneNumber: String): Result = runCatching { + val response = client.post("auth/check-user") { + contentType(ContentType.Application.Json) + setBody(CheckUserRequest(phoneNumber)) + } + + if (response.status.isSuccess()) { + // Success - parse as CheckUserResponse + response.body() + } else { + // Error - parse as ErrorResponse + val errorResponse = try { + response.body() + } catch (e: Exception) { + // If parsing fails, create default error + ErrorResponse( + success = false, + error = "USER_NOT_FOUND", + message = "User is not registered. Please sign up to create a new account.", + userExists = false + ) + } + + throw UserNotFoundException( + message = errorResponse.message ?: "User is not registered. Please sign up to create a new account.", + errorCode = errorResponse.error ?: "USER_NOT_FOUND" + ) + } + } + suspend fun requestOtp(phoneNumber: String): Result = runCatching { client.post("auth/request-otp") { contentType(ContentType.Application.Json) @@ -76,13 +113,23 @@ class AuthApiClient(private val context: Context) { } suspend fun verifyOtp(phoneNumber: String, code: String, deviceId: String): Result = runCatching { - val response: VerifyOtpResponse = client.post("auth/verify-otp") { + val response = client.post("auth/verify-otp") { contentType(ContentType.Application.Json) setBody(VerifyOtpRequest(phoneNumber, code.toInt(), deviceId, getDeviceInfo())) - }.body() - - tokenManager.saveTokens(response.accessToken, response.refreshToken) - response + } + + if (response.status.isSuccess()) { + val verifyResponse: VerifyOtpResponse = response.body() + tokenManager.saveTokens(verifyResponse.accessToken, verifyResponse.refreshToken) + verifyResponse + } else { + // Parse error response + val errorResponse: ErrorResponse = response.body() + throw UserNotFoundException( + message = errorResponse.message ?: "User not found", + errorCode = errorResponse.error ?: "USER_NOT_FOUND" + ) + } } suspend fun signup(request: SignupRequest): Result = runCatching { diff --git a/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt b/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt index faa9004..72271da 100644 --- a/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt +++ b/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt @@ -8,6 +8,10 @@ class AuthManager( private val apiClient: AuthApiClient, private val tokenManager: TokenManager ) { + suspend fun checkUser(phoneNumber: String): Result { + return apiClient.checkUser(phoneNumber) + } + suspend fun requestOtp(phoneNumber: String): Result { return apiClient.requestOtp(phoneNumber) } diff --git a/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt b/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt index 73a28ce..4267933 100644 --- a/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt +++ b/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt @@ -28,7 +28,7 @@ class TokenManager(context: Context) { prefs.edit() .putString(KEY_ACCESS_TOKEN, accessToken) .putString(KEY_REFRESH_TOKEN, refreshToken) - .apply() + .commit() // Use commit() instead of apply() to ensure tokens are saved synchronously } fun getAccessToken(): String? = prefs.getString(KEY_ACCESS_TOKEN, null) diff --git a/app/src/main/java/com/example/livingai_lg/api/models.kt b/app/src/main/java/com/example/livingai_lg/api/models.kt index 333447a..03ddeb1 100644 --- a/app/src/main/java/com/example/livingai_lg/api/models.kt +++ b/app/src/main/java/com/example/livingai_lg/api/models.kt @@ -118,3 +118,25 @@ data class UserDetails( @Serializable data class LogoutResponse(val ok: Boolean) // endregion + +// region: Error Responses +@Serializable +data class ErrorResponse( + val success: Boolean? = null, + val error: String? = null, + val message: String? = null, + @SerialName("user_exists") val userExists: Boolean? = null +) +// endregion + +// region: User Check +@Serializable +data class CheckUserRequest(@SerialName("phone_number") val phoneNumber: String) + +@Serializable +data class CheckUserResponse( + val success: Boolean, + val message: String? = null, + @SerialName("user_exists") val userExists: Boolean +) +// endregion diff --git a/app/src/main/java/com/example/livingai_lg/ui/MainViewModel.kt b/app/src/main/java/com/example/livingai_lg/ui/MainViewModel.kt index d484622..f153dfc 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/MainViewModel.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/MainViewModel.kt @@ -37,15 +37,152 @@ class MainViewModel(context: Context) : ViewModel() { val userState = _userState.asStateFlow() init { - checkAuthStatus() + // Immediately check if tokens exist (synchronous check) + val hasTokens = tokenManager.getAccessToken() != null && tokenManager.getRefreshToken() != null + if (hasTokens) { + // Tokens exist, validate them asynchronously + checkAuthStatus() + } else { + // No tokens, immediately set to unauthenticated + _authState.value = AuthState.Unauthenticated + } } /** * Public method to refresh auth status after login/signup * Call this after tokens are saved to update the auth state + * + * This method optimistically sets authState to Authenticated if tokens exist, + * then validates in the background. This ensures immediate navigation. + * + * IMPORTANT: After optimistic authentication, we only revert to Unauthenticated + * if there's a clear authentication failure (not network errors). */ fun refreshAuthStatus() { - checkAuthStatus() + viewModelScope.launch { + val accessToken = tokenManager.getAccessToken() + val refreshToken = tokenManager.getRefreshToken() + + Log.d(TAG, "refreshAuthStatus: accessToken=${accessToken != null}, refreshToken=${refreshToken != null}") + + if (accessToken != null && refreshToken != null) { + // Optimistically set to Authenticated for immediate navigation + // Then validate in background + Log.d(TAG, "Setting authState to Authenticated (optimistic)") + _authState.value = AuthState.Authenticated + // Validate tokens in background (this will only revert if there's a clear auth failure) + validateTokensOptimistic() + } else { + Log.d(TAG, "No tokens found, setting authState to Unauthenticated") + _authState.value = AuthState.Unauthenticated + } + } + } + + /** + * Validates tokens after optimistic authentication. + * Only reverts authState if there's a clear authentication failure (not network errors). + * This prevents users from being logged out due to temporary network issues. + */ + private fun validateTokensOptimistic() { + viewModelScope.launch { + // Try to fetch user details first - Ktor's Auth plugin will auto-refresh if access token is expired + authApiClient.getUserDetails() + .onSuccess { userDetails -> + // Tokens are valid, user is authenticated + Log.d(TAG, "Token validation successful - user authenticated") + _authState.value = AuthState.Authenticated + _userState.value = UserState.Success(userDetails) + } + .onFailure { error -> + // Check if this is a network error or authentication error + val isNetworkError = error.message?.contains("Unable to resolve host", ignoreCase = true) == true + || error.message?.contains("timeout", ignoreCase = true) == true + || error.message?.contains("network", ignoreCase = true) == true + || error.message?.contains("connection", ignoreCase = true) == true + || error.message?.contains("SocketTimeoutException", ignoreCase = true) == true + || error.message?.contains("ConnectException", ignoreCase = true) == true + || error.message?.contains("UnknownHostException", ignoreCase = true) == true + + if (isNetworkError) { + // Network error - keep optimistic authentication state + // User might be offline, tokens are still valid + Log.w(TAG, "Network error during token validation (keeping optimistic auth): ${error.message}") + _userState.value = UserState.Error("Network error. Please check your connection.") + // Keep authState as Authenticated - don't revert on network errors + return@launch + } + + // Check if it's a clear authentication error (401, 403, or specific auth error messages) + val isAuthError = error.message?.contains("401", ignoreCase = true) == true + || error.message?.contains("403", ignoreCase = true) == true + || error.message?.contains("Unauthorized", ignoreCase = true) == true + || error.message?.contains("Forbidden", ignoreCase = true) == true + || error.message?.contains("Invalid token", ignoreCase = true) == true + || error.message?.contains("expired token", ignoreCase = true) == true + || error.message?.contains("Invalid refresh token", ignoreCase = true) == true + + if (isAuthError) { + // Clear authentication error - try refresh as last resort + Log.d(TAG, "Authentication error detected, attempting token refresh: ${error.message}") + + authApiClient.refreshToken() + .onSuccess { refreshResponse -> + // Refresh successful, now try fetching user details again + Log.d(TAG, "Token refresh successful, fetching user details") + authApiClient.getUserDetails() + .onSuccess { userDetails -> + _authState.value = AuthState.Authenticated + _userState.value = UserState.Success(userDetails) + } + .onFailure { fetchError -> + // Even after refresh, fetching failed - check if network error + val isFetchNetworkError = fetchError.message?.contains("Unable to resolve host", ignoreCase = true) == true + || fetchError.message?.contains("timeout", ignoreCase = true) == true + || error.message?.contains("network", ignoreCase = true) == true + + if (isFetchNetworkError) { + // Network error - keep optimistic auth + Log.w(TAG, "Network error after refresh (keeping optimistic auth): ${fetchError.message}") + _userState.value = UserState.Error("Network error. Please check your connection.") + // Keep authState as Authenticated + } else { + // Clear auth failure - revert to unauthenticated + Log.e(TAG, "Authentication failed after refresh: ${fetchError.message}") + tokenManager.clearTokens() + _authState.value = AuthState.Unauthenticated + _userState.value = UserState.Error("Session expired. Please sign in again.") + } + } + } + .onFailure { refreshError -> + // Check if refresh failed due to network or auth + val isRefreshNetworkError = refreshError.message?.contains("Unable to resolve host", ignoreCase = true) == true + || refreshError.message?.contains("timeout", ignoreCase = true) == true + || refreshError.message?.contains("network", ignoreCase = true) == true + + if (isRefreshNetworkError) { + // Network error - keep optimistic auth + Log.w(TAG, "Network error during refresh (keeping optimistic auth): ${refreshError.message}") + _userState.value = UserState.Error("Network error. Please check your connection.") + // Keep authState as Authenticated + } else { + // Clear auth failure - revert to unauthenticated + Log.e(TAG, "Token refresh failed (reverting auth): ${refreshError.message}") + tokenManager.clearTokens() + _authState.value = AuthState.Unauthenticated + _userState.value = UserState.Error("Session expired. Please sign in again.") + } + } + } else { + // Unknown error - be conservative and keep optimistic auth + // Don't log out user for unknown errors + Log.w(TAG, "Unknown error during validation (keeping optimistic auth): ${error.message}") + _userState.value = UserState.Error("Validation error: ${error.message}") + // Keep authState as Authenticated + } + } + } } private fun checkAuthStatus() { @@ -66,40 +203,94 @@ class MainViewModel(context: Context) : ViewModel() { private fun validateTokens() { viewModelScope.launch { - // Try to fetch user details - this will validate the access token - // If access token is expired, Ktor's Auth plugin will auto-refresh + // Try to fetch user details first - Ktor's Auth plugin will auto-refresh if access token is expired authApiClient.getUserDetails() .onSuccess { userDetails -> // Tokens are valid, user is authenticated + Log.d(TAG, "User authenticated successfully") _authState.value = AuthState.Authenticated _userState.value = UserState.Success(userDetails) } .onFailure { error -> + // Check if this is a network error or authentication error + val isNetworkError = error.message?.contains("Unable to resolve host", ignoreCase = true) == true + || error.message?.contains("timeout", ignoreCase = true) == true + || error.message?.contains("network", ignoreCase = true) == true + || error.message?.contains("connection", ignoreCase = true) == true + + if (isNetworkError) { + // Network error - don't clear tokens, don't change auth state + // User might be offline, tokens are still valid + // Keep current auth state (Unknown) so navigation doesn't change + Log.w(TAG, "Network error during token validation: ${error.message}") + _userState.value = UserState.Error("Network error. Please check your connection.") + // Don't change auth state - keep it as Unknown so we can retry later + // Don't clear tokens - they might still be valid + return@launch + } + // If fetching user details failed, try manual refresh - Log.d(TAG, "Failed to fetch user details, attempting token refresh: ${error.message}") - attemptTokenRefresh() + // This handles cases where Ktor's auto-refresh might not have worked + Log.d(TAG, "Failed to fetch user details (${error.message}), attempting manual token refresh") + + // Try manual refresh as fallback + authApiClient.refreshToken() + .onSuccess { refreshResponse -> + // Refresh successful, now try fetching user details again + Log.d(TAG, "Token refresh successful, fetching user details") + authApiClient.getUserDetails() + .onSuccess { userDetails -> + _authState.value = AuthState.Authenticated + _userState.value = UserState.Success(userDetails) + } + .onFailure { fetchError -> + // Check if this is also a network error + val isFetchNetworkError = fetchError.message?.contains("Unable to resolve host", ignoreCase = true) == true + || fetchError.message?.contains("timeout", ignoreCase = true) == true + || fetchError.message?.contains("network", ignoreCase = true) == true + + if (isFetchNetworkError) { + // Network error - don't clear tokens, keep auth state + Log.w(TAG, "Network error after refresh: ${fetchError.message}") + _userState.value = UserState.Error("Network error. Please check your connection.") + // Don't change auth state - keep it as Unknown + } else { + // Even after refresh, fetching user details failed - likely auth error + Log.e(TAG, "Failed to fetch user details after refresh: ${fetchError.message}") + tokenManager.clearTokens() + _authState.value = AuthState.Unauthenticated + _userState.value = UserState.Error("Session expired. Please sign in again.") + } + } + } + .onFailure { refreshError -> + // Check if refresh failed due to network or auth + val isRefreshNetworkError = refreshError.message?.contains("Unable to resolve host", ignoreCase = true) == true + || refreshError.message?.contains("timeout", ignoreCase = true) == true + || refreshError.message?.contains("network", ignoreCase = true) == true + + if (isRefreshNetworkError) { + // Network error - don't clear tokens, keep auth state + Log.w(TAG, "Network error during token refresh: ${refreshError.message}") + _userState.value = UserState.Error("Network error. Please check your connection.") + // Don't change auth state - keep it as Unknown + } else { + // Refresh failed - tokens are invalid or expired (auth error) + Log.d(TAG, "Token refresh failed (auth error): ${refreshError.message}") + tokenManager.clearTokens() + _authState.value = AuthState.Unauthenticated + _userState.value = UserState.Error("Session expired. Please sign in again.") + } + } } } } + // This method is no longer needed as validateTokens() now handles refresh directly + // Keeping it for backward compatibility but it's not used + @Deprecated("Use validateTokens() instead") private fun attemptTokenRefresh() { - viewModelScope.launch { - authApiClient.refreshToken() - .onSuccess { refreshResponse -> - // Refresh successful, tokens are valid - Log.d(TAG, "Token refresh successful") - _authState.value = AuthState.Authenticated - // Fetch user details with new token - fetchUserDetails() - } - .onFailure { error -> - // Refresh failed, tokens are invalid - clear and logout - Log.d(TAG, "Token refresh failed: ${error.message}") - tokenManager.clearTokens() - _authState.value = AuthState.Unauthenticated - _userState.value = UserState.Error("Session expired. Please sign in again.") - } - } + validateTokens() } fun fetchUserDetails() { diff --git a/app/src/main/java/com/example/livingai_lg/ui/navigation/AppNavigation.kt b/app/src/main/java/com/example/livingai_lg/ui/navigation/AppNavigation.kt index de5f9c7..1bae8a9 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/navigation/AppNavigation.kt @@ -17,6 +17,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.NavType import androidx.navigation.navArgument +import kotlinx.coroutines.delay import com.example.livingai_lg.ui.AuthState import com.example.livingai_lg.ui.screens.AnimalProfileScreen import com.example.livingai_lg.ui.screens.BuyScreen @@ -117,44 +118,81 @@ fun AppNavigation( ) { val navController = rememberNavController() - // Always start with AUTH (landing screen) - this ensures LandingScreen opens first - // We'll navigate to MAIN only if user is authenticated (handled by LaunchedEffect) + // Determine start destination based on initial auth state + // This prevents showing landing screen when user is already logged in + val startDestination = remember(authState) { + when (authState) { + is AuthState.Authenticated -> Graph.MAIN + is AuthState.Unauthenticated -> Graph.AUTH + is AuthState.Unknown -> Graph.AUTH // Show landing while checking + } + } + NavHost( navController = navController, - startDestination = Graph.AUTH + startDestination = startDestination ) { authNavGraph(navController, mainViewModel) mainNavGraph(navController) } - // Handle navigation based on auth state + // Handle navigation based on auth state changes LaunchedEffect(authState) { + android.util.Log.d("AppNavigation", "LaunchedEffect triggered with authState: $authState") when (authState) { is AuthState.Authenticated -> { - // User is authenticated, navigate to main graph + // User is authenticated, navigate to ChooseServiceScreen + // Add a small delay to ensure NavHost graphs are fully built + delay(100) + val currentRoute = navController.currentBackStackEntry?.destination?.route - // Only navigate if we're not already in the MAIN graph + android.util.Log.d("AppNavigation", "Authenticated - currentRoute: $currentRoute") + // Only navigate if we're not already in the MAIN graph or ChooseServiceScreen if (currentRoute?.startsWith(Graph.MAIN) != true && - currentRoute?.startsWith(Graph.AUTH) == true) { - navController.navigate(Graph.MAIN) { - // Clear back stack to prevent going back to auth screens - popUpTo(Graph.AUTH) { inclusive = true } + currentRoute?.startsWith(AppScreen.CHOOSE_SERVICE) != true) { + android.util.Log.d("AppNavigation", "Navigating to ChooseServiceScreen (default profileId: 1)") + try { + // Navigate directly to the start destination route of MAIN graph + // This avoids the "Sequence is empty" error when navigating to Graph.MAIN + navController.navigate(AppScreen.chooseService("1")) { + // Clear back stack to prevent going back to auth screens + popUpTo(Graph.AUTH) { inclusive = true } + // Prevent multiple navigations + launchSingleTop = true + } + } catch (e: Exception) { + android.util.Log.e("AppNavigation", "Navigation error: ${e.message}", e) + // Fallback: try navigating to Graph.MAIN if direct route fails + try { + navController.navigate(Graph.MAIN) { + popUpTo(Graph.AUTH) { inclusive = true } + launchSingleTop = true + } + } catch (e2: Exception) { + android.util.Log.e("AppNavigation", "Fallback navigation also failed: ${e2.message}", e2) + } } + } else { + android.util.Log.d("AppNavigation", "Already in MAIN graph or ChooseServiceScreen, skipping navigation") } } is AuthState.Unauthenticated -> { // User is not authenticated, ensure we're in auth graph (landing screen) val currentRoute = navController.currentBackStackEntry?.destination?.route - if (currentRoute?.startsWith(Graph.MAIN) == true) { + if (currentRoute?.startsWith(Graph.MAIN) == true || + currentRoute?.startsWith(Graph.AUTH) != true) { navController.navigate(Graph.AUTH) { // Clear back stack to prevent going back to main screens popUpTo(0) { inclusive = true } + launchSingleTop = true } } } is AuthState.Unknown -> { - // Still checking auth status, stay on landing screen - // Don't navigate anywhere yet + // Still checking auth status + // If we're on landing screen, stay there + // If we're on main screen and checking, don't navigate yet + // This prevents flickering during token validation } } } diff --git a/app/src/main/java/com/example/livingai_lg/ui/navigation/AuthNavGraph.kt b/app/src/main/java/com/example/livingai_lg/ui/navigation/AuthNavGraph.kt index 2d5b7fd..115a61e 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/navigation/AuthNavGraph.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/navigation/AuthNavGraph.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +// Note: CoroutineScope, Dispatchers, delay, launch still used in onCreateProfile callbacks import com.example.livingai_lg.ui.screens.SaleArchiveScreen import com.example.livingai_lg.ui.screens.auth.LandingScreen import com.example.livingai_lg.ui.screens.auth.OtpScreen @@ -107,26 +108,14 @@ fun NavGraphBuilder.authNavGraph(navController: NavController, mainViewModel: co } }, onSuccess = { - android.util.Log.d("AuthNavGraph", "Navigating to choose service") - // Navigate to main graph first without popping, then navigate to specific route + android.util.Log.d("AuthNavGraph", "Sign-in successful - navigating to ChooseServiceScreen") + // Navigate to MAIN graph which starts at ChooseServiceScreen (startDestination) try { - // Navigate to main graph (this will use its start destination) navController.navigate(Graph.MAIN) { - // Don't pop the AUTH graph yet - keep the graph structure + // Clear back stack to prevent going back to auth screens + popUpTo(Graph.AUTH) { inclusive = true } launchSingleTop = true } - // Then navigate to the specific route after a short delay - CoroutineScope(Dispatchers.Main).launch { - delay(200) - try { - navController.navigate(AppScreen.chooseService("1")) { - // Now pop the AUTH graph after we're in the MAIN graph - popUpTo(Graph.AUTH) { inclusive = true } - } - } catch (e: Exception) { - android.util.Log.e("AuthNavGraph", "Secondary navigation error: ${e.message}", e) - } - } } catch (e: Exception) { android.util.Log.e("AuthNavGraph", "Navigation error: ${e.message}", e) } @@ -190,26 +179,14 @@ fun NavGraphBuilder.authNavGraph(navController: NavController, mainViewModel: co } }, onSuccess = { - android.util.Log.d("AuthNavGraph", "Navigating to choose service") - // Navigate to main graph first without popping, then navigate to specific route + android.util.Log.d("AuthNavGraph", "Sign-in successful - navigating to ChooseServiceScreen") + // Navigate to MAIN graph which starts at ChooseServiceScreen (startDestination) try { - // Navigate to main graph (this will use its start destination) navController.navigate(Graph.MAIN) { - // Don't pop the AUTH graph yet - keep the graph structure + // Clear back stack to prevent going back to auth screens + popUpTo(Graph.AUTH) { inclusive = true } launchSingleTop = true } - // Then navigate to the specific route after a short delay - CoroutineScope(Dispatchers.Main).launch { - delay(200) - try { - navController.navigate(AppScreen.chooseService("1")) { - // Now pop the AUTH graph after we're in the MAIN graph - popUpTo(Graph.AUTH) { inclusive = true } - } - } catch (e: Exception) { - android.util.Log.e("AuthNavGraph", "Secondary navigation error: ${e.message}", e) - } - } } catch (e: Exception) { android.util.Log.e("AuthNavGraph", "Navigation error: ${e.message}", e) } diff --git a/app/src/main/java/com/example/livingai_lg/ui/navigation/MainNavGraph.kt b/app/src/main/java/com/example/livingai_lg/ui/navigation/MainNavGraph.kt index 7a825c2..fe577ef 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/navigation/MainNavGraph.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/navigation/MainNavGraph.kt @@ -47,7 +47,7 @@ fun NavGraphBuilder.mainNavGraph(navController: NavController) { navigation( route = Graph.MAIN, - startDestination = AppScreen.BUY_ANIMALS + startDestination = AppScreen.chooseService("1") // Default to ChooseServiceScreen for authenticated users ){ diff --git a/app/src/main/java/com/example/livingai_lg/ui/screens/auth/OTPScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/screens/auth/OTPScreen.kt index 1f47563..6ed0591 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/screens/auth/OTPScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/screens/auth/OTPScreen.kt @@ -29,6 +29,7 @@ import com.example.livingai_lg.api.AuthApiClient import com.example.livingai_lg.api.AuthManager import com.example.livingai_lg.api.SignupRequest import com.example.livingai_lg.api.TokenManager +import com.example.livingai_lg.api.UserNotFoundException import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground import kotlin.math.min import androidx.compose.ui.focus.FocusRequester @@ -42,6 +43,7 @@ import kotlinx.coroutines.launch import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent +import kotlinx.coroutines.delay @Composable @@ -61,6 +63,22 @@ fun OtpScreen( val context = LocalContext.current.applicationContext val scope = rememberCoroutineScope() val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) } + + // Countdown timer state (2 minutes = 120 seconds) + var countdownSeconds by remember { mutableStateOf(120) } + + // Start countdown when screen is composed + LaunchedEffect(Unit) { + while (countdownSeconds > 0) { + delay(1000) // Wait 1 second + countdownSeconds-- + } + } + + // Format countdown as MM:SS + val minutes = countdownSeconds / 60 + val seconds = countdownSeconds % 60 + val countdownText = String.format("%02d:%02d", minutes, seconds) // Flag to determine if this is a sign-in flow for an existing user. val isSignInFlow = name == "existing_user" @@ -131,6 +149,28 @@ Column( onOtpChange = { if (it.length <= 6) otp.value = it } ) } + + // --------------------------- + // Countdown Timer + // --------------------------- + Text( + text = countdownText, + color = Color(0xFF927B5E), + fontSize = fs(16f), + fontWeight = FontWeight.Medium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + textAlign = TextAlign.Center, + style = LocalTextStyle.current.copy( + shadow = Shadow( + color = Color.Black.copy(alpha = 0.15f), + offset = Offset(0f, s(2f).value), + blurRadius = s(2f).value + ) + ) + ) + // --------------------------- // Continue Button // --------------------------- @@ -198,13 +238,15 @@ Column( // Check if profile needs completion val needsProfile = signupResponse.needsProfile == true || verifyResponse.needsProfile android.util.Log.d("OTPScreen", "Signup successful. needsProfile=$needsProfile, navigating...") + // Refresh auth status - this will trigger navigation via AppNavigation's LaunchedEffect + mainViewModel.refreshAuthStatus() try { if (needsProfile) { android.util.Log.d("OTPScreen", "Navigating to create profile screen with name: $name") onCreateProfile(name) } else { - android.util.Log.d("OTPScreen", "Navigating to success screen") - onSuccess() + // Don't manually navigate - let AppNavigation handle it + android.util.Log.d("OTPScreen", "Signup successful - auth state updated, navigation will happen automatically") } } catch (e: Exception) { android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) @@ -215,13 +257,15 @@ Column( // Navigate to success anyway since verify-otp succeeded android.util.Log.d("OTPScreen", "Signup API returned false, but OTP verified. Navigating anyway...") val needsProfile = verifyResponse.needsProfile + // Refresh auth status - this will trigger navigation via AppNavigation's LaunchedEffect + mainViewModel.refreshAuthStatus() try { if (needsProfile) { android.util.Log.d("OTPScreen", "Navigating to create profile screen with name: $name") onCreateProfile(name) } else { - android.util.Log.d("OTPScreen", "Navigating to success screen") - onSuccess() + // Don't manually navigate - let AppNavigation handle it + android.util.Log.d("OTPScreen", "Signup successful - auth state updated, navigation will happen automatically") } } catch (e: Exception) { android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) @@ -242,6 +286,8 @@ Column( // For existing users, use existing logic val needsProfile = verifyResponse.needsProfile android.util.Log.d("OTPScreen", "Navigating despite signup failure. needsProfile=$needsProfile") + // Refresh auth status - this will trigger navigation via AppNavigation's LaunchedEffect + mainViewModel.refreshAuthStatus() try { // If this is a signup flow and signup failed, treat as new user if (isSignupFlow) { @@ -251,8 +297,8 @@ Column( android.util.Log.d("OTPScreen", "Navigating to create profile screen with name: $name") onCreateProfile(name) } else { - android.util.Log.d("OTPScreen", "Navigating to success screen") - onSuccess() + // Don't manually navigate - let AppNavigation handle it + android.util.Log.d("OTPScreen", "Signup successful - auth state updated, navigation will happen automatically") } } catch (e: Exception) { android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) @@ -276,35 +322,37 @@ Column( authManager.login(phoneNumber, otp.value) .onSuccess { response -> android.util.Log.d("OTPScreen", "Sign-in OTP verified. needsProfile=${response.needsProfile}") - // Refresh auth status in MainViewModel so AppNavigation knows user is authenticated + // Tokens are now saved (synchronously via commit()) + // Refresh auth status - this will optimistically set authState to Authenticated + // The LaunchedEffect in AppNavigation will automatically navigate to ChooseServiceScreen mainViewModel.refreshAuthStatus() - try { - if (isSignInFlow) { - // For existing users, always go to the success screen. - android.util.Log.d("OTPScreen", "Existing user - navigating to success") - onSuccess() - } else { - // For new users, check if a profile needs to be created. - if (response.needsProfile) { - android.util.Log.d("OTPScreen", "New user needs profile - navigating to create profile with name: $name") - onCreateProfile(name) - } else { - android.util.Log.d("OTPScreen", "New user - navigating to success") - onSuccess() - } - } - } catch (e: Exception) { - android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) - Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show() - } + android.util.Log.d("OTPScreen", "Auth status refreshed - navigation will happen automatically via LaunchedEffect") } .onFailure { error -> android.util.Log.e("OTPScreen", "OTP verification failed: ${error.message}", error) - Toast.makeText( - context, - "Invalid or expired OTP", - Toast.LENGTH_SHORT - ).show() + + // Check if user not found - redirect to signup + if (error is UserNotFoundException && error.errorCode == "USER_NOT_FOUND") { + android.util.Log.d("OTPScreen", "User not found - redirecting to signup") + Toast.makeText( + context, + error.message ?: "Account not found. Please sign up to create a new account.", + Toast.LENGTH_LONG + ).show() + // Navigate back to landing page so user can choose signup + try { + onLanding() + } catch (e: Exception) { + android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) + } + } else { + // Other errors (invalid OTP, expired, etc.) + Toast.makeText( + context, + error.message ?: "Invalid or expired OTP", + Toast.LENGTH_SHORT + ).show() + } } } } diff --git a/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignInScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignInScreen.kt index 91270d6..4ea7edb 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignInScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignInScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.sp import com.example.livingai_lg.api.AuthApiClient import com.example.livingai_lg.api.AuthManager import com.example.livingai_lg.api.TokenManager +import com.example.livingai_lg.api.UserNotFoundException import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground import com.example.livingai_lg.ui.components.PhoneNumberInput @@ -96,12 +97,34 @@ fun SignInScreen( onClick = { val fullPhoneNumber = "+91${phoneNumber.value}" scope.launch { - authManager.requestOtp(fullPhoneNumber) - .onSuccess { - onSignInClick(fullPhoneNumber,"existing_user") + // First check if user exists before requesting OTP + authManager.checkUser(fullPhoneNumber) + .onSuccess { checkResponse -> + if (checkResponse.userExists) { + // User exists, proceed to request OTP + authManager.requestOtp(fullPhoneNumber) + .onSuccess { + onSignInClick(fullPhoneNumber, "existing_user") + } + .onFailure { + Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show() + } + } else { + // User doesn't exist (shouldn't happen if API works correctly) + Toast.makeText(context, "User is not registered. Please sign up.", Toast.LENGTH_LONG).show() + } } - .onFailure { - Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show() + .onFailure { error -> + // User not found or other error + if (error is UserNotFoundException) { + Toast.makeText( + context, + error.message ?: "User is not registered. Please sign up to create a new account.", + Toast.LENGTH_LONG + ).show() + } else { + Toast.makeText(context, "Error: ${error.message}", Toast.LENGTH_LONG).show() + } } } }, diff --git a/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignUpScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignUpScreen.kt index b274c73..8bb0905 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignUpScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/screens/auth/SignUpScreen.kt @@ -169,15 +169,50 @@ fun SignUpScreen( onClick = { scope.launch { val fullPhoneNumber = "+91${formData.phoneNumber}" - // Request OTP first before allowing signup - authManager.requestOtp(fullPhoneNumber) - .onSuccess { - // OTP sent successfully, navigate to OTP screen with signup data - // Pass signup form data through the callback - onSignUpClick(fullPhoneNumber, formData.name, formData.state, formData.district, formData.village) + + // First check if user already exists + authManager.checkUser(fullPhoneNumber) + .onSuccess { checkResponse -> + if (checkResponse.userExists) { + // User already registered - show message to sign in instead + Toast.makeText( + context, + "This phone number is already registered. Please sign in instead.", + Toast.LENGTH_LONG + ).show() + // Optionally navigate to sign in screen + onSignInClick() + } else { + // User doesn't exist - proceed with signup + // Request OTP first before allowing signup + authManager.requestOtp(fullPhoneNumber) + .onSuccess { + // OTP sent successfully, navigate to OTP screen with signup data + // Pass signup form data through the callback + onSignUpClick(fullPhoneNumber, formData.name, formData.state, formData.district, formData.village) + } + .onFailure { + Toast.makeText(context, "Signup failed: ${it.message}", Toast.LENGTH_LONG).show() + } + } } - .onFailure { - Toast.makeText(context, "Signup failed: ${it.message}", Toast.LENGTH_LONG).show() + .onFailure { checkError -> + // If check fails, it might be a network error or user doesn't exist + // Try to proceed with signup (user might not exist) + if (checkError is com.example.livingai_lg.api.UserNotFoundException) { + // User doesn't exist - proceed with signup + authManager.requestOtp(fullPhoneNumber) + .onSuccess { + // OTP sent successfully, navigate to OTP screen with signup data + onSignUpClick(fullPhoneNumber, formData.name, formData.state, formData.district, formData.village) + } + .onFailure { + Toast.makeText(context, "Signup failed: ${it.message}", Toast.LENGTH_LONG).show() + } + } else { + // Other error (network, etc.) + Toast.makeText(context, "Unable to verify phone number: ${checkError.message}", Toast.LENGTH_LONG).show() + } } } },