Upadated COde with authentication working

This commit is contained in:
Chandresh Kerkar 2025-12-19 23:24:09 +05:30
parent 6628daed50
commit 078bc02c36
11 changed files with 961 additions and 418 deletions

View File

@ -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">
<activity
android:name=".MainActivity"

View File

@ -2,6 +2,7 @@ package com.example.livingai_lg.api
import android.content.Context
import android.os.Build
import android.provider.Settings
import com.example.livingai_lg.BuildConfig
import io.ktor.client.*
import io.ktor.client.call.*
@ -11,6 +12,7 @@ import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
@ -28,6 +30,8 @@ class AuthApiClient(private val context: Context) {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
coerceInputValues = true // Coerce missing fields to default values
encodeDefaults = false
})
}
@ -74,15 +78,50 @@ class AuthApiClient(private val context: Context) {
}
suspend fun verifyOtp(phoneNumber: String, code: String, deviceId: String): Result<VerifyOtpResponse> = 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<SignupResponse> = 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<SignupResponse>()
} else {
// Try to parse error response as SignupResponse (for 409 conflicts with user_exists flag)
try {
response.body<SignupResponse>()
} 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<User> = 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)
}
}

View File

@ -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<SignupResponse> {
return apiClient.signup(signupRequest)
.onSuccess { response ->
response.accessToken?.let { accessToken ->
response.refreshToken?.let { refreshToken ->
tokenManager.saveTokens(accessToken, refreshToken)
}
}
}
}

View File

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

View File

@ -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<String, String?>? = 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<SignupResponse>("/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

View File

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

View File

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

View File

@ -44,7 +44,7 @@ fun NavGraphBuilder.mainNavGraph(navController: NavController) {
navigation(
route = Graph.MAIN,
startDestination = AppScreen.createProfile("guest")
startDestination = AppScreen.BUY_ANIMALS
){

View File

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

View File

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

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext traffic for localhost/emulator (10.0.2.2) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
<!-- For production, use HTTPS only -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>