diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8456dac..e76973a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ android:supportsRtl="true" android:theme="@style/Theme.LivingAi_Lg" android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="31"> = runCatching { + // Trim and validate code (ensure no whitespace, exactly 6 digits) + val trimmedCode = code.trim() + + // Debug: Log the request details + android.util.Log.d("AuthApiClient", "Verify OTP Request:") + android.util.Log.d("AuthApiClient", " phone_number: $phoneNumber") + android.util.Log.d("AuthApiClient", " code (original): '$code' (length: ${code.length})") + android.util.Log.d("AuthApiClient", " code (trimmed): '$trimmedCode' (length: ${trimmedCode.length})") + android.util.Log.d("AuthApiClient", " device_id: $deviceId") + + // Create request object with trimmed code + val request = VerifyOtpRequest(phoneNumber, trimmedCode, deviceId, getDeviceInfo()) + val response: VerifyOtpResponse = client.post("auth/verify-otp") { contentType(ContentType.Application.Json) - setBody(VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo())) + // Send code as string - backend validation requires string type and bcrypt.compare needs string + setBody(request) }.body() tokenManager.saveTokens(response.accessToken, response.refreshToken) response } + suspend fun signup(request: SignupRequest): Result = runCatching { + val response = client.post("auth/signup") { + contentType(ContentType.Application.Json) + setBody(request.copy(deviceId = getDeviceId(), deviceInfo = getDeviceInfo())) + } + + // Handle both success and error responses + if (response.status.isSuccess()) { + response.body() + } else { + // Try to parse error response as SignupResponse (for 409 conflicts with user_exists flag) + try { + response.body() + } catch (e: Exception) { + // If parsing fails, throw an exception with the status and message + val errorBody = response.bodyAsText() + throw Exception("Signup failed: ${response.status} - $errorBody") + } + } + } + suspend fun updateProfile(name: String, userType: String): Result = runCatching { client.put("users/me") { contentType(ContentType.Application.Json) @@ -115,4 +154,8 @@ class AuthApiClient(private val context: Context) { timezone = TimeZone.getDefault().id ) } + + private fun getDeviceId(): String { + return Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + } } 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 aabacc7..62a213e 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 @@ -16,7 +16,22 @@ class AuthManager( val deviceId = getDeviceId() return apiClient.verifyOtp(phoneNumber, code, deviceId) .onSuccess { response -> - tokenManager.saveTokens(response.accessToken, response.refreshToken) + response.accessToken?.let { accessToken -> + response.refreshToken?.let { refreshToken -> + tokenManager.saveTokens(accessToken, refreshToken) + } + } + } + } + + suspend fun signup(signupRequest: SignupRequest): Result { + return apiClient.signup(signupRequest) + .onSuccess { response -> + response.accessToken?.let { accessToken -> + response.refreshToken?.let { refreshToken -> + tokenManager.saveTokens(accessToken, refreshToken) + } + } } } 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 3738a9d..bb3f331 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 @@ -23,7 +23,7 @@ data class DeviceInfo( @Serializable data class VerifyOtpRequest( @SerialName("phone_number") val phoneNumber: String, - val code: String, + val code: String, // Changed from Int to String - backend expects string for bcrypt comparison @SerialName("device_id") val deviceId: String, @SerialName("device_info") val deviceInfo: DeviceInfo? = null ) @@ -37,6 +37,34 @@ data class VerifyOtpResponse( ) // endregion +// region: Signup +@Serializable +data class SignupRequest( + val name: String, + @SerialName("phone_number") val phoneNumber: String, + val state: String? = null, + val district: String? = null, + @SerialName("city_village") val cityVillage: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("device_info") val deviceInfo: DeviceInfo? = null +) + +@Serializable +data class SignupResponse( + val success: Boolean, + val user: User? = null, + @SerialName("access_token") val accessToken: String? = null, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("needs_profile") val needsProfile: Boolean? = null, + @SerialName("is_new_account") val isNewAccount: Boolean? = null, + @SerialName("is_new_device") val isNewDevice: Boolean? = null, + @SerialName("active_devices_count") val activeDevicesCount: Int? = null, + @SerialName("location_id") val locationId: String? = null, + val message: String? = null, + @SerialName("user_exists") val userExists: Boolean? = null +) +// endregion + // region: Token Refresh @Serializable data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) @@ -59,9 +87,11 @@ data class UpdateProfileRequest( data class User( val id: String, @SerialName("phone_number") val phoneNumber: String, - val name: String?, - val role: String, - @SerialName("user_type") val userType: String? + val name: String? = null, + val role: String? = null, // Made nullable - backend can return null for new users + @SerialName("user_type") val userType: String? = null, // Made nullable with default - backend may not return this + @SerialName("created_at") val createdAt: String? = null, + @SerialName("country_code") val countryCode: String? = null ) @Serializable diff --git a/app/src/main/java/com/example/livingai_lg/ui/login_legacy/signup.md b/app/src/main/java/com/example/livingai_lg/ui/login_legacy/signup.md new file mode 100644 index 0000000..ba7f781 --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/ui/login_legacy/signup.md @@ -0,0 +1,192 @@ +# Signup API Endpoint + +## POST /auth/signup + +Creates a new user account with name, phone number, and optional location information. + +### Request Body + +```json +{ + "name": "John Doe", // Required: User's name (string, max 100 chars) + "phone_number": "+919876543210", // Required: Phone number in E.164 format + "state": "Maharashtra", // Optional: State name (string, max 100 chars) + "district": "Mumbai", // Optional: District name (string, max 100 chars) + "city_village": "Andheri", // Optional: City/Village name (string, max 150 chars) + "device_id": "device-123", // Optional: Device identifier + "device_info": { // Optional: Device information + "platform": "android", + "model": "Samsung Galaxy S21", + "os_version": "Android 13", + "app_version": "1.0.0", + "language_code": "en", + "timezone": "Asia/Kolkata" + } +} +``` + +### Success Response (201 Created) + +```json +{ + "success": true, + "user": { + "id": "uuid-here", + "phone_number": "+919876543210", + "name": "John Doe", + "country_code": "+91", + "created_at": "2024-01-15T10:30:00Z" + }, + "access_token": "jwt-access-token", + "refresh_token": "jwt-refresh-token", + "needs_profile": true, + "is_new_account": true, + "is_new_device": true, + "active_devices_count": 1, + "location_id": "uuid-of-location" // null if no location provided +} +``` + +### Error Responses + +#### 400 Bad Request - Validation Error +```json +{ + "error": "name is required" +} +``` + +#### 409 Conflict - User Already Exists +```json +{ + "success": false, + "message": "User with this phone number already exists. Please sign in instead.", + "user_exists": true +} +``` + +#### 403 Forbidden - IP Blocked +```json +{ + "success": false, + "message": "Access denied from this location." +} +``` + +#### 500 Internal Server Error +```json +{ + "success": false, + "message": "Internal server error" +} +``` + +### Features + +1. **User Existence Check**: Automatically checks if a user with the phone number already exists +2. **Phone Number Encryption**: Phone numbers are encrypted before storing in database +3. **Location Creation**: If state/district/city_village provided, creates a location entry +4. **Token Issuance**: Automatically issues access and refresh tokens +5. **Device Tracking**: Records device information for security +6. **Audit Logging**: Logs signup events for security monitoring + +### Example Usage + +#### cURL +```bash +curl -X POST http://localhost:3000/auth/signup \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "phone_number": "+919876543210", + "state": "Maharashtra", + "district": "Mumbai", + "city_village": "Andheri", + "device_id": "android-device-123", + "device_info": { + "platform": "android", + "model": "Samsung Galaxy S21", + "os_version": "Android 13" + } + }' +``` + +#### JavaScript/TypeScript +```javascript +const response = await fetch('/auth/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'John Doe', + phone_number: '+919876543210', + state: 'Maharashtra', + district: 'Mumbai', + city_village: 'Andheri', + device_id: 'android-device-123', + device_info: { + platform: 'android', + model: 'Samsung Galaxy S21', + os_version: 'Android 13' + } + }) +}); + +const data = await response.json(); +if (data.success) { + // Store tokens + localStorage.setItem('access_token', data.access_token); + localStorage.setItem('refresh_token', data.refresh_token); +} +``` + +#### Kotlin/Android +```kotlin +data class SignupRequest( + val name: String, + val phone_number: String, + val state: String? = null, + val district: String? = null, + val city_village: String? = null, + val device_id: String? = null, + val device_info: Map? = null +) + +data class SignupResponse( + val success: Boolean, + val user: User, + val access_token: String, + val refresh_token: String, + val needs_profile: Boolean, + val is_new_account: Boolean, + val is_new_device: Boolean, + val active_devices_count: Int, + val location_id: String? +) + +// Usage +val request = SignupRequest( + name = "John Doe", + phone_number = "+919876543210", + state = "Maharashtra", + district = "Mumbai", + city_village = "Andheri", + device_id = getDeviceId(), + device_info = mapOf( + "platform" to "android", + "model" to Build.MODEL, + "os_version" to Build.VERSION.RELEASE + ) +) + +val response = apiClient.post("/auth/signup", request) +``` + +### Notes + +- Phone number must be in E.164 format (e.g., `+919876543210`) +- If phone number is 10 digits without `+`, it will be normalized to `+91` prefix +- Location fields are optional - user can be created without location +- If user already exists, returns 409 Conflict with `user_exists: true` +- All phone numbers are encrypted in the database for security +- Country code is automatically extracted from phone number + 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 6db3550..fe1426c 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -74,6 +75,9 @@ object AppScreen { fun otp(phone: String, name: String) = "$OTP/$phone/$name" + + fun otpWithSignup(phone: String, name: String, state: String, district: String, village: String) = + "$OTP/$phone/$name/$state/$district/$village" fun createProfile(name: String) = "$CREATE_PROFILE/$name" @@ -109,25 +113,30 @@ fun AppNavigation( authState: AuthState ) { val navController = rememberNavController() - var isLoggedIn = false; - - when (authState) { - is AuthState.Unauthenticated -> {isLoggedIn = false; } - is AuthState.Authenticated -> {isLoggedIn = true;} - is AuthState.Unknown -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } - } - + + // Always start with AUTH (landing screen) - this ensures LandingScreen opens first + // We'll navigate to MAIN only if user is authenticated (handled by LaunchedEffect) NavHost( navController = navController, - startDestination = if (isLoggedIn) Graph.MAIN else Graph.AUTH + startDestination = Graph.AUTH ) { authNavGraph(navController) mainNavGraph(navController) } + + // Navigate to MAIN graph if user is authenticated + LaunchedEffect(authState) { + if (authState is AuthState.Authenticated) { + val currentRoute = navController.currentBackStackEntry?.destination?.route + // Only navigate if we're not already in the MAIN graph + if (currentRoute?.startsWith(Graph.MAIN) != true) { + navController.navigate(Graph.MAIN) { + // Clear back stack to prevent going back to auth screens + popUpTo(Graph.AUTH) { inclusive = true } + } + } + } + } // MainNavGraph(navController) // AuthNavGraph(navController) // when (authState) { 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 3ca3016..fe7c09d 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 @@ -1,71 +1,192 @@ -package com.example.livingai_lg.ui.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.navigation -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -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 -import com.example.livingai_lg.ui.screens.auth.SignInScreen -import com.example.livingai_lg.ui.screens.auth.SignUpScreen - -fun NavGraphBuilder.authNavGraph(navController: NavController) { - navigation( - route = Graph.AUTH, - startDestination = AppScreen.LANDING - ) { - composable(AppScreen.LANDING) { - LandingScreen( - onSignUpClick = { navController.navigate(AppScreen.SIGN_UP) }, - onSignInClick = { navController.navigate(AppScreen.SIGN_IN) }, - onGuestClick = { navController.navigate(Graph.main(AppScreen.createProfile("guest"))) } - ) - } - - composable(AppScreen.SIGN_IN) { - SignInScreen( - onSignUpClick = { navController.navigate(AppScreen.SIGN_UP){ - popUpTo(AppScreen.SIGN_IN) { inclusive = true } - } }, - onSignInClick = { phone, name -> - navController.navigate(AppScreen.otp(phone,name)) - } - ) - } - - composable(AppScreen.SIGN_UP) { - SignUpScreen( - onSignUpClick = { phone, name -> - navController.navigate(AppScreen.otp(phone,name)) - }, - onSignInClick = { - navController.navigate(AppScreen.SIGN_IN) { - popUpTo(AppScreen.SIGN_UP) { inclusive = true } - } - } - ) - } - - composable( - "${AppScreen.OTP}/{phoneNumber}/{name}", - arguments = listOf( - navArgument("phoneNumber") { type = NavType.StringType }, - navArgument("name") { type = NavType.StringType } - ) - ) { backStackEntry -> - OtpScreen( - phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "", - name = backStackEntry.arguments?.getString("name") ?: "", - onCreateProfile = {name -> navController.navigate(Graph.main(AppScreen.createProfile(name)))}, - onSuccess = { navController.navigate(Graph.auth(AppScreen.chooseService("1")))} - ) - } - } -} +package com.example.livingai_lg.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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 +import com.example.livingai_lg.ui.screens.auth.SignInScreen +import com.example.livingai_lg.ui.screens.auth.SignUpScreen + +fun NavGraphBuilder.authNavGraph(navController: NavController) { + navigation( + route = Graph.AUTH, + startDestination = AppScreen.LANDING + ) { + composable(AppScreen.LANDING) { + LandingScreen( + onSignUpClick = { navController.navigate(AppScreen.SIGN_UP) }, + onSignInClick = { navController.navigate(AppScreen.SIGN_IN) }, + onGuestClick = { navController.navigate(Graph.main(AppScreen.createProfile("guest"))) } + ) + } + + composable(AppScreen.SIGN_IN) { + SignInScreen( + onSignUpClick = { navController.navigate(AppScreen.SIGN_UP){ + popUpTo(AppScreen.SIGN_IN) { inclusive = true } + } }, + onSignInClick = { phone, name -> + navController.navigate(AppScreen.otp(phone,name)) + } + ) + } + + composable(AppScreen.SIGN_UP) { + SignUpScreen( + onSignUpClick = { phone, name, state, district, village -> + navController.navigate(AppScreen.otpWithSignup(phone, name, state, district, village)) + }, + onSignInClick = { + navController.navigate(AppScreen.SIGN_IN) { + popUpTo(AppScreen.SIGN_UP) { inclusive = true } + } + } + ) + } + + composable( + "${AppScreen.OTP}/{phoneNumber}/{name}", + arguments = listOf( + navArgument("phoneNumber") { type = NavType.StringType }, + navArgument("name") { type = NavType.StringType } + ) + ) { backStackEntry -> + OtpScreen( + phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "", + name = backStackEntry.arguments?.getString("name") ?: "", + onCreateProfile = {name -> + android.util.Log.d("AuthNavGraph", "Navigating to create profile with name: $name") + // Navigate to main graph first without popping, then navigate to specific route + 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 + launchSingleTop = true + } + // Then navigate to the specific route after a short delay + CoroutineScope(Dispatchers.Main).launch { + delay(200) + try { + navController.navigate(AppScreen.createProfile(name)) { + // 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) + } + }, + onSuccess = { + android.util.Log.d("AuthNavGraph", "Navigating to choose service") + // Navigate to main graph first without popping, then navigate to specific route + 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 + 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) + } + } + ) + } + + composable( + "${AppScreen.OTP}/{phoneNumber}/{name}/{state}/{district}/{village}", + arguments = listOf( + navArgument("phoneNumber") { type = NavType.StringType }, + navArgument("name") { type = NavType.StringType }, + navArgument("state") { type = NavType.StringType }, + navArgument("district") { type = NavType.StringType }, + navArgument("village") { type = NavType.StringType } + ) + ) { backStackEntry -> + OtpScreen( + phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "", + name = backStackEntry.arguments?.getString("name") ?: "", + signupState = backStackEntry.arguments?.getString("state"), + signupDistrict = backStackEntry.arguments?.getString("district"), + signupVillage = backStackEntry.arguments?.getString("village"), + onCreateProfile = {name -> + android.util.Log.d("AuthNavGraph", "Navigating to create profile with name: $name") + // Navigate to main graph first without popping, then navigate to specific route + 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 + launchSingleTop = true + } + // Then navigate to the specific route after a short delay + CoroutineScope(Dispatchers.Main).launch { + delay(200) + try { + navController.navigate(AppScreen.createProfile(name)) { + // 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) + } + }, + onSuccess = { + android.util.Log.d("AuthNavGraph", "Navigating to choose service") + // Navigate to main graph first without popping, then navigate to specific route + 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 + 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 ecb63ca..3d09a54 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 @@ -44,7 +44,7 @@ fun NavGraphBuilder.mainNavGraph(navController: NavController) { navigation( route = Graph.MAIN, - startDestination = AppScreen.createProfile("guest") + startDestination = AppScreen.BUY_ANIMALS ){ 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 89c145b..a3a61fc 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 @@ -1,325 +1,436 @@ -package com.example.livingai_lg.ui.screens.auth - -import android.widget.Toast -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -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.ui.components.backgrounds.DecorativeBackground -import kotlin.math.min -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.input.key.* -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.unit.Dp -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 - - -@Composable -fun OtpScreen( - phoneNumber: String, - name: String, - onSuccess: () -> Unit = {}, - onCreateProfile: (name: String) -> Unit = {}, -) { - val otp = remember { mutableStateOf("") } - val context = LocalContext.current.applicationContext - val scope = rememberCoroutineScope() - val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) } - - // Flag to determine if this is a sign-in flow for an existing user. - val isSignInFlow = name == "existing_user" - - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - listOf( - Color(0xFFFFFBEA), - Color(0xFFFDFBE8), - Color(0xFFF7FEE7) - ) - ) - ) - ) { - DecorativeBackground() - - val screenW = maxWidth.value - val screenH = maxHeight.value - - // Figma design reference size from Flutter widget - val designW = 393.39f - val designH = 852.53f - - val scale = min(screenW / designW, screenH / designH) - - fun s(v: Float) = (v * scale).dp // dp scaling - fun fs(v: Float) = (v * scale).sp // font scaling -Column( - Modifier.fillMaxSize().padding(horizontal = 12.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally -) { - // --------------------------- - // "Enter OTP" Title - // --------------------------- - Text( - text = "Enter OTP", - color = Color(0xFF927B5E), - fontSize = fs(20f), - fontWeight = FontWeight.Medium, - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - textAlign = TextAlign.Center, - style = LocalTextStyle.current.copy( - shadow = Shadow( - color = Color.Black.copy(alpha = 0.25f), - offset = Offset(0f, s(4f).value), - blurRadius = s(4f).value - ) - ) - ) - - // --------------------------- - // OTP 4-Box Input Row - // --------------------------- - Row( - Modifier.fillMaxWidth().padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(s(17f)) - ) { - OtpInputRow( - otpLength = 6, - scale = scale, - otp = otp.value, - onOtpChange = { if (it.length <= 6) otp.value = it } - ) - } - // --------------------------- - // Continue Button - // --------------------------- - Box( - modifier = Modifier - .fillMaxWidth().padding(vertical = 16.dp, horizontal = 48.dp) - .size(s(279.25f), s(55.99f)) - .shadow( - elevation = s(6f), - ambientColor = Color.Black.copy(alpha = 0.10f), - shape = RoundedCornerShape(s(16f)), - ) - .shadow( - elevation = s(15f), - ambientColor = Color.Black.copy(alpha = 0.10f), - shape = RoundedCornerShape(s(16f)), - ) - .background( - Brush.horizontalGradient( - listOf(Color(0xFFFD9900), Color(0xFFE17100)) - ), - shape = RoundedCornerShape(s(16f)) - ) - .clickable( - indication = LocalIndication.current, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - scope.launch { - authManager.login(phoneNumber, otp.value) - .onSuccess { response -> - if (isSignInFlow) { - // For existing users, always go to the success screen. - onSuccess() - } else { - // For new users, check if a profile needs to be created. - if (response.needsProfile) { - onCreateProfile(name) - } else { - onSuccess() - } - } - } - .onFailure { - Toast.makeText( - context, - "Invalid or expired OTP", - Toast.LENGTH_SHORT - ).show() - } - } - } - ), - contentAlignment = Alignment.Center - ) { - Text( - "Continue", - color = Color.White, - fontSize = fs(16f), - fontWeight = FontWeight.Medium - ) - } -} - } -} - -@Composable -fun OtpInputRow( - otpLength: Int, - scale: Float, - otp: String, - onOtpChange: (String) -> Unit -) { - BoxWithConstraints( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - val maxRowWidth = maxWidth - - val spacing = (12f * scale).dp - val totalSpacing = spacing * (otpLength - 1) - - val boxWidth = ((maxRowWidth - totalSpacing) / otpLength) - .coerceAtMost((66f * scale).dp) - - val focusRequesters = remember { - List(otpLength) { FocusRequester() } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalAlignment = Alignment.CenterVertically - ) { - repeat(otpLength) { index -> - OtpBox( - index = index, - otp = otp, - scale = scale, - width = boxWidth, // 👈 fixed width - focusRequester = focusRequesters[index], - onRequestFocus = { - val firstEmpty = otp.length.coerceAtMost(otpLength - 1) - focusRequesters[firstEmpty].requestFocus() - }, - onNextFocus = { - if (index + 1 < otpLength) focusRequesters[index + 1].requestFocus() - }, - onPrevFocus = { - if (index - 1 >= 0) focusRequesters[index - 1].requestFocus() - }, - onChange = onOtpChange - ) - } - } - } -} - - - - -@Composable -private fun OtpBox( - index: Int, - otp: String, - scale: Float, - width: Dp, - focusRequester: FocusRequester, - onRequestFocus: () -> Unit, - onNextFocus: () -> Unit, - onPrevFocus: () -> Unit, - onChange: (String) -> Unit -) { - val boxH = 52f * scale - val radius = 16f * scale - - val char = otp.getOrNull(index)?.toString() ?: "" - - Box( - modifier = Modifier - .size(width, boxH.dp) - .shadow((4f * scale).dp, RoundedCornerShape(radius.dp)) - .background(Color.White, RoundedCornerShape(radius.dp)) - .clickable { onRequestFocus() }, - contentAlignment = Alignment.Center - ) { - BasicTextField( - value = char, - onValueChange = { new -> - when { - // DIGIT ENTERED - new.matches(Regex("\\d")) -> { - val updated = otp.padEnd(index + 1, ' ').toMutableList() - updated[index] = new.first() - onChange(updated.joinToString("").trim()) - onNextFocus() - } - - // BACKSPACE WHEN CHARACTER EXISTS - new.isEmpty() && char.isNotEmpty() -> { - val updated = otp.toMutableList() - updated.removeAt(index) - onChange(updated.joinToString("")) - } - } - }, - modifier = Modifier - .focusRequester(focusRequester) - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown && - event.key == Key.Backspace && - char.isEmpty() && - index > 0 - ) { - val updated = otp.toMutableList() - updated.removeAt(index - 1) // 👈 clear previous box - onChange(updated.joinToString("")) - onPrevFocus() - true - } - else { - false - } - }, - textStyle = LocalTextStyle.current.copy( - fontSize = (24f * scale).sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword - ), - singleLine = true - ) - - - - } -} - - - - +package com.example.livingai_lg.ui.screens.auth + +import android.widget.Toast +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.SignupRequest +import com.example.livingai_lg.api.TokenManager +import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground +import kotlin.math.min +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.Dp +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 + + +@Composable +fun OtpScreen( + phoneNumber: String, + name: String, + onSuccess: () -> Unit = {}, + onCreateProfile: (name: String) -> Unit = {}, + // Optional signup data for signup flow + signupState: String? = null, + signupDistrict: String? = null, + signupVillage: String? = null, +) { + val otp = remember { mutableStateOf("") } + val context = LocalContext.current.applicationContext + val scope = rememberCoroutineScope() + val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) } + + // Flag to determine if this is a sign-in flow for an existing user. + val isSignInFlow = name == "existing_user" + // Flag to determine if this is a signup flow (has signup data) + val isSignupFlow = !isSignInFlow && (signupState != null || signupDistrict != null || signupVillage != null) + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf( + Color(0xFFFFFBEA), + Color(0xFFFDFBE8), + Color(0xFFF7FEE7) + ) + ) + ) + ) { + DecorativeBackground() + + val screenW = maxWidth.value + val screenH = maxHeight.value + + // Figma design reference size from Flutter widget + val designW = 393.39f + val designH = 852.53f + + val scale = min(screenW / designW, screenH / designH) + + fun s(v: Float) = (v * scale).dp // dp scaling + fun fs(v: Float) = (v * scale).sp // font scaling +Column( + Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally +) { + // --------------------------- + // "Enter OTP" Title + // --------------------------- + Text( + text = "Enter OTP", + color = Color(0xFF927B5E), + fontSize = fs(20f), + fontWeight = FontWeight.Medium, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + textAlign = TextAlign.Center, + style = LocalTextStyle.current.copy( + shadow = Shadow( + color = Color.Black.copy(alpha = 0.25f), + offset = Offset(0f, s(4f).value), + blurRadius = s(4f).value + ) + ) + ) + + // --------------------------- + // OTP 4-Box Input Row + // --------------------------- + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(s(17f)) + ) { + OtpInputRow( + otpLength = 6, + scale = scale, + otp = otp.value, + onOtpChange = { if (it.length <= 6) otp.value = it } + ) + } + // --------------------------- + // Continue Button + // --------------------------- + Box( + modifier = Modifier + .fillMaxWidth().padding(vertical = 16.dp, horizontal = 48.dp) + .size(s(279.25f), s(55.99f)) + .shadow( + elevation = s(6f), + ambientColor = Color.Black.copy(alpha = 0.10f), + shape = RoundedCornerShape(s(16f)), + ) + .shadow( + elevation = s(15f), + ambientColor = Color.Black.copy(alpha = 0.10f), + shape = RoundedCornerShape(s(16f)), + ) + .background( + Brush.horizontalGradient( + listOf(Color(0xFFFD9900), Color(0xFFE17100)) + ), + shape = RoundedCornerShape(s(16f)) + ) + .clickable( + indication = LocalIndication.current, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + scope.launch { + if (isSignupFlow) { + // For signup flow: Verify OTP first, then call signup API to update user with name/location + android.util.Log.d("OTPScreen", "Signup flow: Verifying OTP...") + authManager.login(phoneNumber, otp.value) + .onSuccess { verifyResponse -> + android.util.Log.d("OTPScreen", "OTP verified successfully. Calling signup API...") + // OTP verified successfully - user is now logged in + // Now call signup API to update user with name and location + val signupRequest = SignupRequest( + name = name, + phoneNumber = phoneNumber, + state = signupState, + district = signupDistrict, + cityVillage = signupVillage + ) + authManager.signup(signupRequest) + .onSuccess { signupResponse -> + android.util.Log.d("OTPScreen", "Signup API response: success=${signupResponse.success}, userExists=${signupResponse.userExists}, needsProfile=${signupResponse.needsProfile}") + // Signup API response - check if successful or user exists + if (signupResponse.success || signupResponse.userExists == true) { + // Success - user is created/updated and logged in + // Check if profile needs completion + val needsProfile = signupResponse.needsProfile == true || verifyResponse.needsProfile + android.util.Log.d("OTPScreen", "Signup successful. needsProfile=$needsProfile, navigating...") + 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() + } + } catch (e: Exception) { + android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) + Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show() + } + } else { + // Signup failed but OTP was verified - user is logged in + // 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 + 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() + } + } catch (e: Exception) { + android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) + Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show() + } + // Show warning if signup update failed + val errorMsg = signupResponse.message + if (errorMsg != null) { + Toast.makeText(context, "Profile update: $errorMsg", Toast.LENGTH_SHORT).show() + } + } + } + .onFailure { signupError -> + android.util.Log.e("OTPScreen", "Signup API failed: ${signupError.message}", signupError) + // Signup API failed but OTP was verified - user is logged in + // Navigate to success anyway since verify-otp succeeded + val needsProfile = verifyResponse.needsProfile + android.util.Log.d("OTPScreen", "Navigating despite signup failure. needsProfile=$needsProfile") + 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() + } + } catch (e: Exception) { + android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e) + Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show() + } + // Show warning about signup failure + val errorMsg = signupError.message ?: "Profile update failed" + Toast.makeText(context, "Warning: $errorMsg", Toast.LENGTH_SHORT).show() + } + } + .onFailure { error -> + android.util.Log.e("OTPScreen", "OTP verification failed: ${error.message}", error) + Toast.makeText( + context, + "Invalid or expired OTP", + Toast.LENGTH_SHORT + ).show() + } + } else { + // For sign-in flow: Just verify OTP and login + authManager.login(phoneNumber, otp.value) + .onSuccess { response -> + android.util.Log.d("OTPScreen", "Sign-in OTP verified. needsProfile=${response.needsProfile}") + 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() + } + } + .onFailure { error -> + android.util.Log.e("OTPScreen", "OTP verification failed: ${error.message}", error) + Toast.makeText( + context, + "Invalid or expired OTP", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + ), + contentAlignment = Alignment.Center + ) { + Text( + "Continue", + color = Color.White, + fontSize = fs(16f), + fontWeight = FontWeight.Medium + ) + } +} + } +} + +@Composable +fun OtpInputRow( + otpLength: Int, + scale: Float, + otp: String, + onOtpChange: (String) -> Unit +) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val maxRowWidth = maxWidth + + val spacing = (12f * scale).dp + val totalSpacing = spacing * (otpLength - 1) + + val boxWidth = ((maxRowWidth - totalSpacing) / otpLength) + .coerceAtMost((66f * scale).dp) + + val focusRequesters = remember { + List(otpLength) { FocusRequester() } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(otpLength) { index -> + OtpBox( + index = index, + otp = otp, + scale = scale, + width = boxWidth, // 👈 fixed width + focusRequester = focusRequesters[index], + onRequestFocus = { + val firstEmpty = otp.length.coerceAtMost(otpLength - 1) + focusRequesters[firstEmpty].requestFocus() + }, + onNextFocus = { + if (index + 1 < otpLength) focusRequesters[index + 1].requestFocus() + }, + onPrevFocus = { + if (index - 1 >= 0) focusRequesters[index - 1].requestFocus() + }, + onChange = onOtpChange + ) + } + } + } +} + + + + +@Composable +private fun OtpBox( + index: Int, + otp: String, + scale: Float, + width: Dp, + focusRequester: FocusRequester, + onRequestFocus: () -> Unit, + onNextFocus: () -> Unit, + onPrevFocus: () -> Unit, + onChange: (String) -> Unit +) { + val boxH = 52f * scale + val radius = 16f * scale + + val char = otp.getOrNull(index)?.toString() ?: "" + + Box( + modifier = Modifier + .size(width, boxH.dp) + .shadow((4f * scale).dp, RoundedCornerShape(radius.dp)) + .background(Color.White, RoundedCornerShape(radius.dp)) + .clickable { onRequestFocus() }, + contentAlignment = Alignment.Center + ) { + BasicTextField( + value = char, + onValueChange = { new -> + when { + // DIGIT ENTERED + new.matches(Regex("\\d")) -> { + val updated = otp.padEnd(index + 1, ' ').toMutableList() + updated[index] = new.first() + onChange(updated.joinToString("").trim()) + onNextFocus() + } + + // BACKSPACE WHEN CHARACTER EXISTS + new.isEmpty() && char.isNotEmpty() -> { + val updated = otp.toMutableList() + updated.removeAt(index) + onChange(updated.joinToString("")) + } + } + }, + modifier = Modifier + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + event.key == Key.Backspace && + char.isEmpty() && + index > 0 + ) { + val updated = otp.toMutableList() + updated.removeAt(index - 1) // 👈 clear previous box + onChange(updated.joinToString("")) + onPrevFocus() + true + } + else { + false + } + }, + textStyle = LocalTextStyle.current.copy( + fontSize = (24f * scale).sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ), + singleLine = true + ) + + + + } +} + + + + 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 ea4dd49..2214eb2 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 @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp 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.SignupRequest import com.example.livingai_lg.api.TokenManager import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground import com.example.livingai_lg.ui.components.DropdownInput @@ -52,7 +53,7 @@ private fun String.isValidPhoneNumber(): Boolean { @Composable fun SignUpScreen( onSignInClick: () -> Unit = {}, - onSignUpClick: (phone: String, name: String) -> Unit = {_,_->} + onSignUpClick: (phone: String, name: String, state: String, district: String, village: String) -> Unit = {_,_,_,_,_->} ) { var formData by remember { mutableStateOf(SignUpFormData()) } @@ -168,9 +169,12 @@ fun SignUpScreen( onClick = { val fullPhoneNumber = "+91${formData.phoneNumber}" scope.launch { + // Request OTP first before allowing signup authManager.requestOtp(fullPhoneNumber) .onSuccess { - onSignUpClick(fullPhoneNumber,formData.name) + // 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, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show() diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..10ace14 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + + + + + + + + +