Working Login
This commit is contained in:
parent
d6b099f921
commit
1def19003b
|
|
@ -57,6 +57,7 @@ dependencies {
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.ktor.client.auth) // <-- Added missing dependency
|
||||||
|
|
||||||
// Kotlinx Serialization
|
// Kotlinx Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
|
||||||
|
|
@ -214,9 +214,75 @@ Authorization: Bearer <access_token>
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"user_type": "seller",
|
"user_type": "seller",
|
||||||
|
"avatar_url": "https://example.com/avatar.jpg",
|
||||||
|
"language": "en",
|
||||||
|
"timezone": "Asia/Kolkata",
|
||||||
"created_at": "2024-01-15T10:30:00Z",
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
"last_login_at": "2024-01-20T14:22:00Z",
|
"last_login_at": "2024-01-20T14:22:00Z",
|
||||||
"active_devices_count": 2
|
"active_devices_count": 2,
|
||||||
|
"location": {
|
||||||
|
"id": "location-uuid-here",
|
||||||
|
"country": "India",
|
||||||
|
"state": "Maharashtra",
|
||||||
|
"district": "Pune",
|
||||||
|
"city_village": "Pune City",
|
||||||
|
"pincode": "411001",
|
||||||
|
"coordinates": {
|
||||||
|
"latitude": 18.5204,
|
||||||
|
"longitude": 73.8567
|
||||||
|
},
|
||||||
|
"location_type": "home",
|
||||||
|
"is_saved_address": true,
|
||||||
|
"source_type": "manual",
|
||||||
|
"source_confidence": "high",
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2024-01-20T14:22:00Z"
|
||||||
|
},
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"id": "location-uuid-here",
|
||||||
|
"country": "India",
|
||||||
|
"state": "Maharashtra",
|
||||||
|
"district": "Pune",
|
||||||
|
"city_village": "Pune City",
|
||||||
|
"pincode": "411001",
|
||||||
|
"coordinates": {
|
||||||
|
"latitude": 18.5204,
|
||||||
|
"longitude": 73.8567
|
||||||
|
},
|
||||||
|
"location_type": "home",
|
||||||
|
"is_saved_address": true,
|
||||||
|
"source_type": "manual",
|
||||||
|
"source_confidence": "high",
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2024-01-20T14:22:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Notes:**
|
||||||
|
- `location`: Primary/most recent saved location (or `null` if no saved locations)
|
||||||
|
- `locations`: Array of all saved locations (empty array if none)
|
||||||
|
- `coordinates`: `null` if latitude/longitude not available
|
||||||
|
- All optional fields (`avatar_url`, `language`, `timezone`) may be `null`
|
||||||
|
|
||||||
|
**Response with No Saved Locations:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"phone_number": "+919876543210",
|
||||||
|
"name": "John Doe",
|
||||||
|
"role": "user",
|
||||||
|
"user_type": "seller",
|
||||||
|
"avatar_url": null,
|
||||||
|
"language": null,
|
||||||
|
"timezone": null,
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"last_login_at": "2024-01-20T14:22:00Z",
|
||||||
|
"active_devices_count": 1,
|
||||||
|
"location": null,
|
||||||
|
"locations": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -233,6 +299,13 @@ or
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Error (404):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "User not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. Update User Profile
|
### 6. Update User Profile
|
||||||
|
|
@ -416,6 +489,29 @@ data class VerifyOtpRequest(
|
||||||
@SerialName("device_info") val deviceInfo: DeviceInfo? = null
|
@SerialName("device_info") val deviceInfo: DeviceInfo? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Coordinates(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Location(
|
||||||
|
val id: String,
|
||||||
|
val country: String?,
|
||||||
|
val state: String?,
|
||||||
|
val district: String?,
|
||||||
|
@SerialName("city_village") val cityVillage: String?,
|
||||||
|
val pincode: String?,
|
||||||
|
val coordinates: Coordinates?,
|
||||||
|
@SerialName("location_type") val locationType: String?,
|
||||||
|
@SerialName("is_saved_address") val isSavedAddress: Boolean,
|
||||||
|
@SerialName("source_type") val sourceType: String?,
|
||||||
|
@SerialName("source_confidence") val sourceConfidence: String?,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("updated_at") val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class User(
|
data class User(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|
@ -423,9 +519,14 @@ data class User(
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val role: String,
|
val role: String,
|
||||||
@SerialName("user_type") val userType: String?,
|
@SerialName("user_type") val userType: String?,
|
||||||
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
|
val language: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
@SerialName("created_at") val createdAt: String? = null,
|
@SerialName("created_at") val createdAt: String? = null,
|
||||||
@SerialName("last_login_at") val lastLoginAt: String? = null,
|
@SerialName("last_login_at") val lastLoginAt: String? = null,
|
||||||
@SerialName("active_devices_count") val activeDevicesCount: Int? = null
|
@SerialName("active_devices_count") val activeDevicesCount: Int? = null,
|
||||||
|
val location: Location? = null,
|
||||||
|
val locations: List<Location> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -1034,9 +1135,9 @@ suspend fun <T> callWithAuth(
|
||||||
): Result<T> {
|
): Result<T> {
|
||||||
val token = tokenStorage.getAccessToken()
|
val token = tokenStorage.getAccessToken()
|
||||||
?: return Result.failure(Exception("Not authenticated"))
|
?: return Result.failure(Exception("Not authenticated"))
|
||||||
|
|
||||||
return block(token).recoverCatching { error ->
|
return block(token).recoverCatching { error ->
|
||||||
if (error.message?.contains("401") == true ||
|
if (error.message?.contains("401") == true ||
|
||||||
error.message?.contains("Unauthorized") == true) {
|
error.message?.contains("Unauthorized") == true) {
|
||||||
// Token expired, refresh and retry
|
// Token expired, refresh and retry
|
||||||
refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
|
refreshTokens().getOrNull()?.let { (newAccessToken, _) ->
|
||||||
|
|
@ -1068,22 +1169,22 @@ suspend fun <T> callWithAuth(
|
||||||
```kotlin
|
```kotlin
|
||||||
class LoginActivity : AppCompatActivity() {
|
class LoginActivity : AppCompatActivity() {
|
||||||
private lateinit var authManager: AuthManager
|
private lateinit var authManager: AuthManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val httpClient = HttpClient(Android) {
|
val httpClient = HttpClient(Android) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val apiClient = AuthApiClient("http://your-api-url", httpClient)
|
val apiClient = AuthApiClient("http://your-api-url", httpClient)
|
||||||
val tokenStorage = AndroidTokenStorage(this)
|
val tokenStorage = AndroidTokenStorage(this)
|
||||||
val deviceInfoProvider = AndroidDeviceInfoProvider(this)
|
val deviceInfoProvider = AndroidDeviceInfoProvider(this)
|
||||||
|
|
||||||
authManager = AuthManager(apiClient, tokenStorage, deviceInfoProvider)
|
authManager = AuthManager(apiClient, tokenStorage, deviceInfoProvider)
|
||||||
|
|
||||||
// Observe authentication state
|
// Observe authentication state
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
authManager.isAuthenticated.collect { isAuth ->
|
authManager.isAuthenticated.collect { isAuth ->
|
||||||
|
|
@ -1093,25 +1194,25 @@ class LoginActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestOtp() {
|
private fun requestOtp() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val phoneNumber = phoneInput.text.toString()
|
val phoneNumber = phoneInput.text.toString()
|
||||||
authManager.requestOtp(phoneNumber)
|
authManager.requestOtp(phoneNumber)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
showToast("OTP sent!")
|
showToast("OTP sent!")
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
showError(it.message ?: "Failed to send OTP")
|
showError(it.message ?: "Failed to send OTP")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verifyOtp() {
|
private fun verifyOtp() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val phoneNumber = phoneInput.text.toString()
|
val phoneNumber = phoneInput.text.toString()
|
||||||
val code = otpInput.text.toString()
|
val code = otpInput.text.toString()
|
||||||
|
|
||||||
authManager.verifyOtp(phoneNumber, code)
|
authManager.verifyOtp(phoneNumber, code)
|
||||||
.onSuccess { response ->
|
.onSuccess { response ->
|
||||||
if (response.needsProfile) {
|
if (response.needsProfile) {
|
||||||
|
|
@ -1121,8 +1222,8 @@ class LoginActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
showError("Invalid OTP")
|
showError("Invalid OTP")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,500 @@
|
||||||
|
# Gemini Prompt: Implement JWT Authentication with Refresh Token Rotation
|
||||||
|
|
||||||
|
## Context
|
||||||
|
I have a partially built Android Kotlin application that needs secure JWT authentication with rotating refresh tokens for persistent login. The authentication service is already built and running at `http://localhost:3000`. The app should keep users logged in using secure token storage and automatic token refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Service Details
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:3000` (development)
|
||||||
|
|
||||||
|
**Authentication Flow:**
|
||||||
|
1. User requests OTP via `POST /auth/request-otp`
|
||||||
|
2. User verifies OTP via `POST /auth/verify-otp` → receives `access_token` and `refresh_token`
|
||||||
|
3. Access token (15 min expiry) is used for authenticated API calls
|
||||||
|
4. Refresh token (7 days expiry) is used to get new tokens when access token expires
|
||||||
|
5. Refresh tokens rotate on each use (must save new refresh_token)
|
||||||
|
|
||||||
|
**Key Endpoints:**
|
||||||
|
|
||||||
|
### 1. Request OTP
|
||||||
|
```
|
||||||
|
POST /auth/request-otp
|
||||||
|
Body: { "phone_number": "+919876543210" }
|
||||||
|
Response: { "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verify OTP (Login)
|
||||||
|
```
|
||||||
|
POST /auth/verify-otp
|
||||||
|
Body: {
|
||||||
|
"phone_number": "+919876543210",
|
||||||
|
"code": "123456",
|
||||||
|
"device_id": "android-installation-id",
|
||||||
|
"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: {
|
||||||
|
"user": { "id": "...", "phone_number": "...", "name": null, ... },
|
||||||
|
"access_token": "eyJhbGc...",
|
||||||
|
"refresh_token": "eyJhbGc...",
|
||||||
|
"needs_profile": true,
|
||||||
|
"is_new_device": true,
|
||||||
|
"is_new_account": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Refresh Token (Get New Tokens)
|
||||||
|
```
|
||||||
|
POST /auth/refresh
|
||||||
|
Body: { "refresh_token": "eyJhbGc..." }
|
||||||
|
Response: {
|
||||||
|
"access_token": "eyJhbGc...",
|
||||||
|
"refresh_token": "eyJhbGc..." // NEW token - MUST save this
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Get User Details (Authenticated)
|
||||||
|
```
|
||||||
|
GET /users/me
|
||||||
|
Headers: Authorization: Bearer <access_token>
|
||||||
|
Response: {
|
||||||
|
"id": "...",
|
||||||
|
"phone_number": "+919876543210",
|
||||||
|
"name": "John Doe",
|
||||||
|
"user_type": "seller",
|
||||||
|
"last_login_at": "2024-01-20T14:22:00Z",
|
||||||
|
"location": { ... },
|
||||||
|
"locations": [ ... ],
|
||||||
|
"active_devices_count": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Logout
|
||||||
|
```
|
||||||
|
POST /auth/logout
|
||||||
|
Body: { "refresh_token": "eyJhbGc..." }
|
||||||
|
Response: { "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Requirements
|
||||||
|
|
||||||
|
### 1. Secure Token Storage
|
||||||
|
|
||||||
|
**Use EncryptedSharedPreferences (Android Security Library):**
|
||||||
|
- Store `access_token` and `refresh_token` securely
|
||||||
|
- Never use plain SharedPreferences
|
||||||
|
- Never log tokens in console/logs
|
||||||
|
- Clear tokens on logout or app uninstall
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```kotlin
|
||||||
|
// Use androidx.security:security-crypto:1.1.0-alpha06
|
||||||
|
// Store tokens using EncryptedSharedPreferences with MasterKey
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Token Management
|
||||||
|
|
||||||
|
**Access Token:**
|
||||||
|
- Lifetime: 15 minutes
|
||||||
|
- Used in `Authorization: Bearer <token>` header for all authenticated requests
|
||||||
|
- Automatically refreshed when expired (401 response)
|
||||||
|
|
||||||
|
**Refresh Token:**
|
||||||
|
- Lifetime: 7 days (configurable)
|
||||||
|
- Idle timeout: 3 days (if unused for 3 days, becomes invalid)
|
||||||
|
- **ROTATES on each refresh** - always save the new refresh_token
|
||||||
|
- Stored securely, never sent in URLs or logs
|
||||||
|
|
||||||
|
**Token Refresh Flow:**
|
||||||
|
```
|
||||||
|
1. API call returns 401 (Unauthorized)
|
||||||
|
2. Get refresh_token from secure storage
|
||||||
|
3. Call POST /auth/refresh with refresh_token
|
||||||
|
4. Receive new access_token and new refresh_token
|
||||||
|
5. Save BOTH new tokens securely
|
||||||
|
6. Retry original API call with new access_token
|
||||||
|
7. If refresh fails → clear tokens → redirect to login
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Auto-Refresh Interceptor/Plugin
|
||||||
|
|
||||||
|
**Implement automatic token refresh:**
|
||||||
|
- Intercept all API requests
|
||||||
|
- Add `Authorization: Bearer <access_token>` header automatically
|
||||||
|
- On 401 response:
|
||||||
|
- Refresh token automatically
|
||||||
|
- Retry original request
|
||||||
|
- If refresh fails, clear tokens and redirect to login
|
||||||
|
|
||||||
|
**Use either:**
|
||||||
|
- Ktor: HTTP client interceptor/plugin
|
||||||
|
- Retrofit: OkHttp interceptor
|
||||||
|
|
||||||
|
### 4. Success Page Implementation
|
||||||
|
|
||||||
|
**After successful login (OTP verification), show a Success/Home screen that:**
|
||||||
|
|
||||||
|
1. **Fetches User Details:**
|
||||||
|
- Call `GET /users/me` with access_token
|
||||||
|
- Display: name, phone_number, user_type, last_login_at
|
||||||
|
- Display location if available (city, state, pincode)
|
||||||
|
- Handle loading and error states
|
||||||
|
|
||||||
|
2. **Persistent Login:**
|
||||||
|
- Check for stored tokens on app launch
|
||||||
|
- If tokens exist and valid → auto-login user
|
||||||
|
- If tokens expired → attempt refresh
|
||||||
|
- If refresh fails → show login screen
|
||||||
|
|
||||||
|
3. **Logout Button:**
|
||||||
|
- Clear all tokens from secure storage
|
||||||
|
- Call `POST /auth/logout` with refresh_token
|
||||||
|
- Navigate back to login screen
|
||||||
|
- Show confirmation dialog before logout
|
||||||
|
|
||||||
|
### 5. Security Requirements
|
||||||
|
|
||||||
|
**Critical Security Rules:**
|
||||||
|
1. ✅ Store tokens only in `EncryptedSharedPreferences`
|
||||||
|
2. ✅ Never log tokens or sensitive data
|
||||||
|
3. ✅ Always use HTTPS in production (http://localhost only for dev)
|
||||||
|
4. ✅ Implement certificate pinning for production
|
||||||
|
5. ✅ Handle token reuse detection (if refresh returns 401, force re-login)
|
||||||
|
6. ✅ Clear tokens on logout, app uninstall, or security breach
|
||||||
|
7. ✅ Validate device_id consistently (use Android ID or Installation ID)
|
||||||
|
8. ✅ Handle network errors gracefully without exposing tokens
|
||||||
|
|
||||||
|
### 6. Error Handling
|
||||||
|
|
||||||
|
**Handle these scenarios:**
|
||||||
|
- **401 Unauthorized** → Refresh token → Retry
|
||||||
|
- **401 on refresh** → Clear tokens → Redirect to login (session expired)
|
||||||
|
- **Network errors** → Show user-friendly message, retry option
|
||||||
|
- **Invalid OTP** → Show error, allow retry
|
||||||
|
- **Token expired** → Auto-refresh silently (user shouldn't notice)
|
||||||
|
- **Refresh token expired** → Show "Session expired, please login again"
|
||||||
|
|
||||||
|
### 7. Device Information
|
||||||
|
|
||||||
|
**Send device info during login:**
|
||||||
|
- Use Android ID or Firebase Installation ID for `device_id`
|
||||||
|
- Collect: platform, model, OS version, app version, language, timezone
|
||||||
|
- Send in `device_info` object during OTP verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Structure Requirements
|
||||||
|
|
||||||
|
### Data Models (Kotlinx Serialization)
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
data class User(...)
|
||||||
|
@Serializable
|
||||||
|
data class VerifyOtpResponse(...)
|
||||||
|
@Serializable
|
||||||
|
data class RefreshResponse(...)
|
||||||
|
// Include all necessary models with @SerialName for snake_case JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Manager
|
||||||
|
```kotlin
|
||||||
|
class TokenManager(context: Context) {
|
||||||
|
fun saveTokens(accessToken: String, refreshToken: String)
|
||||||
|
fun getAccessToken(): String?
|
||||||
|
fun getRefreshToken(): String?
|
||||||
|
fun clearTokens()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Client with Auto-Refresh
|
||||||
|
```kotlin
|
||||||
|
class AuthApiClient {
|
||||||
|
// Automatically adds Authorization header
|
||||||
|
// Automatically refreshes token on 401
|
||||||
|
// Handles token rotation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ViewModel Pattern
|
||||||
|
```kotlin
|
||||||
|
class SuccessViewModel : ViewModel() {
|
||||||
|
// Fetch user details
|
||||||
|
// Handle logout
|
||||||
|
// Observe authentication state
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Experience Flow
|
||||||
|
|
||||||
|
1. **App Launch:**
|
||||||
|
- Check for stored tokens
|
||||||
|
- If valid → navigate to Success/Home screen
|
||||||
|
- If invalid/expired → show Login screen
|
||||||
|
|
||||||
|
2. **Login Screen:**
|
||||||
|
- Enter phone number → Request OTP
|
||||||
|
- Enter OTP code → Verify OTP
|
||||||
|
- On success → Save tokens → Navigate to Success screen
|
||||||
|
|
||||||
|
3. **Success/Home Screen:**
|
||||||
|
- Show loading indicator
|
||||||
|
- Fetch user details from `/users/me`
|
||||||
|
- Display: Name, Phone, Profile Type, Location, Last Login
|
||||||
|
- Show logout button
|
||||||
|
- Handle token refresh silently if needed
|
||||||
|
|
||||||
|
4. **Logout:**
|
||||||
|
- Show confirmation dialog
|
||||||
|
- Call logout API
|
||||||
|
- Clear tokens
|
||||||
|
- Navigate to Login screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies Required
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// HTTP Client
|
||||||
|
implementation("io.ktor:ktor-client-android:2.3.5")
|
||||||
|
implementation("io.ktor:ktor-client-content-negotiation:2.3.5")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.5")
|
||||||
|
|
||||||
|
// Secure Storage
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// ViewModel
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Login with OTP and receive tokens
|
||||||
|
- [ ] Tokens stored securely in EncryptedSharedPreferences
|
||||||
|
- [ ] Success page displays user details from `/users/me`
|
||||||
|
- [ ] Access token auto-refreshes when expired (wait 15+ min)
|
||||||
|
- [ ] Refresh token rotates correctly (save new token)
|
||||||
|
- [ ] Logout clears tokens and navigates to login
|
||||||
|
- [ ] App remembers login after restart (if tokens valid)
|
||||||
|
- [ ] Session expires gracefully after 3 days idle
|
||||||
|
- [ ] Network errors handled gracefully
|
||||||
|
- [ ] No tokens logged in console/logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specific Implementation Notes
|
||||||
|
|
||||||
|
1. **Phone Number Format:**
|
||||||
|
- Accept user input (can be 10 digits or with +91)
|
||||||
|
- Server auto-normalizes: `9876543210` → `+919876543210`
|
||||||
|
- Always send in E.164 format
|
||||||
|
|
||||||
|
2. **Device ID:**
|
||||||
|
- Use consistent identifier: Android ID or Firebase Installation ID
|
||||||
|
- Must be 4-128 alphanumeric characters
|
||||||
|
- Server sanitizes invalid IDs
|
||||||
|
|
||||||
|
3. **Token Rotation:**
|
||||||
|
- **CRITICAL:** After refresh, the new `refresh_token` replaces the old one
|
||||||
|
- Old refresh_token becomes invalid immediately
|
||||||
|
- Always save the new refresh_token from refresh response
|
||||||
|
|
||||||
|
4. **Base URL Configuration:**
|
||||||
|
- Development: `http://localhost:3000`
|
||||||
|
- Production: Use environment-based configuration
|
||||||
|
- Consider using BuildConfig for different environments
|
||||||
|
|
||||||
|
5. **API Response Handling:**
|
||||||
|
- All error responses: `{ "error": "message" }`
|
||||||
|
- Success responses vary by endpoint
|
||||||
|
- Always check HTTP status code before parsing JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Validation Checklist
|
||||||
|
|
||||||
|
Before considering the implementation complete, verify:
|
||||||
|
|
||||||
|
- ✅ No tokens in logs/console
|
||||||
|
- ✅ Tokens only in EncryptedSharedPreferences
|
||||||
|
- ✅ Automatic token refresh works
|
||||||
|
- ✅ Token rotation handled correctly
|
||||||
|
- ✅ Tokens cleared on logout
|
||||||
|
- ✅ Session expiry handled
|
||||||
|
- ✅ Network errors don't expose tokens
|
||||||
|
- ✅ HTTPS for production (when deployed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
**On Successful Login:**
|
||||||
|
1. Save `access_token` and `refresh_token` securely
|
||||||
|
2. Navigate to Success/Home screen
|
||||||
|
3. Automatically fetch user details from `/users/me`
|
||||||
|
4. Display user information
|
||||||
|
5. Show logout button
|
||||||
|
|
||||||
|
**On App Restart (with valid tokens):**
|
||||||
|
1. Check stored tokens
|
||||||
|
2. Validate token (or refresh if expired)
|
||||||
|
3. Auto-navigate to Success/Home screen
|
||||||
|
4. Fetch and display user details
|
||||||
|
|
||||||
|
**On Token Expiration:**
|
||||||
|
1. Next API call returns 401
|
||||||
|
2. Automatically refresh token (silently)
|
||||||
|
3. Retry API call with new token
|
||||||
|
4. User experience uninterrupted
|
||||||
|
|
||||||
|
**On Refresh Token Expiration:**
|
||||||
|
1. Refresh attempt fails with 401
|
||||||
|
2. Clear all tokens
|
||||||
|
3. Show "Session expired" message
|
||||||
|
4. Navigate to login screen
|
||||||
|
|
||||||
|
**On Logout:**
|
||||||
|
1. Show confirmation dialog
|
||||||
|
2. Call logout API
|
||||||
|
3. Clear all tokens from storage
|
||||||
|
4. Navigate to login screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Documentation
|
||||||
|
|
||||||
|
The complete API documentation and data models are available in:
|
||||||
|
- `how_to_use_Auth.md` (in your Android project)
|
||||||
|
- Full API reference with request/response examples
|
||||||
|
- All data models with Kotlinx Serialization annotations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
Please implement:
|
||||||
|
|
||||||
|
1. **TokenManager** - Secure token storage using EncryptedSharedPreferences
|
||||||
|
2. **AuthApiClient** - API client with automatic token refresh and rotation
|
||||||
|
3. **Success/Home Screen** - Displays user details from `/users/me`
|
||||||
|
4. **Logout functionality** - With confirmation and proper cleanup
|
||||||
|
5. **Auto-login on app launch** - Check tokens and auto-login if valid
|
||||||
|
6. **Error handling** - Graceful handling of all error scenarios
|
||||||
|
7. **Loading states** - Show loading indicators during API calls
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
- Use Kotlin best practices
|
||||||
|
- Follow MVVM architecture pattern
|
||||||
|
- Use Kotlin Coroutines for async operations
|
||||||
|
- Proper error handling with Result type
|
||||||
|
- Clean, maintainable, and secure code
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- All tokens encrypted at rest
|
||||||
|
- No tokens in logs
|
||||||
|
- Proper token rotation
|
||||||
|
- Secure network communication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This implementation should provide a secure, production-ready authentication system with persistent login capability. The user should be able to login once and remain logged in (until tokens expire or logout) with seamless token refresh happening automatically in the background.
|
||||||
|
|
||||||
|
# Gemini Prompt: JWT Auth with Refresh Token Rotation - Copy This to Gemini
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
I need you to implement secure JWT authentication with rotating refresh tokens in my Android Kotlin app for persistent login. The auth service runs at `http://localhost:3000`.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:3000`
|
||||||
|
|
||||||
|
1. **Request OTP:** `POST /auth/request-otp` → Body: `{ "phone_number": "+919876543210" }`
|
||||||
|
2. **Verify OTP:** `POST /auth/verify-otp` → Returns: `{ "access_token", "refresh_token", "user", ... }`
|
||||||
|
3. **Refresh Token:** `POST /auth/refresh` → Body: `{ "refresh_token": "..." }` → Returns new access_token AND new refresh_token (ROTATES)
|
||||||
|
4. **Get User:** `GET /users/me` → Header: `Authorization: Bearer <access_token>` → Returns user details with location
|
||||||
|
5. **Logout:** `POST /auth/logout` → Body: `{ "refresh_token": "..." }`
|
||||||
|
|
||||||
|
## Critical Requirements
|
||||||
|
|
||||||
|
**Token Storage (SECURITY):**
|
||||||
|
- ✅ Use `EncryptedSharedPreferences` (androidx.security:security-crypto)
|
||||||
|
- ❌ NEVER use plain SharedPreferences
|
||||||
|
- ❌ NEVER log tokens in console/logs
|
||||||
|
- ✅ Clear tokens on logout
|
||||||
|
|
||||||
|
**Token Management:**
|
||||||
|
- Access token: 15 min lifetime, used in `Authorization: Bearer <token>` header
|
||||||
|
- Refresh token: 7 days lifetime, rotates on each refresh (SAVE NEW TOKEN)
|
||||||
|
- Auto-refresh on 401: Get new tokens, retry request, if refresh fails → logout
|
||||||
|
|
||||||
|
**Success/Home Screen:**
|
||||||
|
- After login → Navigate to Success screen
|
||||||
|
- Fetch user details from `GET /users/me` with access_token
|
||||||
|
- Display: name, phone_number, user_type, last_login_at, location
|
||||||
|
- Show logout button with confirmation
|
||||||
|
- Handle loading/error states
|
||||||
|
|
||||||
|
**Persistent Login:**
|
||||||
|
- On app launch: Check stored tokens → If valid, auto-login to Success screen
|
||||||
|
- If tokens expired: Try refresh → If fails, show login screen
|
||||||
|
- User should stay logged in until logout or 7 days of inactivity
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
1. **TokenManager** - Secure storage using EncryptedSharedPreferences
|
||||||
|
2. **AuthApiClient** - With auto-refresh interceptor (handles 401, refreshes, retries)
|
||||||
|
3. **Success/Home Activity/Fragment** - Displays user details from `/users/me`
|
||||||
|
4. **Logout** - Calls logout API, clears tokens, navigates to login
|
||||||
|
5. **Auto-login** - Check tokens on app launch
|
||||||
|
|
||||||
|
## Code Requirements
|
||||||
|
|
||||||
|
- Use Kotlinx Serialization for JSON
|
||||||
|
- Use Ktor or Retrofit for HTTP client
|
||||||
|
- Use MVVM architecture
|
||||||
|
- Use Kotlin Coroutines
|
||||||
|
- Handle all errors gracefully
|
||||||
|
- Show loading indicators
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- ✅ Tokens only in EncryptedSharedPreferences
|
||||||
|
- ✅ Auto-refresh on token expiration
|
||||||
|
- ✅ Token rotation handled (save new refresh_token)
|
||||||
|
- ✅ No tokens in logs
|
||||||
|
- ✅ Clear tokens on logout
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
implementation("io.ktor:ktor-client-android:2.3.5")
|
||||||
|
implementation("io.ktor:ktor-client-content-negotiation:2.3.5")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.5")
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Refresh tokens ROTATE - always save the new refresh_token from refresh response. Reference: See `how_to_use_Auth.md` in the project for complete API documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
@ -4,11 +4,24 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
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.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
import com.example.livingai_lg.ui.AuthState
|
||||||
|
import com.example.livingai_lg.ui.MainViewModel
|
||||||
|
import com.example.livingai_lg.ui.MainViewModelFactory
|
||||||
import com.example.livingai_lg.ui.login.*
|
import com.example.livingai_lg.ui.login.*
|
||||||
import com.example.livingai_lg.ui.theme.LivingAi_LgTheme
|
import com.example.livingai_lg.ui.theme.LivingAi_LgTheme
|
||||||
|
|
||||||
|
|
@ -18,43 +31,64 @@ class MainActivity : ComponentActivity() {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
LivingAi_LgTheme {
|
LivingAi_LgTheme {
|
||||||
val navController = rememberNavController()
|
val mainViewModel: MainViewModel = viewModel(factory = MainViewModelFactory(LocalContext.current))
|
||||||
NavHost(navController = navController, startDestination = "login") {
|
val authState by mainViewModel.authState.collectAsState()
|
||||||
composable("login") {
|
|
||||||
LoginScreen(navController = navController)
|
when (authState) {
|
||||||
|
is AuthState.Unknown -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable("signup") {
|
is AuthState.Authenticated -> {
|
||||||
SignUpScreen(navController = navController)
|
SuccessScreen(mainViewModel)
|
||||||
}
|
}
|
||||||
composable("signin") {
|
is AuthState.Unauthenticated -> {
|
||||||
SignInScreen(navController = navController)
|
AuthNavigation()
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthNavigation() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
NavHost(navController = navController, startDestination = "login") {
|
||||||
|
composable("login") {
|
||||||
|
LoginScreen(navController = navController)
|
||||||
|
}
|
||||||
|
composable("signup") {
|
||||||
|
SignUpScreen(navController = navController)
|
||||||
|
}
|
||||||
|
composable("signin") {
|
||||||
|
SignInScreen(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
package com.example.livingai_lg.api
|
package com.example.livingai_lg.api
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.example.livingai_lg.BuildConfig
|
import com.example.livingai_lg.BuildConfig
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.auth.*
|
||||||
|
import io.ktor.client.plugins.auth.providers.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
|
@ -12,95 +17,101 @@ import kotlinx.serialization.json.Json
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
class AuthApiClient(private val baseUrl: String) {
|
class AuthApiClient(private val context: Context) {
|
||||||
private val client = HttpClient {
|
|
||||||
|
private val tokenManager = TokenManager(context)
|
||||||
|
|
||||||
|
val client = HttpClient(CIO) {
|
||||||
|
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json {
|
json(Json {
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun requestOtp(phoneNumber: String): Result<RequestOtpResponse> {
|
install(Auth) {
|
||||||
return try {
|
bearer {
|
||||||
val response = client.post("$baseUrl/auth/request-otp") {
|
loadTokens {
|
||||||
contentType(ContentType.Application.Json)
|
val accessToken = tokenManager.getAccessToken()
|
||||||
setBody(RequestOtpRequest(phoneNumber))
|
val refreshToken = tokenManager.getRefreshToken()
|
||||||
|
if (accessToken != null && refreshToken != null) {
|
||||||
|
BearerTokens(accessToken, refreshToken)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTokens {
|
||||||
|
val refreshToken = tokenManager.getRefreshToken() ?: return@refreshTokens null
|
||||||
|
|
||||||
|
val response: RefreshResponse = client.post("http://10.0.2.2:3000/auth/refresh") {
|
||||||
|
markAsRefreshTokenRequest()
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(RefreshRequest(refreshToken))
|
||||||
|
}.body()
|
||||||
|
|
||||||
|
tokenManager.saveTokens(response.accessToken, response.refreshToken)
|
||||||
|
|
||||||
|
BearerTokens(response.accessToken, response.refreshToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Result.success(response.body())
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
defaultRequest {
|
||||||
|
url("http://10.0.2.2:3000/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun verifyOtp(
|
// --- API Calls ---
|
||||||
phoneNumber: String,
|
|
||||||
code: String,
|
suspend fun requestOtp(phoneNumber: String): Result<RequestOtpResponse> = runCatching {
|
||||||
deviceId: String
|
client.post("auth/request-otp") {
|
||||||
): Result<VerifyOtpResponse> {
|
contentType(ContentType.Application.Json)
|
||||||
return try {
|
setBody(RequestOtpRequest(phoneNumber))
|
||||||
val request = VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo())
|
}.body()
|
||||||
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<RefreshResponse> {
|
suspend fun verifyOtp(phoneNumber: String, code: String, deviceId: String): Result<VerifyOtpResponse> = runCatching {
|
||||||
return try {
|
val response: VerifyOtpResponse = client.post("auth/verify-otp") {
|
||||||
val request = RefreshRequest(refreshToken)
|
contentType(ContentType.Application.Json)
|
||||||
val response = client.post("$baseUrl/auth/refresh") {
|
setBody(VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo()))
|
||||||
contentType(ContentType.Application.Json)
|
}.body()
|
||||||
setBody(request)
|
|
||||||
}
|
tokenManager.saveTokens(response.accessToken, response.refreshToken)
|
||||||
Result.success(response.body())
|
response
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProfile(
|
suspend fun updateProfile(name: String, userType: String): Result<User> = runCatching {
|
||||||
name: String,
|
client.put("users/me") {
|
||||||
userType: String,
|
contentType(ContentType.Application.Json)
|
||||||
accessToken: String
|
setBody(UpdateProfileRequest(name, userType))
|
||||||
): Result<UpdateProfileResponse> {
|
}.body()
|
||||||
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<Unit> {
|
suspend fun getUserDetails(): Result<UserDetails> = runCatching {
|
||||||
return try {
|
client.get("users/me").body()
|
||||||
val request = RefreshRequest(refreshToken)
|
}
|
||||||
client.post("$baseUrl/auth/logout") {
|
|
||||||
contentType(ContentType.Application.Json)
|
suspend fun logout(): Result<LogoutResponse> = runCatching {
|
||||||
setBody(request)
|
val refreshToken = tokenManager.getRefreshToken() ?: throw IllegalStateException("No refresh token found")
|
||||||
}
|
val response: LogoutResponse = client.post("auth/logout") {
|
||||||
Result.success(Unit)
|
contentType(ContentType.Application.Json)
|
||||||
} catch (e: Exception) {
|
setBody(RefreshRequest(refreshToken))
|
||||||
Result.failure(e)
|
}.body()
|
||||||
}
|
|
||||||
|
tokenManager.clearTokens()
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDeviceInfo(): DeviceInfo {
|
private fun getDeviceInfo(): DeviceInfo {
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
platform = "android",
|
platform = "android",
|
||||||
model = Build.MODEL,
|
model = Build.MODEL,
|
||||||
os_version = Build.VERSION.RELEASE,
|
osVersion = Build.VERSION.RELEASE,
|
||||||
app_version = BuildConfig.VERSION_NAME,
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
language_code = Locale.getDefault().toString(),
|
languageCode = Locale.getDefault().toString(),
|
||||||
timezone = TimeZone.getDefault().id
|
timezone = TimeZone.getDefault().id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,12 @@ class AuthManager(
|
||||||
val deviceId = getDeviceId()
|
val deviceId = getDeviceId()
|
||||||
return apiClient.verifyOtp(phoneNumber, code, deviceId)
|
return apiClient.verifyOtp(phoneNumber, code, deviceId)
|
||||||
.onSuccess { response ->
|
.onSuccess { response ->
|
||||||
tokenManager.saveTokens(response.access_token, response.refresh_token)
|
tokenManager.saveTokens(response.accessToken, response.refreshToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProfile(name: String, userType: String): Result<UpdateProfileResponse> {
|
suspend fun updateProfile(name: String, userType: String): Result<User> {
|
||||||
val accessToken = tokenManager.getAccessToken() ?: return Result.failure(Exception("No access token found"))
|
return apiClient.updateProfile(name, userType)
|
||||||
return apiClient.updateProfile(name, userType, accessToken)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDeviceId(): String {
|
private fun getDeviceId(): String {
|
||||||
|
|
|
||||||
|
|
@ -6,30 +6,39 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
|
|
||||||
class TokenManager(context: Context) {
|
class TokenManager(context: Context) {
|
||||||
|
|
||||||
private val masterKey = MasterKey.Builder(context)
|
private val masterKey = MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
context,
|
context,
|
||||||
"auth_tokens",
|
"secure_auth_prefs",
|
||||||
masterKey,
|
masterKey,
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveTokens(accessToken: String, refreshToken: String) {
|
companion object {
|
||||||
prefs.edit().apply {
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
putString("access_token", accessToken)
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
putString("refresh_token", refreshToken)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAccessToken(): String? = prefs.getString("access_token", null)
|
fun saveTokens(accessToken: String, refreshToken: String) {
|
||||||
fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCESS_TOKEN, accessToken)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAccessToken(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||||
|
|
||||||
|
fun getRefreshToken(): String? = prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||||
|
|
||||||
fun clearTokens() {
|
fun clearTokens() {
|
||||||
prefs.edit().clear().apply()
|
prefs.edit()
|
||||||
|
.remove(KEY_ACCESS_TOKEN)
|
||||||
|
.remove(KEY_REFRESH_TOKEN)
|
||||||
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package com.example.livingai_lg.api
|
package com.example.livingai_lg.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// region: OTP and Login
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RequestOtpRequest(val phone_number: String)
|
data class RequestOtpRequest(@SerialName("phone_number") val phoneNumber: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RequestOtpResponse(val ok: Boolean)
|
data class RequestOtpResponse(val ok: Boolean)
|
||||||
|
|
@ -12,51 +14,77 @@ data class RequestOtpResponse(val ok: Boolean)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
val platform: String,
|
val platform: String,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val os_version: String? = null,
|
@SerialName("os_version") val osVersion: String? = null,
|
||||||
val app_version: String? = null,
|
@SerialName("app_version") val appVersion: String? = null,
|
||||||
val language_code: String? = null,
|
@SerialName("language_code") val languageCode: String? = null,
|
||||||
val timezone: String? = null
|
val timezone: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class VerifyOtpRequest(
|
data class VerifyOtpRequest(
|
||||||
val phone_number: String,
|
@SerialName("phone_number") val phoneNumber: String,
|
||||||
val code: String,
|
val code: String,
|
||||||
val device_id: String,
|
@SerialName("device_id") val deviceId: String,
|
||||||
val device_info: DeviceInfo? = null
|
@SerialName("device_info") val deviceInfo: DeviceInfo? = null
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class User(
|
|
||||||
val id: String,
|
|
||||||
val phone_number: String,
|
|
||||||
val name: String?,
|
|
||||||
val role: String,
|
|
||||||
val user_type: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class VerifyOtpResponse(
|
data class VerifyOtpResponse(
|
||||||
val user: User,
|
val user: User,
|
||||||
val access_token: String,
|
@SerialName("access_token") val accessToken: String,
|
||||||
val refresh_token: String,
|
@SerialName("refresh_token") val refreshToken: String,
|
||||||
val needs_profile: Boolean
|
@SerialName("needs_profile") val needsProfile: Boolean
|
||||||
|
)
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region: Token Refresh
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region: User Profile
|
||||||
|
@Serializable
|
||||||
|
data class UpdateProfileRequest(
|
||||||
|
val name: String,
|
||||||
|
@SerialName("user_type") val userType: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RefreshRequest(val refresh_token: String)
|
data class User(
|
||||||
|
|
||||||
@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 id: String,
|
||||||
val phone_number: String,
|
@SerialName("phone_number") val phoneNumber: String,
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val role: String,
|
val role: String,
|
||||||
val user_type: String?
|
@SerialName("user_type") val userType: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Location(
|
||||||
|
val city: String?,
|
||||||
|
val state: String?,
|
||||||
|
val pincode: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserDetails(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("phone_number") val phoneNumber: String,
|
||||||
|
val name: String?,
|
||||||
|
@SerialName("user_type") val userType: String?,
|
||||||
|
@SerialName("last_login_at") val lastLoginAt: String?,
|
||||||
|
val location: Location?,
|
||||||
|
val locations: List<Location> = emptyList(),
|
||||||
|
@SerialName("active_devices_count") val activeDevicesCount: Int
|
||||||
|
)
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region: Logout
|
||||||
|
@Serializable
|
||||||
|
data class LogoutResponse(val ok: Boolean)
|
||||||
|
// endregion
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package com.example.livingai_lg.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.livingai_lg.api.AuthApiClient
|
||||||
|
import com.example.livingai_lg.api.TokenManager
|
||||||
|
import com.example.livingai_lg.api.UserDetails
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val TAG = "MainViewModel"
|
||||||
|
|
||||||
|
sealed class AuthState {
|
||||||
|
object Unknown : AuthState()
|
||||||
|
object Authenticated : AuthState()
|
||||||
|
object Unauthenticated : AuthState()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UserState {
|
||||||
|
object Loading : UserState()
|
||||||
|
data class Success(val userDetails: UserDetails) : UserState()
|
||||||
|
data class Error(val message: String) : UserState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainViewModel(context: Context) : ViewModel() {
|
||||||
|
|
||||||
|
private val tokenManager = TokenManager(context)
|
||||||
|
private val authApiClient = AuthApiClient(context)
|
||||||
|
|
||||||
|
private val _authState = MutableStateFlow<AuthState>(AuthState.Unknown)
|
||||||
|
val authState = _authState.asStateFlow()
|
||||||
|
|
||||||
|
private val _userState = MutableStateFlow<UserState>(UserState.Loading)
|
||||||
|
val userState = _userState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
checkAuthStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkAuthStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (tokenManager.getAccessToken() != null) {
|
||||||
|
_authState.value = AuthState.Authenticated
|
||||||
|
fetchUserDetails()
|
||||||
|
} else {
|
||||||
|
_authState.value = AuthState.Unauthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchUserDetails() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_userState.value = UserState.Loading
|
||||||
|
authApiClient.getUserDetails()
|
||||||
|
.onSuccess {
|
||||||
|
_userState.value = UserState.Success(it)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_userState.value = UserState.Error(it.message ?: "Unknown error")
|
||||||
|
_authState.value = AuthState.Unauthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Log.d(TAG, "Logout process started")
|
||||||
|
authApiClient.logout()
|
||||||
|
.onSuccess {
|
||||||
|
Log.d(TAG, "Logout successful")
|
||||||
|
_authState.value = AuthState.Unauthenticated
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Log.e(TAG, "Logout failed", it)
|
||||||
|
// Even if the API call fails, force the user to an unauthenticated state
|
||||||
|
_authState.value = AuthState.Unauthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.example.livingai_lg.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
|
||||||
|
class MainViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return MainViewModel(context) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.example.livingai_lg.ui.login
|
package com.example.livingai_lg.ui.login
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -32,9 +33,9 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateProfileScreen(navController: NavController, name: String) {
|
fun CreateProfileScreen(navController: NavController, name: String) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current.applicationContext
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
|
val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) }
|
||||||
|
|
||||||
fun updateProfile(userType: String) {
|
fun updateProfile(userType: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ import kotlinx.coroutines.launch
|
||||||
@Composable
|
@Composable
|
||||||
fun OtpScreen(navController: NavController, phoneNumber: String, name: String) {
|
fun OtpScreen(navController: NavController, phoneNumber: String, name: String) {
|
||||||
val otp = remember { mutableStateOf("") }
|
val otp = remember { mutableStateOf("") }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current.applicationContext
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
|
val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) }
|
||||||
|
|
||||||
// Flag to determine if this is a sign-in flow for an existing user.
|
// Flag to determine if this is a sign-in flow for an existing user.
|
||||||
val isSignInFlow = name == "existing_user"
|
val isSignInFlow = name == "existing_user"
|
||||||
|
|
@ -89,7 +89,7 @@ fun OtpScreen(navController: NavController, phoneNumber: String, name: String) {
|
||||||
navController.navigate("success") { popUpTo("login") { inclusive = true } }
|
navController.navigate("success") { popUpTo("login") { inclusive = true } }
|
||||||
} else {
|
} else {
|
||||||
// For new users, check if a profile needs to be created.
|
// For new users, check if a profile needs to be created.
|
||||||
if (response.needs_profile) {
|
if (response.needsProfile) {
|
||||||
navController.navigate("create_profile/$name")
|
navController.navigate("create_profile/$name")
|
||||||
} else {
|
} else {
|
||||||
navController.navigate("success") { popUpTo("login") { inclusive = true } }
|
navController.navigate("success") { popUpTo("login") { inclusive = true } }
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.example.livingai_lg.ui.login
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
|
@ -18,6 +19,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -33,9 +35,9 @@ import kotlinx.coroutines.launch
|
||||||
fun SignInScreen(navController: NavController) {
|
fun SignInScreen(navController: NavController) {
|
||||||
val phoneNumber = remember { mutableStateOf("") }
|
val phoneNumber = remember { mutableStateOf("") }
|
||||||
val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } }
|
val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current.applicationContext
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) }
|
val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -119,7 +121,6 @@ fun SignInScreen(navController: NavController) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authManager.requestOtp(fullPhoneNumber)
|
authManager.requestOtp(fullPhoneNumber)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
// For existing user, name is not needed, so we pass a placeholder
|
|
||||||
navController.navigate("otp/$fullPhoneNumber/existing_user")
|
navController.navigate("otp/$fullPhoneNumber/existing_user")
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
|
|
@ -139,6 +140,20 @@ fun SignInScreen(navController: NavController) {
|
||||||
) {
|
) {
|
||||||
Text("Sign In", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
Text("Sign In", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
text = "Sign up",
|
||||||
|
color = Color(0xFFE17100),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
modifier = Modifier.clickable { navController.navigate("signup") }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,10 @@ fun SignUpScreen(navController: NavController) {
|
||||||
val name = remember { mutableStateOf("") }
|
val name = remember { mutableStateOf("") }
|
||||||
val phoneNumber = remember { mutableStateOf("") }
|
val phoneNumber = remember { mutableStateOf("") }
|
||||||
val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } }
|
val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current.applicationContext
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Use 10.0.2.2 to connect to host machine's localhost from emulator
|
// 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)) }
|
val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -173,7 +173,7 @@ fun SignUpScreen(navController: NavController) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Row(modifier = Modifier.align(Alignment.CenterHorizontally)) {
|
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(
|
||||||
text = "Sign up",
|
text = "Sign up",
|
||||||
color = Color(0xFFE17100),
|
color = Color(0xFFE17100),
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,30 @@
|
||||||
package com.example.livingai_lg.ui.login
|
package com.example.livingai_lg.ui.login
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.runtime.*
|
||||||
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.example.livingai_lg.ui.MainViewModel
|
||||||
|
import com.example.livingai_lg.ui.UserState
|
||||||
import com.example.livingai_lg.ui.theme.LightCream
|
import com.example.livingai_lg.ui.theme.LightCream
|
||||||
import com.example.livingai_lg.ui.theme.LighterCream
|
import com.example.livingai_lg.ui.theme.LighterCream
|
||||||
import com.example.livingai_lg.ui.theme.LightestGreen
|
import com.example.livingai_lg.ui.theme.LightestGreen
|
||||||
import com.example.livingai_lg.ui.theme.LivingAi_LgTheme
|
import com.example.livingai_lg.ui.MainViewModelFactory // <-- This was the missing import
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SuccessScreen() {
|
fun SuccessScreen(mainViewModel: MainViewModel = viewModel(factory = MainViewModelFactory(LocalContext.current))) {
|
||||||
|
|
||||||
|
val userState by mainViewModel.userState.collectAsState()
|
||||||
|
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -30,27 +35,52 @@ fun SuccessScreen() {
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
when (val state = userState) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
is UserState.Loading -> {
|
||||||
verticalArrangement = Arrangement.Center
|
CircularProgressIndicator()
|
||||||
) {
|
}
|
||||||
Text(
|
is UserState.Success -> {
|
||||||
text = "Success!",
|
Column(
|
||||||
fontSize = 48.sp,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
fontWeight = FontWeight.Bold
|
verticalArrangement = Arrangement.Center
|
||||||
)
|
) {
|
||||||
Text(
|
Text("Welcome, ${state.userDetails.name}!", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
text = "Your profile has been created.",
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
fontSize = 24.sp
|
Text("Phone: ${state.userDetails.phoneNumber}")
|
||||||
|
Text("Profile Type: ${state.userDetails.userType}")
|
||||||
|
Text("Last Login: ${state.userDetails.lastLoginAt}")
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Button(onClick = { showLogoutDialog = true }) {
|
||||||
|
Text("Logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UserState.Error -> {
|
||||||
|
Text("Error: ${state.message}", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLogoutDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showLogoutDialog = false },
|
||||||
|
title = { Text("Confirm Logout") },
|
||||||
|
text = { Text("Are you sure you want to log out?") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
showLogoutDialog = false
|
||||||
|
mainViewModel.logout()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Logout")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Button(onClick = { showLogoutDialog = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun SuccessScreenPreview() {
|
|
||||||
LivingAi_LgTheme {
|
|
||||||
SuccessScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref =
|
||||||
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", 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-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" }
|
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
|
||||||
|
|
||||||
# Kotlinx Serialization
|
# Kotlinx Serialization
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue