getting started auth

This commit is contained in:
androidlover5842
2026-01-26 22:55:22 +05:30
parent 2194502ad6
commit 745a92e579
17 changed files with 611 additions and 90 deletions

View File

@@ -47,6 +47,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
@@ -54,8 +55,6 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
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)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -4,13 +4,14 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.auth.AuthScreen
import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.auth.NameScreen
import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
class MainActivity : ComponentActivity() {
@@ -19,29 +20,22 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
TrisolarisPMSTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
val authViewModel: AuthViewModel = viewModel()
val state by authViewModel.state.collectAsState()
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")
}
}

View File

@@ -2,7 +2,6 @@ package com.android.trisolarispms.data.api
interface ApiService :
AuthApi,
OrgApi,
PropertyApi,
RoomTypeApi,
RoomApi,

View File

@@ -1,10 +1,14 @@
package com.android.trisolarispms.data.api
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 retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Body
interface AuthApi {
@POST("auth/verify")
@@ -12,4 +16,7 @@ interface AuthApi {
@GET("auth/me")
suspend fun me(): Response<UserDto>
@PUT("auth/me")
suspend fun updateMe(@Body body: AuthMeUpdateRequest): Response<AppUserDto>
}

View File

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

View File

@@ -1,11 +1,11 @@
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.PropertyDto
import com.android.trisolarispms.data.api.model.PropertyUpdateRequest
import com.android.trisolarispms.data.api.model.UserDto
import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest
import com.android.trisolarispms.data.api.model.ActionResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
@@ -15,14 +15,11 @@ import retrofit2.http.PUT
import retrofit2.http.Path
interface PropertyApi {
@POST("orgs/{orgId}/properties")
suspend fun createProperty(
@Path("orgId") orgId: String,
@Body body: PropertyCreateRequest
): Response<PropertyDto>
@POST("properties")
suspend fun createProperty(@Body body: PropertyCreateRequest): Response<PropertyDto>
@GET("orgs/{orgId}/properties")
suspend fun listProperties(@Path("orgId") orgId: String): Response<List<PropertyDto>>
@GET("properties")
suspend fun listProperties(): Response<List<PropertyDto>>
@PUT("properties/{propertyId}")
suspend fun updateProperty(
@@ -30,9 +27,6 @@ interface PropertyApi {
@Body body: PropertyUpdateRequest
): Response<PropertyDto>
@GET("orgs/{orgId}/users")
suspend fun listOrgUsers(@Path("orgId") orgId: String): Response<List<UserDto>>
@GET("properties/{propertyId}/users")
suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<UserDto>>

View File

@@ -1,7 +1,26 @@
package com.android.trisolarispms.data.api.model
data class AuthVerifyResponse(
val user: UserDto? = null,
val orgId: String? = null,
val propertyId: String? = null
val status: String? = null,
val user: AppUserDto? = 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
)

View File

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

View File

@@ -25,7 +25,6 @@ data class PropertyUpdateRequest(
data class PropertyDto(
val id: String? = null,
val orgId: String? = null,
val code: String? = null,
val name: String? = null,
val addressText: String? = null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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