diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5480392..eca23e2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,18 +2,17 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.example.livingai_lg"
- compileSdk {
- version = release(36)
- }
+ compileSdk = 34
defaultConfig {
applicationId = "com.example.livingai_lg"
minSdk = 24
- targetSdk = 36
+ targetSdk = 34
versionCode = 1
versionName = "1.0"
@@ -30,14 +29,15 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
- jvmTarget = "11"
+ jvmTarget = "1.8"
}
buildFeatures {
compose = true
+ buildConfig = true
}
}
@@ -50,7 +50,20 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
- implementation("androidx.navigation:navigation-compose:2.7.7")
+ implementation(libs.androidx.navigation.compose)
+
+ // Ktor
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+
+ // Kotlinx Serialization
+ implementation(libs.kotlinx.serialization.json)
+
+ // AndroidX Security
+ implementation(libs.androidx.security.crypto)
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/how_to_use_Auth.md b/app/how_to_use_Auth.md
new file mode 100644
index 0000000..439b8f4
--- /dev/null
+++ b/app/how_to_use_Auth.md
@@ -0,0 +1,1130 @@
+# Farm Auth Service - API Integration Guide
+
+Complete integration guide for Farm Auth Service with Kotlin Multiplatform Mobile (KMM) or Kotlin Native applications.
+
+## Base URL
+
+```
+http://localhost:3000 (development)
+https://your-domain.com (production)
+```
+
+## Table of Contents
+
+- [Authentication Flow](#authentication-flow)
+- [API Endpoints](#api-endpoints)
+- [Kotlin Multiplatform Implementation](#kotlin-multiplatform-implementation)
+- [Error Handling](#error-handling)
+- [Security Best Practices](#security-best-practices)
+- [Token Management](#token-management)
+
+---
+
+## Authentication Flow
+
+1. **Request OTP** → User enters phone number
+2. **Verify OTP** → User enters code, receives tokens + user info
+3. **Use Access Token** → Include in `Authorization` header for protected endpoints
+4. **Refresh Token** → When access token expires, get new tokens (auto-rotation)
+5. **Logout** → Revoke refresh token on current device
+6. **Device Management** → View/manage active devices
+
+---
+
+## API Endpoints
+
+### 1. Request OTP
+
+**Endpoint:** `POST /auth/request-otp`
+
+**Request:**
+```json
+{
+ "phone_number": "+919876543210"
+}
+```
+
+**Response (200):**
+```json
+{
+ "ok": true
+}
+```
+
+**Error (400):**
+```json
+{
+ "error": "phone_number is required"
+}
+```
+
+**Error (500):**
+```json
+{
+ "error": "Failed to send OTP"
+}
+```
+
+**Phone Number Normalization:**
+- `9876543210` → Automatically becomes `+919876543210` (10-digit assumed as Indian)
+- `+919876543210` → Kept as is
+- Phone numbers should ideally be in E.164 format with `+` prefix
+
+---
+
+### 2. Verify OTP
+
+**Endpoint:** `POST /auth/verify-otp`
+
+**Request:**
+```json
+{
+ "phone_number": "+919876543210",
+ "code": "123456",
+ "device_id": "android-installation-id-123",
+ "device_info": {
+ "platform": "android",
+ "model": "Samsung SM-M326B",
+ "os_version": "Android 14",
+ "app_version": "1.0.0",
+ "language_code": "en-IN",
+ "timezone": "Asia/Kolkata"
+ }
+}
+```
+
+**Response (200):**
+```json
+{
+ "user": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "phone_number": "+919876543210",
+ "name": null,
+ "role": "user",
+ "user_type": null
+ },
+ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "needs_profile": true,
+ "is_new_device": true,
+ "is_new_account": false,
+ "active_devices_count": 2
+}
+```
+
+**Error (400):**
+```json
+{
+ "error": "phone_number and code are required"
+}
+```
+or
+```json
+{
+ "error": "Invalid or expired OTP"
+}
+```
+
+**Notes:**
+- `device_id` is required and will be sanitized (must be 4-128 alphanumeric characters, otherwise hashed)
+- `device_info` is optional but recommended for better device tracking
+- `needs_profile` is `true` if `name` or `user_type` is null
+- `is_new_device` indicates if this device was seen for the first time
+- `is_new_account` indicates if the user account was just created
+- `active_devices_count` shows how many active devices the user has
+
+---
+
+### 3. Refresh Token
+
+**Endpoint:** `POST /auth/refresh`
+
+**Request:**
+```json
+{
+ "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+**Response (200):**
+```json
+{
+ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+**Error (400):**
+```json
+{
+ "error": "refresh_token is required"
+}
+```
+
+**Error (401):**
+```json
+{
+ "error": "Invalid refresh token"
+}
+```
+
+**Important:**
+- Refresh tokens **rotate on each use** - always save the new `refresh_token`
+- Old refresh token is automatically revoked
+- If refresh token is compromised and reused, all tokens for that device are revoked
+
+---
+
+### 4. Logout
+
+**Endpoint:** `POST /auth/logout`
+
+**Request:**
+```json
+{
+ "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+```
+
+**Response (200):**
+```json
+{
+ "ok": true
+}
+```
+
+**Note:** Returns `ok: true` even if token is already invalid (idempotent)
+
+---
+
+### 5. Get Current User Profile
+
+**Endpoint:** `GET /users/me`
+
+**Headers:**
+```
+Authorization: Bearer
+```
+
+**Response (200):**
+```json
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "phone_number": "+919876543210",
+ "name": "John Doe",
+ "role": "user",
+ "user_type": "seller",
+ "created_at": "2024-01-15T10:30:00Z",
+ "last_login_at": "2024-01-20T14:22:00Z",
+ "active_devices_count": 2
+}
+```
+
+**Error (401):**
+```json
+{
+ "error": "Missing Authorization header"
+}
+```
+or
+```json
+{
+ "error": "Invalid or expired token"
+}
+```
+
+---
+
+### 6. Update User Profile
+
+**Endpoint:** `PUT /users/me`
+
+**Headers:**
+```
+Authorization: Bearer
+```
+
+**Request:**
+```json
+{
+ "name": "John Doe",
+ "user_type": "seller"
+}
+```
+
+**Response (200):**
+```json
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "phone_number": "+919876543210",
+ "name": "John Doe",
+ "role": "user",
+ "user_type": "seller"
+}
+```
+
+**Valid `user_type` values:**
+- `seller`
+- `buyer`
+- `service_provider`
+
+**Error (400):**
+```json
+{
+ "error": "name and user_type are required"
+}
+```
+
+---
+
+### 7. List Active Devices
+
+**Endpoint:** `GET /users/me/devices`
+
+**Headers:**
+```
+Authorization: Bearer
+```
+
+**Response (200):**
+```json
+{
+ "devices": [
+ {
+ "device_identifier": "android-installation-id-123",
+ "device_platform": "android",
+ "device_model": "Samsung SM-M326B",
+ "os_version": "Android 14",
+ "app_version": "1.0.0",
+ "language_code": "en-IN",
+ "timezone": "Asia/Kolkata",
+ "first_seen_at": "2024-01-15T10:30:00Z",
+ "last_seen_at": "2024-01-20T14:22:00Z",
+ "is_active": true
+ }
+ ]
+}
+```
+
+---
+
+### 8. Logout Specific Device
+
+**Endpoint:** `DELETE /users/me/devices/:device_id`
+
+**Headers:**
+```
+Authorization: Bearer
+```
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "message": "Device logged out successfully"
+}
+```
+
+**Note:** This will:
+- Mark the device as inactive in `user_devices`
+- Revoke all refresh tokens for that device
+- Log the action in `auth_audit`
+
+---
+
+### 9. Logout All Other Devices
+
+**Endpoint:** `POST /users/me/logout-all-other-devices`
+
+**Headers:**
+```
+Authorization: Bearer
+X-Device-Id:
+```
+
+**Alternative:** Send `current_device_id` in request body:
+```json
+{
+ "current_device_id": "android-installation-id-123"
+}
+```
+
+**Response (200):**
+```json
+{
+ "ok": true,
+ "message": "Logged out 2 device(s)",
+ "revoked_devices_count": 2
+}
+```
+
+**Error (400):**
+```json
+{
+ "error": "current_device_id is required in header or body"
+}
+```
+
+---
+
+### 10. Health Check
+
+**Endpoint:** `GET /health`
+
+**Response (200):**
+```json
+{
+ "ok": true
+}
+```
+
+Use this to verify the service is running before attempting authentication.
+
+---
+
+## Kotlin Multiplatform Implementation
+
+### 1. Data Models (Common Module)
+
+```kotlin
+// commonMain/kotlin/models/Requests.kt
+package com.farm.auth.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RequestOtpRequest(val phone_number: String)
+
+@Serializable
+data class RequestOtpResponse(val ok: Boolean)
+
+@Serializable
+data class DeviceInfo(
+ val platform: String,
+ val model: String? = null,
+ @SerialName("os_version") val osVersion: String? = null,
+ @SerialName("app_version") val appVersion: String? = null,
+ @SerialName("language_code") val languageCode: String? = null,
+ val timezone: String? = null
+)
+
+@Serializable
+data class VerifyOtpRequest(
+ @SerialName("phone_number") val phoneNumber: String,
+ val code: String,
+ @SerialName("device_id") val deviceId: String,
+ @SerialName("device_info") val deviceInfo: DeviceInfo? = null
+)
+
+@Serializable
+data class User(
+ val id: String,
+ @SerialName("phone_number") val phoneNumber: String,
+ val name: String?,
+ val role: String,
+ @SerialName("user_type") val userType: String?,
+ @SerialName("created_at") val createdAt: String? = null,
+ @SerialName("last_login_at") val lastLoginAt: String? = null,
+ @SerialName("active_devices_count") val activeDevicesCount: Int? = null
+)
+
+@Serializable
+data class VerifyOtpResponse(
+ val user: User,
+ @SerialName("access_token") val accessToken: String,
+ @SerialName("refresh_token") val refreshToken: String,
+ @SerialName("needs_profile") val needsProfile: Boolean,
+ @SerialName("is_new_device") val isNewDevice: Boolean,
+ @SerialName("is_new_account") val isNewAccount: Boolean,
+ @SerialName("active_devices_count") val activeDevicesCount: Int
+)
+
+@Serializable
+data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
+
+@Serializable
+data class RefreshResponse(
+ @SerialName("access_token") val accessToken: String,
+ @SerialName("refresh_token") val refreshToken: String
+)
+
+@Serializable
+data class LogoutRequest(@SerialName("refresh_token") val refreshToken: String)
+
+@Serializable
+data class LogoutResponse(val ok: Boolean)
+
+@Serializable
+data class UpdateProfileRequest(
+ val name: String,
+ @SerialName("user_type") val userType: String
+)
+
+@Serializable
+data class Device(
+ @SerialName("device_identifier") val deviceIdentifier: String,
+ @SerialName("device_platform") val devicePlatform: String,
+ @SerialName("device_model") val deviceModel: String?,
+ @SerialName("os_version") val osVersion: String?,
+ @SerialName("app_version") val appVersion: String?,
+ @SerialName("language_code") val languageCode: String?,
+ val timezone: String?,
+ @SerialName("first_seen_at") val firstSeenAt: String,
+ @SerialName("last_seen_at") val lastSeenAt: String,
+ @SerialName("is_active") val isActive: Boolean
+)
+
+@Serializable
+data class DevicesResponse(val devices: List)
+
+@Serializable
+data class LogoutAllOtherDevicesRequest(
+ @SerialName("current_device_id") val currentDeviceId: String
+)
+
+@Serializable
+data class LogoutAllOtherDevicesResponse(
+ val ok: Boolean,
+ val message: String,
+ @SerialName("revoked_devices_count") val revokedDevicesCount: Int
+)
+
+@Serializable
+data class ErrorResponse(val error: String)
+
+@Serializable
+data class HealthResponse(val ok: Boolean)
+```
+
+### 2. API Client (Common Module)
+
+```kotlin
+// commonMain/kotlin/network/AuthApiClient.kt
+package com.farm.auth.network
+
+import com.farm.auth.models.*
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.plugins.contentnegotiation.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.serialization.json.Json
+
+class AuthApiClient(
+ private val baseUrl: String,
+ private val httpClient: HttpClient
+) {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ encodeDefaults = false
+ }
+
+ init {
+ httpClient.config {
+ install(ContentNegotiation) {
+ json(json)
+ }
+ }
+ }
+
+ suspend fun requestOtp(phoneNumber: String): Result {
+ return try {
+ val response = httpClient.post("$baseUrl/auth/request-otp") {
+ contentType(ContentType.Application.Json)
+ setBody(RequestOtpRequest(phoneNumber))
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun verifyOtp(
+ phoneNumber: String,
+ code: String,
+ deviceId: String,
+ deviceInfo: DeviceInfo? = null
+ ): Result {
+ return try {
+ val request = VerifyOtpRequest(phoneNumber, code, deviceId, deviceInfo)
+ val response = httpClient.post("$baseUrl/auth/verify-otp") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun refreshToken(refreshToken: String): Result {
+ return try {
+ val request = RefreshRequest(refreshToken)
+ val response = httpClient.post("$baseUrl/auth/refresh") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun logout(refreshToken: String): Result {
+ return try {
+ val request = LogoutRequest(refreshToken)
+ val response = httpClient.post("$baseUrl/auth/logout") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getCurrentUser(accessToken: String): Result {
+ return try {
+ val response = httpClient.get("$baseUrl/users/me") {
+ contentType(ContentType.Application.Json)
+ header("Authorization", "Bearer $accessToken")
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateProfile(
+ name: String,
+ userType: String,
+ accessToken: String
+ ): Result {
+ return try {
+ val request = UpdateProfileRequest(name, userType)
+ val response = httpClient.put("$baseUrl/users/me") {
+ contentType(ContentType.Application.Json)
+ header("Authorization", "Bearer $accessToken")
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getDevices(accessToken: String): Result {
+ return try {
+ val response = httpClient.get("$baseUrl/users/me/devices") {
+ contentType(ContentType.Application.Json)
+ header("Authorization", "Bearer $accessToken")
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun logoutDevice(
+ deviceId: String,
+ accessToken: String
+ ): Result {
+ return try {
+ val response = httpClient.delete("$baseUrl/users/me/devices/$deviceId") {
+ contentType(ContentType.Application.Json)
+ header("Authorization", "Bearer $accessToken")
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun logoutAllOtherDevices(
+ currentDeviceId: String,
+ accessToken: String
+ ): Result {
+ return try {
+ val request = LogoutAllOtherDevicesRequest(currentDeviceId)
+ val response = httpClient.post("$baseUrl/users/me/logout-all-other-devices") {
+ contentType(ContentType.Application.Json)
+ header("Authorization", "Bearer $accessToken")
+ header("X-Device-Id", currentDeviceId)
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun healthCheck(): Result {
+ return try {
+ val response = httpClient.get("$baseUrl/health")
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
+```
+
+### 3. Token Storage Interface (Common Module)
+
+```kotlin
+// commonMain/kotlin/storage/TokenStorage.kt
+package com.farm.auth.storage
+
+interface TokenStorage {
+ fun saveTokens(accessToken: String, refreshToken: String)
+ fun getAccessToken(): String?
+ fun getRefreshToken(): String?
+ fun clearTokens()
+}
+```
+
+### 4. Android Token Storage Implementation
+
+```kotlin
+// androidMain/kotlin/storage/AndroidTokenStorage.kt
+package com.farm.auth.storage
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+class AndroidTokenStorage(private val context: Context) : TokenStorage {
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ "auth_tokens",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ override fun saveTokens(accessToken: String, refreshToken: String) {
+ prefs.edit().apply {
+ putString("access_token", accessToken)
+ putString("refresh_token", refreshToken)
+ apply()
+ }
+ }
+
+ override fun getAccessToken(): String? = prefs.getString("access_token", null)
+
+ override fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
+
+ override fun clearTokens() {
+ prefs.edit().clear().apply()
+ }
+}
+```
+
+### 5. iOS Token Storage Implementation (using Keychain)
+
+```kotlin
+// iosMain/kotlin/storage/IosTokenStorage.kt
+package com.farm.auth.storage
+
+import platform.Security.*
+import platform.Foundation.*
+
+class IosTokenStorage : TokenStorage {
+ private val accessTokenKey = "access_token"
+ private val refreshTokenKey = "refresh_token"
+ private val service = "com.farm.auth"
+
+ override fun saveTokens(accessToken: String, refreshToken: String) {
+ saveToKeychain(accessTokenKey, accessToken)
+ saveToKeychain(refreshTokenKey, refreshToken)
+ }
+
+ override fun getAccessToken(): String? = getFromKeychain(accessTokenKey)
+
+ override fun getRefreshToken(): String? = getFromKeychain(refreshTokenKey)
+
+ override fun clearTokens() {
+ deleteFromKeychain(accessTokenKey)
+ deleteFromKeychain(refreshTokenKey)
+ }
+
+ private fun saveToKeychain(key: String, value: String): Boolean {
+ val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return false
+ val query = mapOf(
+ kSecClass to kSecClassGenericPassword,
+ kSecAttrService to service,
+ kSecAttrAccount to key,
+ kSecValueData to data
+ )
+ SecItemDelete(query.toCFDictionary())
+ return SecItemAdd(query.toCFDictionary(), null) == errSecSuccess
+ }
+
+ private fun getFromKeychain(key: String): String? {
+ val query = mapOf(
+ kSecClass to kSecClassGenericPassword,
+ kSecAttrService to service,
+ kSecAttrAccount to key,
+ kSecReturnData to kCFBooleanTrue
+ )
+ val result = alloc()
+ if (SecItemCopyMatching(query.toCFDictionary(), result.ptr) == errSecSuccess) {
+ val data = result.value as? NSData ?: return null
+ return NSString.create(data, NSUTF8StringEncoding) as String
+ }
+ return null
+ }
+
+ private fun deleteFromKeychain(key: String) {
+ val query = mapOf(
+ kSecClass to kSecClassGenericPassword,
+ kSecAttrService to service,
+ kSecAttrAccount to key
+ )
+ SecItemDelete(query.toCFDictionary())
+ }
+}
+```
+
+### 6. Device Info Provider Interface
+
+```kotlin
+// commonMain/kotlin/device/DeviceInfoProvider.kt
+package com.farm.auth.device
+
+import com.farm.auth.models.DeviceInfo
+
+interface DeviceInfoProvider {
+ fun getDeviceId(): String
+ fun getDeviceInfo(): DeviceInfo
+}
+```
+
+### 7. Authentication Manager
+
+```kotlin
+// commonMain/kotlin/auth/AuthManager.kt
+package com.farm.auth.auth
+
+import com.farm.auth.models.*
+import com.farm.auth.network.AuthApiClient
+import com.farm.auth.storage.TokenStorage
+import com.farm.auth.device.DeviceInfoProvider
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class AuthManager(
+ private val apiClient: AuthApiClient,
+ private val tokenStorage: TokenStorage,
+ private val deviceInfoProvider: DeviceInfoProvider
+) {
+ private val _currentUser = MutableStateFlow(null)
+ val currentUser: StateFlow = _currentUser.asStateFlow()
+
+ private val _isAuthenticated = MutableStateFlow(false)
+ val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow()
+
+ suspend fun requestOtp(phoneNumber: String): Result {
+ return apiClient.requestOtp(phoneNumber)
+ }
+
+ suspend fun verifyOtp(phoneNumber: String, code: String): Result {
+ val deviceId = deviceInfoProvider.getDeviceId()
+ val deviceInfo = deviceInfoProvider.getDeviceInfo()
+
+ return apiClient.verifyOtp(phoneNumber, code, deviceId, deviceInfo)
+ .onSuccess { response ->
+ tokenStorage.saveTokens(response.accessToken, response.refreshToken)
+ _currentUser.value = response.user
+ _isAuthenticated.value = true
+ }
+ }
+
+ suspend fun refreshTokens(): Result> {
+ val refreshToken = tokenStorage.getRefreshToken()
+ ?: return Result.failure(Exception("No refresh token"))
+
+ return apiClient.refreshToken(refreshToken)
+ .onSuccess { response ->
+ tokenStorage.saveTokens(response.accessToken, response.refreshToken)
+ }
+ .onFailure {
+ // If refresh fails, clear tokens and logout
+ if (it.message?.contains("Invalid refresh token") == true) {
+ logout()
+ }
+ }
+ .map { it.accessToken to it.refreshToken }
+ }
+
+ suspend fun logout() {
+ tokenStorage.getRefreshToken()?.let { refreshToken ->
+ apiClient.logout(refreshToken)
+ }
+ tokenStorage.clearTokens()
+ _currentUser.value = null
+ _isAuthenticated.value = false
+ }
+
+ suspend fun getCurrentUser(): Result {
+ val accessToken = tokenStorage.getAccessToken()
+ ?: return Result.failure(Exception("Not authenticated"))
+
+ return apiClient.getCurrentUser(accessToken)
+ .onSuccess { user ->
+ _currentUser.value = user
+ }
+ .recoverCatching { error ->
+ if (error.message?.contains("401") == true ||
+ error.message?.contains("Unauthorized") == true) {
+ // Try to refresh token and retry
+ refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
+ apiClient.getCurrentUser(newAccessToken)
+ .onSuccess { user ->
+ _currentUser.value = user
+ }
+ } ?: Result.failure(error)
+ } else {
+ Result.failure(error)
+ }
+ }
+ }
+
+ suspend fun updateProfile(name: String, userType: String): Result {
+ val accessToken = tokenStorage.getAccessToken()
+ ?: return Result.failure(Exception("Not authenticated"))
+
+ return apiClient.updateProfile(name, userType, accessToken)
+ .onSuccess { user ->
+ _currentUser.value = user
+ }
+ }
+
+ suspend fun getDevices(): Result {
+ val accessToken = tokenStorage.getAccessToken()
+ ?: return Result.failure(Exception("Not authenticated"))
+
+ return callWithAuth { token ->
+ apiClient.getDevices(token)
+ }
+ }
+
+ suspend fun logoutDevice(deviceId: String): Result {
+ return callWithAuth { token ->
+ apiClient.logoutDevice(deviceId, token)
+ }
+ }
+
+ suspend fun logoutAllOtherDevices(): Result {
+ val currentDeviceId = deviceInfoProvider.getDeviceId()
+ return callWithAuth { token ->
+ apiClient.logoutAllOtherDevices(currentDeviceId, token)
+ }
+ }
+
+ fun getAccessToken(): String? = tokenStorage.getAccessToken()
+
+ private suspend fun callWithAuth(
+ block: suspend (String) -> Result
+ ): Result {
+ val token = tokenStorage.getAccessToken()
+ ?: return Result.failure(Exception("Not authenticated"))
+
+ return block(token).recoverCatching { error ->
+ if (error.message?.contains("401") == true ||
+ error.message?.contains("Unauthorized") == true) {
+ // Token expired, refresh and retry
+ refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
+ block(newAccessToken)
+ } ?: Result.failure(Exception("Failed to refresh token"))
+ } else {
+ Result.failure(error)
+ }
+ }
+ }
+}
+```
+
+---
+
+## Error Handling
+
+### Common Error Codes
+
+| Status | Error | Description |
+|--------|-------|-------------|
+| 400 | `phone_number is required` | Missing phone number in request |
+| 400 | `Invalid or expired OTP` | Wrong code, OTP expired (10 min), or max attempts exceeded (5) |
+| 400 | `name and user_type are required` | Missing required fields in profile update |
+| 400 | `current_device_id is required` | Missing device ID for logout all devices |
+| 401 | `Invalid refresh token` | Token expired, revoked, or compromised |
+| 401 | `Missing Authorization header` | Access token not provided |
+| 401 | `Invalid or expired token` | Access token invalid or expired |
+| 404 | `User not found` | User account doesn't exist |
+| 403 | `Origin not allowed` | CORS restriction (production only) |
+| 500 | `Internal server error` | Server-side error |
+| 500 | `Failed to send OTP` | Twilio SMS sending failed |
+
+### Error Response Format
+
+```json
+{
+ "error": "Error message here"
+}
+```
+
+---
+
+## Security Best Practices
+
+1. **Store tokens securely**
+ - **Android**: Use `EncryptedSharedPreferences` (Android Security Library)
+ - **iOS**: Use Keychain Services
+ - Never store tokens in plain SharedPreferences/UserDefaults
+
+2. **Handle token expiration**
+ - Automatically refresh when access token expires (401 response)
+ - Implement retry logic with token refresh
+
+3. **Rotate refresh tokens**
+ - Always save the new `refresh_token` after refresh
+ - Old refresh tokens are automatically revoked
+
+4. **Validate device_id**
+ - Use consistent device identifier (Android ID, Installation ID, or Vendor ID)
+ - Device ID should be 4-128 alphanumeric characters
+ - Server will hash invalid device IDs
+
+5. **Handle reuse detection**
+ - If refresh returns 401 with "Invalid refresh token", force re-login
+ - This indicates potential token compromise
+
+6. **Secure network communication**
+ - Always use HTTPS in production
+ - Implement certificate pinning if needed
+
+7. **Rate limiting awareness**
+ - Don't spam OTP requests
+ - Implement client-side rate limiting if possible
+
+---
+
+## Token Management
+
+### Token Expiration
+
+- **Access Token:** 15 minutes (default, configurable via `JWT_ACCESS_TTL` env var)
+- **Refresh Token:** 7 days (default, configurable via `JWT_REFRESH_TTL` env var)
+- **Refresh Token Idle Timeout:** 3 days (default, configurable via `REFRESH_MAX_IDLE_MINUTES` = 4320)
+- **OTP:** 10 minutes (fixed)
+
+### Auto-refresh Pattern
+
+```kotlin
+suspend fun callWithAuth(
+ block: suspend (String) -> Result
+): Result {
+ val token = tokenStorage.getAccessToken()
+ ?: return Result.failure(Exception("Not authenticated"))
+
+ return block(token).recoverCatching { error ->
+ if (error.message?.contains("401") == true ||
+ error.message?.contains("Unauthorized") == true) {
+ // Token expired, refresh and retry
+ refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
+ block(newAccessToken)
+ } ?: Result.failure(Exception("Failed to refresh token"))
+ } else {
+ Result.failure(error)
+ }
+ }
+}
+```
+
+---
+
+## Notes
+
+- Phone numbers are auto-normalized: 10-digit numbers → `+91` prefix
+- Device ID should be 4-128 alphanumeric characters (server sanitizes if invalid)
+- Refresh tokens rotate on each use - always update stored token
+- If `needs_profile: true`, prompt user to complete profile before accessing app
+- `is_new_device` and `is_new_account` flags help with onboarding flows
+- Device management endpoints require authentication
+- All timestamps are in ISO 8601 format with timezone (UTC)
+
+---
+
+## Example Usage (Android Activity)
+
+```kotlin
+class LoginActivity : AppCompatActivity() {
+ private lateinit var authManager: AuthManager
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val httpClient = HttpClient(Android) {
+ install(ContentNegotiation) {
+ json()
+ }
+ }
+
+ val apiClient = AuthApiClient("http://your-api-url", httpClient)
+ val tokenStorage = AndroidTokenStorage(this)
+ val deviceInfoProvider = AndroidDeviceInfoProvider(this)
+
+ authManager = AuthManager(apiClient, tokenStorage, deviceInfoProvider)
+
+ // Observe authentication state
+ lifecycleScope.launch {
+ authManager.isAuthenticated.collect { isAuth ->
+ if (isAuth) {
+ // Navigate to main screen
+ }
+ }
+ }
+ }
+
+ private fun requestOtp() {
+ lifecycleScope.launch {
+ val phoneNumber = phoneInput.text.toString()
+ authManager.requestOtp(phoneNumber)
+ .onSuccess {
+ showToast("OTP sent!")
+ }
+ .onFailure {
+ showError(it.message ?: "Failed to send OTP")
+ }
+ }
+ }
+
+ private fun verifyOtp() {
+ lifecycleScope.launch {
+ val phoneNumber = phoneInput.text.toString()
+ val code = otpInput.text.toString()
+
+ authManager.verifyOtp(phoneNumber, code)
+ .onSuccess { response ->
+ if (response.needsProfile) {
+ startActivity(Intent(this@LoginActivity, ProfileSetupActivity::class.java))
+ } else {
+ startActivity(Intent(this@LoginActivity, MainActivity::class.java))
+ }
+ finish()
+ }
+ .onFailure {
+ showError("Invalid OTP")
+ }
+ }
+ }
+}
+```
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 64f3c6c..3a291db 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
+ android:theme="@style/Theme.LivingAi_Lg"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="31">
diff --git a/app/src/main/java/com/example/livingai_lg/MainActivity.kt b/app/src/main/java/com/example/livingai_lg/MainActivity.kt
index f0520ef..4f324d8 100644
--- a/app/src/main/java/com/example/livingai_lg/MainActivity.kt
+++ b/app/src/main/java/com/example/livingai_lg/MainActivity.kt
@@ -1,17 +1,15 @@
-
package com.example.livingai_lg
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
-import com.example.livingai_lg.ui.login.LoginScreen
-import com.example.livingai_lg.ui.login.SignUpScreen
-import com.example.livingai_lg.ui.login.OtpScreen
-import com.example.livingai_lg.ui.login.CreateProfileScreen
+import androidx.navigation.navArgument
+import com.example.livingai_lg.ui.login.*
import com.example.livingai_lg.ui.theme.LivingAi_LgTheme
class MainActivity : ComponentActivity() {
@@ -28,11 +26,32 @@ class MainActivity : ComponentActivity() {
composable("signup") {
SignUpScreen(navController = navController)
}
- composable("otp") {
- OtpScreen(navController = navController)
+ composable("signin") {
+ SignInScreen(navController = navController)
}
- composable("create_profile") {
- CreateProfileScreen(navController = navController)
+ composable(
+ "otp/{phoneNumber}/{name}",
+ arguments = listOf(
+ navArgument("phoneNumber") { type = NavType.StringType },
+ navArgument("name") { type = NavType.StringType })
+ ) { backStackEntry ->
+ OtpScreen(
+ navController = navController,
+ phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "",
+ name = backStackEntry.arguments?.getString("name") ?: ""
+ )
+ }
+ composable(
+ "create_profile/{name}",
+ arguments = listOf(navArgument("name") { type = NavType.StringType })
+ ) { backStackEntry ->
+ CreateProfileScreen(
+ navController = navController,
+ name = backStackEntry.arguments?.getString("name") ?: ""
+ )
+ }
+ composable("success") {
+ SuccessScreen()
}
}
}
diff --git a/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt b/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt
new file mode 100644
index 0000000..c97a0f4
--- /dev/null
+++ b/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt
@@ -0,0 +1,107 @@
+package com.example.livingai_lg.api
+
+import android.os.Build
+import com.example.livingai_lg.BuildConfig
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.plugins.contentnegotiation.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.serialization.json.Json
+import java.util.Locale
+import java.util.TimeZone
+
+class AuthApiClient(private val baseUrl: String) {
+ private val client = HttpClient {
+ install(ContentNegotiation) {
+ json(Json {
+ ignoreUnknownKeys = true
+ })
+ }
+ }
+
+ suspend fun requestOtp(phoneNumber: String): Result {
+ return try {
+ val response = client.post("$baseUrl/auth/request-otp") {
+ contentType(ContentType.Application.Json)
+ setBody(RequestOtpRequest(phoneNumber))
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun verifyOtp(
+ phoneNumber: String,
+ code: String,
+ deviceId: String
+ ): Result {
+ return try {
+ val request = VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo())
+ val response = client.post("$baseUrl/auth/verify-otp") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun refreshToken(refreshToken: String): Result {
+ return try {
+ val request = RefreshRequest(refreshToken)
+ val response = client.post("$baseUrl/auth/refresh") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateProfile(
+ name: String,
+ userType: String,
+ accessToken: String
+ ): Result {
+ return try {
+ val request = UpdateProfileRequest(name, userType)
+ val response = client.put("$baseUrl/users/me") {
+ contentType(ContentType.Application.Json)
+ header("Authorization", "Bearer $accessToken")
+ setBody(request)
+ }
+ Result.success(response.body())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun logout(refreshToken: String): Result {
+ return try {
+ val request = RefreshRequest(refreshToken)
+ client.post("$baseUrl/auth/logout") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ private fun getDeviceInfo(): DeviceInfo {
+ return DeviceInfo(
+ platform = "android",
+ model = Build.MODEL,
+ os_version = Build.VERSION.RELEASE,
+ app_version = BuildConfig.VERSION_NAME,
+ language_code = Locale.getDefault().toString(),
+ timezone = TimeZone.getDefault().id
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt b/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt
new file mode 100644
index 0000000..0a1fce9
--- /dev/null
+++ b/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt
@@ -0,0 +1,34 @@
+package com.example.livingai_lg.api
+
+import android.content.Context
+import android.provider.Settings
+
+class AuthManager(
+ private val context: Context,
+ private val apiClient: AuthApiClient,
+ private val tokenManager: TokenManager
+) {
+ suspend fun requestOtp(phoneNumber: String): Result {
+ return apiClient.requestOtp(phoneNumber)
+ }
+
+ suspend fun login(phoneNumber: String, code: String): Result {
+ val deviceId = getDeviceId()
+ return apiClient.verifyOtp(phoneNumber, code, deviceId)
+ .onSuccess { response ->
+ tokenManager.saveTokens(response.access_token, response.refresh_token)
+ }
+ }
+
+ suspend fun updateProfile(name: String, userType: String): Result {
+ val accessToken = tokenManager.getAccessToken() ?: return Result.failure(Exception("No access token found"))
+ return apiClient.updateProfile(name, userType, accessToken)
+ }
+
+ private fun getDeviceId(): String {
+ return Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.ANDROID_ID
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt b/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt
new file mode 100644
index 0000000..3ee62e5
--- /dev/null
+++ b/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt
@@ -0,0 +1,35 @@
+package com.example.livingai_lg.api
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+class TokenManager(context: Context) {
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ "auth_tokens",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ fun saveTokens(accessToken: String, refreshToken: String) {
+ prefs.edit().apply {
+ putString("access_token", accessToken)
+ putString("refresh_token", refreshToken)
+ apply()
+ }
+ }
+
+ fun getAccessToken(): String? = prefs.getString("access_token", null)
+ fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
+
+ fun clearTokens() {
+ prefs.edit().clear().apply()
+ }
+}
diff --git a/app/src/main/java/com/example/livingai_lg/api/models.kt b/app/src/main/java/com/example/livingai_lg/api/models.kt
new file mode 100644
index 0000000..50777c2
--- /dev/null
+++ b/app/src/main/java/com/example/livingai_lg/api/models.kt
@@ -0,0 +1,62 @@
+package com.example.livingai_lg.api
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class RequestOtpRequest(val phone_number: String)
+
+@Serializable
+data class RequestOtpResponse(val ok: Boolean)
+
+@Serializable
+data class DeviceInfo(
+ val platform: String,
+ val model: String? = null,
+ val os_version: String? = null,
+ val app_version: String? = null,
+ val language_code: String? = null,
+ val timezone: String? = null
+)
+
+@Serializable
+data class VerifyOtpRequest(
+ val phone_number: String,
+ val code: String,
+ val device_id: String,
+ val device_info: DeviceInfo? = null
+)
+
+@Serializable
+data class User(
+ val id: String,
+ val phone_number: String,
+ val name: String?,
+ val role: String,
+ val user_type: String?
+)
+
+@Serializable
+data class VerifyOtpResponse(
+ val user: User,
+ val access_token: String,
+ val refresh_token: String,
+ val needs_profile: Boolean
+)
+
+@Serializable
+data class RefreshRequest(val refresh_token: String)
+
+@Serializable
+data class RefreshResponse(val access_token: String, val refresh_token: String)
+
+@Serializable
+data class UpdateProfileRequest(val name: String, val user_type: String)
+
+@Serializable
+data class UpdateProfileResponse(
+ val id: String,
+ val phone_number: String,
+ val name: String?,
+ val role: String,
+ val user_type: String?
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt
index ef62bc1..cee669c 100644
--- a/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt
+++ b/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt
@@ -1,5 +1,6 @@
package com.example.livingai_lg.ui.login
+import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -7,11 +8,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -20,10 +24,30 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.example.livingai_lg.R
+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.theme.*
+import kotlinx.coroutines.launch
@Composable
-fun CreateProfileScreen(navController: NavController) {
+fun CreateProfileScreen(navController: NavController, name: String) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
+
+ fun updateProfile(userType: String) {
+ scope.launch {
+ authManager.updateProfile(name, userType)
+ .onSuccess {
+ navController.navigate("success") { popUpTo("login") { inclusive = true } }
+ }
+ .onFailure {
+ Toast.makeText(context, "Failed to update profile: ${it.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+
Box(
modifier = Modifier
.fillMaxSize()
@@ -46,26 +70,26 @@ fun CreateProfileScreen(navController: NavController) {
Spacer(modifier = Modifier.height(64.dp))
- ProfileTypeItem(text = "I'm a Seller", icon = R.drawable.ic_seller)
+ ProfileTypeItem(text = "I'm a Seller", icon = R.drawable.ic_seller) { updateProfile("seller") }
Spacer(modifier = Modifier.height(16.dp))
- ProfileTypeItem(text = "I'm a Buyer", icon = R.drawable.ic_buyer)
+ ProfileTypeItem(text = "I'm a Buyer", icon = R.drawable.ic_buyer) { updateProfile("buyer") }
Spacer(modifier = Modifier.height(16.dp))
- ProfileTypeItem(text = "I'm a Service Provider", icon = R.drawable.ic_service_provider)
+ ProfileTypeItem(text = "I'm a Service Provider", icon = R.drawable.ic_service_provider) { updateProfile("service_provider") }
Spacer(modifier = Modifier.height(16.dp))
- ProfileTypeItem(text = "I'm a Mandi Host", icon = R.drawable.ic_mandi_host)
+ ProfileTypeItem(text = "I'm a Mandi Host", icon = R.drawable.ic_mandi_host) { /* TODO: Add user_type for Mandi Host */ }
}
}
}
@Composable
-fun ProfileTypeItem(text: String, icon: Int) {
+fun ProfileTypeItem(text: String, icon: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp))
.background(Color.White, RoundedCornerShape(16.dp))
- .clickable { /* TODO */ }
+ .clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -81,6 +105,6 @@ fun ProfileTypeItem(text: String, icon: Int) {
@Composable
fun CreateProfileScreenPreview() {
LivingAi_LgTheme {
- CreateProfileScreen(rememberNavController())
+ CreateProfileScreen(rememberNavController(), "John Doe")
}
}
diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt
index ed20b15..99c7fb0 100644
--- a/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt
+++ b/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt
@@ -1,8 +1,9 @@
-
package com.example.livingai_lg.ui.login
+import android.widget.Toast
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -10,9 +11,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -24,6 +24,8 @@ import com.example.livingai_lg.ui.theme.*
@Composable
fun LoginScreen(navController: NavController) {
+ val context = LocalContext.current
+
Box(
modifier = Modifier
.fillMaxSize()
@@ -33,12 +35,7 @@ fun LoginScreen(navController: NavController) {
)
)
) {
- // Decorative elements
- Box(
- modifier = Modifier.fillMaxSize()
- ) {
- // ... (decorative elements)
- }
+ // Decorative elements...
Column(
modifier = Modifier
@@ -99,6 +96,8 @@ fun LoginScreen(navController: NavController) {
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(48.dp))
+
+ // New User Button
Button(
onClick = { navController.navigate("signup") },
shape = RoundedCornerShape(16.dp),
@@ -109,9 +108,12 @@ fun LoginScreen(navController: NavController) {
) {
Text(text = "New user? Sign up", color = DarkerBrown, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
+
Spacer(modifier = Modifier.height(16.dp))
+
+ // Existing User Button
Button(
- onClick = { /* TODO: Handle Sign in */ },
+ onClick = { navController.navigate("signin") },
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = TerraCotta),
modifier = Modifier
@@ -120,13 +122,18 @@ fun LoginScreen(navController: NavController) {
) {
Text(text = "Already a user? Sign in", color = DarkerBrown, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
+
Spacer(modifier = Modifier.height(24.dp))
+
+ // Guest Button
Text(
text = "Continue as Guest",
color = MidBrown,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
+ modifier = Modifier.clickable { Toast.makeText(context, "Guest mode is not yet available", Toast.LENGTH_SHORT).show() }
)
+
Spacer(modifier = Modifier.weight(1.5f))
}
}
diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt
index 499a4da..7ce223d 100644
--- a/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt
+++ b/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt
@@ -1,24 +1,18 @@
-
package com.example.livingai_lg.ui.login
+import android.widget.Toast
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.LocalTextStyle
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.material3.*
+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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
@@ -27,11 +21,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
+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.theme.*
+import kotlinx.coroutines.launch
@Composable
-fun OtpScreen(navController: NavController) {
+fun OtpScreen(navController: NavController, phoneNumber: String, name: String) {
val otp = remember { mutableStateOf("") }
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
+
+ // Flag to determine if this is a sign-in flow for an existing user.
+ val isSignInFlow = name == "existing_user"
Box(
modifier = Modifier
@@ -54,36 +58,49 @@ fun OtpScreen(navController: NavController) {
Spacer(modifier = Modifier.height(32.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly
- ) {
- for (i in 0..3) {
- TextField(
- value = if (otp.value.length > i) otp.value[i].toString() else "",
- onValueChange = { if (it.length <= 1) { /* TODO */ } },
- modifier = Modifier
- .width(60.dp)
- .height(60.dp)
- .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)),
- shape = RoundedCornerShape(16.dp),
- colors = TextFieldDefaults.colors(
- focusedContainerColor = Color.White.copy(alpha = 0.9f),
- unfocusedContainerColor = Color.White.copy(alpha = 0.9f),
- disabledContainerColor = Color.White.copy(alpha = 0.9f),
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- ),
- textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center, fontSize = 24.sp),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
- )
- }
- }
+ TextField(
+ value = otp.value,
+ onValueChange = { if (it.length <= 6) otp.value = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(60.dp)
+ .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)),
+ shape = RoundedCornerShape(16.dp),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.White.copy(alpha = 0.9f),
+ unfocusedContainerColor = Color.White.copy(alpha = 0.9f),
+ disabledContainerColor = Color.White.copy(alpha = 0.9f),
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center, fontSize = 24.sp),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+ )
Spacer(modifier = Modifier.height(48.dp))
Button(
- onClick = { navController.navigate("create_profile") },
+ onClick = {
+ scope.launch {
+ authManager.login(phoneNumber, otp.value)
+ .onSuccess { response ->
+ if (isSignInFlow) {
+ // For existing users, always go to the success screen.
+ navController.navigate("success") { popUpTo("login") { inclusive = true } }
+ } else {
+ // For new users, check if a profile needs to be created.
+ if (response.needs_profile) {
+ navController.navigate("create_profile/$name")
+ } else {
+ navController.navigate("success") { popUpTo("login") { inclusive = true } }
+ }
+ }
+ }
+ .onFailure {
+ Toast.makeText(context, "Invalid or expired OTP", Toast.LENGTH_SHORT).show()
+ }
+ }
+ },
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFE9A00)),
modifier = Modifier
@@ -101,6 +118,6 @@ fun OtpScreen(navController: NavController) {
@Composable
fun OtpScreenPreview() {
LivingAi_LgTheme {
- OtpScreen(rememberNavController())
+ OtpScreen(rememberNavController(), "+919876543210", "John Doe")
}
}
diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/SignInScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/SignInScreen.kt
new file mode 100644
index 0000000..c107bfb
--- /dev/null
+++ b/app/src/main/java/com/example/livingai_lg/ui/login/SignInScreen.kt
@@ -0,0 +1,152 @@
+package com.example.livingai_lg.ui.login
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material3.*
+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.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import androidx.navigation.compose.rememberNavController
+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.theme.*
+import kotlinx.coroutines.launch
+
+@Composable
+fun SignInScreen(navController: NavController) {
+ val phoneNumber = remember { mutableStateOf("") }
+ val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } }
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(LightCream, LighterCream, LightestGreen)
+ )
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 36.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(140.dp))
+
+ Row {
+ Text("Farm", fontSize = 32.sp, fontWeight = FontWeight.Medium, color = Color(0xFFE17100))
+ Text("Market", fontSize = 32.sp, fontWeight = FontWeight.Medium, color = Color.Black)
+ }
+ Text("Welcome back!", fontSize = 16.sp, color = Color(0xFF4A5565))
+
+ Spacer(modifier = Modifier.height(128.dp))
+
+ Text(
+ text = "Enter Phone Number",
+ fontSize = 16.sp,
+ color = Color(0xFF364153),
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.align(Alignment.Start).padding(start = 21.dp)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .width(65.dp)
+ .height(52.dp)
+ .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp))
+ .background(color = Color.White.copy(alpha = 0.9f), shape = RoundedCornerShape(16.dp))
+ .border(width = 1.dp, color = Color.Black.copy(alpha = 0.07f), shape = RoundedCornerShape(16.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("+91", fontSize = 16.sp, color = Color(0xFF0A0A0A))
+ }
+ Spacer(modifier = Modifier.width(6.dp))
+ TextField(
+ value = phoneNumber.value,
+ onValueChange = { phoneNumber.value = it },
+ placeholder = { Text("Enter your Phone Number", color = Color(0xFF99A1AF)) },
+ leadingIcon = { Icon(Icons.Default.Phone, contentDescription = null, tint = Color(0xFF99A1AF)) },
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.White.copy(alpha = 0.9f),
+ unfocusedContainerColor = Color.White.copy(alpha = 0.9f),
+ disabledContainerColor = Color.White.copy(alpha = 0.9f),
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ isError = phoneNumber.value.isNotEmpty() && !isPhoneNumberValid,
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier.weight(1f).height(52.dp).shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ singleLine = true
+ )
+ }
+ if (phoneNumber.value.isNotEmpty() && !isPhoneNumberValid) {
+ Text(
+ text = "Please enter a valid 10-digit phone number",
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 16.dp, top = 4.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = {
+ val fullPhoneNumber = "+91${phoneNumber.value}"
+ scope.launch {
+ authManager.requestOtp(fullPhoneNumber)
+ .onSuccess {
+ // For existing user, name is not needed, so we pass a placeholder
+ navController.navigate("otp/$fullPhoneNumber/existing_user")
+ }
+ .onFailure {
+ Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ },
+ enabled = isPhoneNumberValid,
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFFFE9A00),
+ disabledContainerColor = Color(0xFFF8DDA7),
+ contentColor = Color.White,
+ disabledContentColor = Color.White.copy(alpha = 0.7f)
+ ),
+ modifier = Modifier.fillMaxWidth().height(56.dp).shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp))
+ ) {
+ Text("Sign In", fontSize = 16.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun SignInScreenPreview() {
+ LivingAi_LgTheme {
+ SignInScreen(rememberNavController())
+ }
+}
diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt
index e0489d3..c7788cf 100644
--- a/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt
+++ b/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt
@@ -1,29 +1,23 @@
-
package com.example.livingai_lg.ui.login
+import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Phone
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.material3.*
+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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
@@ -32,12 +26,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
+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.theme.*
+import kotlinx.coroutines.launch
@Composable
fun SignUpScreen(navController: NavController) {
val name = remember { mutableStateOf("") }
val phoneNumber = remember { mutableStateOf("") }
+ val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } }
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ // Use 10.0.2.2 to connect to host machine's localhost from emulator
+ val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
Box(
modifier = Modifier
@@ -48,12 +51,6 @@ fun SignUpScreen(navController: NavController) {
)
)
) {
- // Decorative elements from LoginScreen
- Box(
- modifier = Modifier.fillMaxSize()
- ) {
- // ...
- }
Column(
modifier = Modifier
.fillMaxSize()
@@ -129,19 +126,45 @@ fun SignUpScreen(navController: NavController) {
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
+ isError = phoneNumber.value.isNotEmpty() && !isPhoneNumberValid,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.weight(1f).height(52.dp).shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true
)
}
+ if (phoneNumber.value.isNotEmpty() && !isPhoneNumberValid) {
+ Text(
+ text = "Please enter a valid 10-digit phone number",
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 16.dp, top = 4.dp)
+ )
+ }
Spacer(modifier = Modifier.height(32.dp))
Button(
- onClick = { navController.navigate("otp") },
+ onClick = {
+ val fullPhoneNumber = "+91${phoneNumber.value}"
+ scope.launch {
+ authManager.requestOtp(fullPhoneNumber)
+ .onSuccess {
+ navController.navigate("otp/$fullPhoneNumber/${name.value}")
+ }
+ .onFailure {
+ Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ },
+ enabled = isPhoneNumberValid,
shape = RoundedCornerShape(16.dp),
- colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFE9A00)),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFFFE9A00),
+ disabledContainerColor = Color(0xFFF8DDA7),
+ contentColor = Color.White,
+ disabledContentColor = Color.White.copy(alpha = 0.7f)
+ ),
modifier = Modifier.fillMaxWidth().height(56.dp).shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp))
) {
Text("Sign In", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Medium)
@@ -150,7 +173,7 @@ fun SignUpScreen(navController: NavController) {
Spacer(modifier = Modifier.height(32.dp))
Row(modifier = Modifier.align(Alignment.CenterHorizontally)) {
- Text("Don\'t have an account? ", color = Color(0xFF4A5565), fontSize = 16.sp)
+ Text("Don't have an account? ", color = Color(0xFF4A5565), fontSize = 16.sp)
Text(
text = "Sign up",
color = Color(0xFFE17100),
diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/SuccessScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/SuccessScreen.kt
new file mode 100644
index 0000000..1cd8e50
--- /dev/null
+++ b/app/src/main/java/com/example/livingai_lg/ui/login/SuccessScreen.kt
@@ -0,0 +1,56 @@
+package com.example.livingai_lg.ui.login
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.sp
+import com.example.livingai_lg.ui.theme.LightCream
+import com.example.livingai_lg.ui.theme.LighterCream
+import com.example.livingai_lg.ui.theme.LightestGreen
+import com.example.livingai_lg.ui.theme.LivingAi_LgTheme
+
+@Composable
+fun SuccessScreen() {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(LightCream, LighterCream, LightestGreen)
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Success!",
+ fontSize = 48.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = "Your profile has been created.",
+ fontSize = 24.sp
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun SuccessScreenPreview() {
+ LivingAi_LgTheme {
+ SuccessScreen()
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e7e6b91..fa99242 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,13 +1,17 @@
[versions]
-agp = "8.13.1"
-kotlin = "2.0.21"
-coreKtx = "1.10.1"
+agp = "8.2.2"
+kotlin = "2.0.0"
+coreKtx = "1.13.1"
junit = "4.13.2"
-junitVersion = "1.1.5"
-espressoCore = "3.5.1"
-lifecycleRuntimeKtx = "2.6.1"
-activityCompose = "1.8.0"
-composeBom = "2024.09.00"
+junitVersion = "1.2.1"
+espressoCore = "3.6.1"
+lifecycleRuntimeKtx = "2.8.3"
+activityCompose = "1.9.0"
+composeBom = "2024.06.00"
+navigationCompose = "2.7.7"
+ktor = "2.3.12"
+kotlinxSerialization = "1.6.3"
+securityCrypto = "1.1.0-alpha06"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,9 +28,22 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+
+# Ktor
+ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
+ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
+
+# Kotlinx Serialization
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+
+# AndroidX Security
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }