Upadated COde with authentication working
This commit is contained in:
parent
6628daed50
commit
078bc02c36
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ fun NavGraphBuilder.mainNavGraph(navController: NavController) {
|
|||
|
||||
navigation(
|
||||
route = Graph.MAIN,
|
||||
startDestination = AppScreen.createProfile("guest")
|
||||
startDestination = AppScreen.BUY_ANIMALS
|
||||
){
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue