getting started auth
This commit is contained in:
@@ -47,6 +47,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.retrofit.converter.gson)
|
implementation(libs.retrofit.converter.gson)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
@@ -54,8 +55,6 @@ dependencies {
|
|||||||
implementation(platform(libs.firebase.bom))
|
implementation(platform(libs.firebase.bom))
|
||||||
implementation(libs.firebase.auth.ktx)
|
implementation(libs.firebase.auth.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
implementation(platform("com.google.firebase:firebase-bom:34.8.0"))
|
|
||||||
implementation(libs.google.firebase.auth.ktx)
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ 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.fillMaxSize
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.compose.material3.Text
|
import com.android.trisolarispms.ui.auth.AuthScreen
|
||||||
import androidx.compose.runtime.Composable
|
import com.android.trisolarispms.ui.auth.AuthViewModel
|
||||||
import androidx.compose.ui.Modifier
|
import com.android.trisolarispms.ui.auth.NameScreen
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import com.android.trisolarispms.ui.auth.UnauthorizedScreen
|
||||||
|
import com.android.trisolarispms.ui.home.HomeScreen
|
||||||
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
|
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -19,29 +20,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
TrisolarisPMSTheme {
|
TrisolarisPMSTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val authViewModel: AuthViewModel = viewModel()
|
||||||
Greeting(
|
val state by authViewModel.state.collectAsState()
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
if (state.unauthorized) {
|
||||||
|
UnauthorizedScreen(
|
||||||
|
message = state.error ?: "Not authorized. Contact admin.",
|
||||||
|
onSignOut = authViewModel::signOut
|
||||||
)
|
)
|
||||||
|
} else if (state.apiVerified && state.needsName) {
|
||||||
|
NameScreen(viewModel = authViewModel)
|
||||||
|
} else if (state.apiVerified) {
|
||||||
|
HomeScreen(userId = state.userId, userName = state.userName)
|
||||||
|
} else {
|
||||||
|
AuthScreen(viewModel = authViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
text = "Hello $name!",
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun GreetingPreview() {
|
|
||||||
TrisolarisPMSTheme {
|
|
||||||
Greeting("Android")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package com.android.trisolarispms.data.api
|
|||||||
|
|
||||||
interface ApiService :
|
interface ApiService :
|
||||||
AuthApi,
|
AuthApi,
|
||||||
OrgApi,
|
|
||||||
PropertyApi,
|
PropertyApi,
|
||||||
RoomTypeApi,
|
RoomTypeApi,
|
||||||
RoomApi,
|
RoomApi,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.AuthVerifyResponse
|
import com.android.trisolarispms.data.api.model.AuthVerifyResponse
|
||||||
|
import com.android.trisolarispms.data.api.model.AuthMeUpdateRequest
|
||||||
|
import com.android.trisolarispms.data.api.model.AppUserDto
|
||||||
import com.android.trisolarispms.data.api.model.UserDto
|
import com.android.trisolarispms.data.api.model.UserDto
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Body
|
||||||
|
|
||||||
interface AuthApi {
|
interface AuthApi {
|
||||||
@POST("auth/verify")
|
@POST("auth/verify")
|
||||||
@@ -12,4 +16,7 @@ interface AuthApi {
|
|||||||
|
|
||||||
@GET("auth/me")
|
@GET("auth/me")
|
||||||
suspend fun me(): Response<UserDto>
|
suspend fun me(): Response<UserDto>
|
||||||
|
|
||||||
|
@PUT("auth/me")
|
||||||
|
suspend fun updateMe(@Body body: AuthMeUpdateRequest): Response<AppUserDto>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.android.trisolarispms.data.api
|
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.OrgCreateRequest
|
|
||||||
import com.android.trisolarispms.data.api.model.OrgDto
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Path
|
|
||||||
|
|
||||||
interface OrgApi {
|
|
||||||
@POST("orgs")
|
|
||||||
suspend fun createOrg(@Body body: OrgCreateRequest): Response<OrgDto>
|
|
||||||
|
|
||||||
@GET("orgs/{orgId}")
|
|
||||||
suspend fun getOrg(@Path("orgId") orgId: String): Response<OrgDto>
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
|
import com.android.trisolarispms.data.api.model.ActionResponse
|
||||||
import com.android.trisolarispms.data.api.model.PropertyCreateRequest
|
import com.android.trisolarispms.data.api.model.PropertyCreateRequest
|
||||||
import com.android.trisolarispms.data.api.model.PropertyDto
|
import com.android.trisolarispms.data.api.model.PropertyDto
|
||||||
import com.android.trisolarispms.data.api.model.PropertyUpdateRequest
|
import com.android.trisolarispms.data.api.model.PropertyUpdateRequest
|
||||||
import com.android.trisolarispms.data.api.model.UserDto
|
import com.android.trisolarispms.data.api.model.UserDto
|
||||||
import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest
|
import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest
|
||||||
import com.android.trisolarispms.data.api.model.ActionResponse
|
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
@@ -15,14 +15,11 @@ import retrofit2.http.PUT
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
interface PropertyApi {
|
interface PropertyApi {
|
||||||
@POST("orgs/{orgId}/properties")
|
@POST("properties")
|
||||||
suspend fun createProperty(
|
suspend fun createProperty(@Body body: PropertyCreateRequest): Response<PropertyDto>
|
||||||
@Path("orgId") orgId: String,
|
|
||||||
@Body body: PropertyCreateRequest
|
|
||||||
): Response<PropertyDto>
|
|
||||||
|
|
||||||
@GET("orgs/{orgId}/properties")
|
@GET("properties")
|
||||||
suspend fun listProperties(@Path("orgId") orgId: String): Response<List<PropertyDto>>
|
suspend fun listProperties(): Response<List<PropertyDto>>
|
||||||
|
|
||||||
@PUT("properties/{propertyId}")
|
@PUT("properties/{propertyId}")
|
||||||
suspend fun updateProperty(
|
suspend fun updateProperty(
|
||||||
@@ -30,9 +27,6 @@ interface PropertyApi {
|
|||||||
@Body body: PropertyUpdateRequest
|
@Body body: PropertyUpdateRequest
|
||||||
): Response<PropertyDto>
|
): Response<PropertyDto>
|
||||||
|
|
||||||
@GET("orgs/{orgId}/users")
|
|
||||||
suspend fun listOrgUsers(@Path("orgId") orgId: String): Response<List<UserDto>>
|
|
||||||
|
|
||||||
@GET("properties/{propertyId}/users")
|
@GET("properties/{propertyId}/users")
|
||||||
suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<UserDto>>
|
suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<UserDto>>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
package com.android.trisolarispms.data.api.model
|
package com.android.trisolarispms.data.api.model
|
||||||
|
|
||||||
data class AuthVerifyResponse(
|
data class AuthVerifyResponse(
|
||||||
val user: UserDto? = null,
|
val status: String? = null,
|
||||||
val orgId: String? = null,
|
val user: AppUserDto? = null,
|
||||||
val propertyId: String? = null
|
val properties: List<PropertyUserDto>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthMeUpdateRequest(
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AppUserDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val firebaseUid: String? = null,
|
||||||
|
val phoneE164: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val disabled: Boolean? = null,
|
||||||
|
val superAdmin: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PropertyUserDto(
|
||||||
|
val userId: String? = null,
|
||||||
|
val propertyId: String? = null,
|
||||||
|
val roles: List<String>? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.android.trisolarispms.data.api.model
|
|
||||||
|
|
||||||
data class OrgCreateRequest(
|
|
||||||
val name: String,
|
|
||||||
val emailAliases: List<String>? = null,
|
|
||||||
val allowedTransportModes: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OrgDto(
|
|
||||||
val id: String? = null,
|
|
||||||
val name: String? = null,
|
|
||||||
val emailAliases: List<String>? = null,
|
|
||||||
val allowedTransportModes: List<String>? = null
|
|
||||||
)
|
|
||||||
@@ -25,7 +25,6 @@ data class PropertyUpdateRequest(
|
|||||||
|
|
||||||
data class PropertyDto(
|
data class PropertyDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val orgId: String? = null,
|
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.android.trisolarispms.ui.auth
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as? ComponentActivity
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.phone,
|
||||||
|
onValueChange = viewModel::onPhoneChange,
|
||||||
|
label = { Text("Phone number") },
|
||||||
|
placeholder = { Text("10-digit mobile") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
prefix = { Text(state.countryCode) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Default country: India (+91)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (activity != null) {
|
||||||
|
viewModel.sendCode(activity)
|
||||||
|
} else {
|
||||||
|
viewModel.reportError("Unable to access activity for phone auth")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !state.isLoading
|
||||||
|
) {
|
||||||
|
Text(if (state.isCodeSent) "Resend code" else "Send code")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.code,
|
||||||
|
onValueChange = viewModel::onCodeChange,
|
||||||
|
label = { Text("Verification code") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::verifyCode,
|
||||||
|
enabled = state.isCodeSent && !state.isLoading
|
||||||
|
) {
|
||||||
|
Text("Verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { message ->
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(text = message)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::clearError,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.userId != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(text = "Firebase user: ${state.userId}")
|
||||||
|
Text(text = "API verified: ${state.apiVerified}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.noProperties) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "No properties yet. Create a property to continue.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.android.trisolarispms.ui.auth
|
||||||
|
|
||||||
|
data class AuthUiState(
|
||||||
|
val countryCode: String = "+91",
|
||||||
|
val phone: String = "",
|
||||||
|
val code: String = "",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isCodeSent: Boolean = false,
|
||||||
|
val verificationId: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val userId: String? = null,
|
||||||
|
val apiVerified: Boolean = false,
|
||||||
|
val isSuperAdmin: Boolean = false,
|
||||||
|
val noProperties: Boolean = false,
|
||||||
|
val userName: String? = null,
|
||||||
|
val nameInput: String = "",
|
||||||
|
val needsName: Boolean = false,
|
||||||
|
val unauthorized: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package com.android.trisolarispms.ui.auth
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.android.trisolarispms.data.api.ApiClient
|
||||||
|
import com.google.firebase.FirebaseException
|
||||||
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
|
import com.google.firebase.auth.PhoneAuthCredential
|
||||||
|
import com.google.firebase.auth.PhoneAuthOptions
|
||||||
|
import com.google.firebase.auth.PhoneAuthProvider
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class AuthViewModel(
|
||||||
|
private val auth: FirebaseAuth = FirebaseAuth.getInstance()
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(AuthUiState())
|
||||||
|
val state: StateFlow<AuthUiState> = _state
|
||||||
|
|
||||||
|
private var resendToken: PhoneAuthProvider.ForceResendingToken? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
val user = auth.currentUser
|
||||||
|
if (user != null) {
|
||||||
|
verifyExistingSession(user.uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPhoneChange(value: String) {
|
||||||
|
val digits = value.filter { it.isDigit() }.take(10)
|
||||||
|
_state.update { it.copy(phone = digits, error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCodeChange(value: String) {
|
||||||
|
_state.update { it.copy(code = value, error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNameChange(value: String) {
|
||||||
|
_state.update { it.copy(nameInput = value, error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportError(message: String) {
|
||||||
|
setError(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setError(message: String) {
|
||||||
|
_state.update { it.copy(isLoading = false, error = message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendCode(activity: ComponentActivity) {
|
||||||
|
val phone = buildE164Phone()
|
||||||
|
if (phone == null) {
|
||||||
|
setError("Enter a valid 10-digit phone number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
|
||||||
|
val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
|
||||||
|
override fun onVerificationCompleted(credential: PhoneAuthCredential) {
|
||||||
|
signInWithCredential(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVerificationFailed(e: FirebaseException) {
|
||||||
|
setError(e.localizedMessage ?: "Verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCodeSent(
|
||||||
|
verificationId: String,
|
||||||
|
token: PhoneAuthProvider.ForceResendingToken
|
||||||
|
) {
|
||||||
|
resendToken = token
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isCodeSent = true,
|
||||||
|
verificationId = verificationId,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = PhoneAuthOptions.newBuilder(auth)
|
||||||
|
.setPhoneNumber(phone)
|
||||||
|
.setTimeout(60L, TimeUnit.SECONDS)
|
||||||
|
.setActivity(activity)
|
||||||
|
.setCallbacks(callbacks)
|
||||||
|
.apply {
|
||||||
|
resendToken?.let { setForceResendingToken(it) }
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
PhoneAuthProvider.verifyPhoneNumber(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildE164Phone(): String? {
|
||||||
|
val digits = state.value.phone.trim()
|
||||||
|
if (digits.length != 10) return null
|
||||||
|
return "${state.value.countryCode}$digits"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyCode() {
|
||||||
|
val verificationId = state.value.verificationId
|
||||||
|
val code = state.value.code.trim()
|
||||||
|
|
||||||
|
if (verificationId.isNullOrBlank()) {
|
||||||
|
setError("Request a code first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (code.isBlank()) {
|
||||||
|
setError("Enter the code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val credential = PhoneAuthProvider.getCredential(verificationId, code)
|
||||||
|
signInWithCredential(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signInWithCredential(credential: PhoneAuthCredential) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
try {
|
||||||
|
val result = auth.signInWithCredential(credential).await()
|
||||||
|
val userId = result.user?.uid
|
||||||
|
val api = ApiClient.create(auth = auth)
|
||||||
|
val response = api.verifyAuth()
|
||||||
|
handleVerifyResponse(userId, response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
setError(e.localizedMessage ?: "Sign-in failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyExistingSession(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null, userId = userId) }
|
||||||
|
try {
|
||||||
|
val api = ApiClient.create(auth = auth)
|
||||||
|
val response = api.verifyAuth()
|
||||||
|
handleVerifyResponse(userId, response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
setError(e.localizedMessage ?: "Session verify failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleVerifyResponse(
|
||||||
|
userId: String?,
|
||||||
|
response: retrofit2.Response<com.android.trisolarispms.data.api.model.AuthVerifyResponse>
|
||||||
|
) {
|
||||||
|
val body = response.body()
|
||||||
|
val userName = body?.user?.name
|
||||||
|
val isSuperAdmin = body?.user?.superAdmin == true
|
||||||
|
when {
|
||||||
|
response.isSuccessful && (body?.status == "OK" || body?.status == "SUPER_ADMIN") -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
userId = userId,
|
||||||
|
apiVerified = true,
|
||||||
|
needsName = userName.isNullOrBlank(),
|
||||||
|
nameInput = userName ?: "",
|
||||||
|
userName = userName,
|
||||||
|
isSuperAdmin = isSuperAdmin,
|
||||||
|
noProperties = body?.status == "NO_PROPERTIES",
|
||||||
|
unauthorized = false,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.isSuccessful && body?.status == "NO_PROPERTIES" -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
userId = userId,
|
||||||
|
apiVerified = true,
|
||||||
|
needsName = userName.isNullOrBlank(),
|
||||||
|
nameInput = userName ?: "",
|
||||||
|
userName = userName,
|
||||||
|
isSuperAdmin = isSuperAdmin,
|
||||||
|
noProperties = true,
|
||||||
|
unauthorized = false,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.code() == 401 -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
apiVerified = false,
|
||||||
|
isSuperAdmin = false,
|
||||||
|
noProperties = false,
|
||||||
|
unauthorized = true,
|
||||||
|
error = "Not authorized. Contact admin."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
apiVerified = false,
|
||||||
|
isSuperAdmin = false,
|
||||||
|
noProperties = false,
|
||||||
|
unauthorized = false,
|
||||||
|
error = "API verify failed: ${response.code()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
auth.signOut()
|
||||||
|
_state.update { AuthUiState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitName() {
|
||||||
|
val name = state.value.nameInput.trim()
|
||||||
|
if (name.isBlank()) {
|
||||||
|
setError("Please enter your name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
try {
|
||||||
|
val api = ApiClient.create(auth = auth)
|
||||||
|
val body = com.android.trisolarispms.data.api.model.AuthMeUpdateRequest(name = name)
|
||||||
|
val response = api.updateMe(body)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
needsName = false,
|
||||||
|
userName = name,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Update failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
setError(e.localizedMessage ?: "Update failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.android.trisolarispms.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NameScreen(viewModel: AuthViewModel = viewModel()) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(text = "Finish setup", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.nameInput,
|
||||||
|
onValueChange = viewModel::onNameChange,
|
||||||
|
label = { Text("Your name") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::submitName,
|
||||||
|
enabled = !state.isLoading
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.android.trisolarispms.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnauthorizedScreen(message: String, onSignOut: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(text = "Not authorized", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(text = message, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Button(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Sign out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.android.trisolarispms.ui.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(userId: String?, userName: String?) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome"
|
||||||
|
Text(text = title, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
if (userId != null) {
|
||||||
|
Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
alias(libs.plugins.google.services) apply false
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.0.0"
|
agp = "9.0.0"
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.17.0"
|
||||||
|
firebaseBomVersion = "34.8.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.5.1"
|
espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.6.1"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.12.2"
|
||||||
kotlin = "2.0.21"
|
kotlin = "2.3.0"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2026.01.00"
|
||||||
retrofit = "2.11.0"
|
retrofit = "3.0.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "5.3.2"
|
||||||
firebaseBom = "33.5.1"
|
firebaseBom = "34.8.0"
|
||||||
coroutinesPlayServices = "1.7.3"
|
coroutinesPlayServices = "1.10.2"
|
||||||
|
googleServices = "4.4.4"
|
||||||
|
lifecycleViewModelCompose = "2.10.0"
|
||||||
firebaseAuthKtx = "24.0.1"
|
firebaseAuthKtx = "24.0.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -20,6 +23,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModelCompose" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
@@ -34,10 +38,10 @@ retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-
|
|||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||||
firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth-ktx" }
|
firebase-auth-ktx = { module = "com.google.firebase:firebase-auth" }
|
||||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
|
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
|
||||||
google-firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth-ktx", version.ref = "firebaseAuthKtx" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
|
||||||
|
|||||||
Reference in New Issue
Block a user