diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index b87f625..89b66f6 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.viewmodel.compose.viewModel import com.android.trisolarispms.ui.AppRoute import com.android.trisolarispms.ui.auth.AuthScreen @@ -38,6 +39,9 @@ import com.android.trisolarispms.ui.roomimage.EditImageTagScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen import com.android.trisolarispms.ui.razorpay.RazorpayQrScreen +import com.android.trisolarispms.ui.users.PropertyUsersScreen +import com.android.trisolarispms.ui.users.PropertyAccessCodeScreen +import com.android.trisolarispms.ui.users.SuperAdminUserDirectoryScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AmenitiesScreen @@ -78,28 +82,69 @@ class MainActivity : ComponentActivity() { val roomFormKey = remember { mutableStateOf(0) } val amenitiesReturnRoute = remember { mutableStateOf(AppRoute.Home) } val currentRoute = route.value + val singlePropertyId = state.propertyRoles.keys.firstOrNull() + val singlePropertyIsAdmin = singlePropertyId?.let { + state.propertyRoles[it]?.contains("ADMIN") == true + } ?: false val canManageProperty: (String) -> Boolean = { propertyId -> state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true) } + val canIssueTemporaryCard: (String) -> Boolean = { propertyId -> + state.isSuperAdmin || state.propertyRoles[propertyId]?.any { + it == "ADMIN" || it == "MANAGER" + } == true + } val canViewCardInfo: (String) -> Boolean = { propertyId -> state.isSuperAdmin || state.propertyRoles[propertyId]?.any { it == "ADMIN" || it == "MANAGER" || it == "STAFF" } == true } val canManageRazorpaySettings: (String) -> Boolean = { propertyId -> - state.isSuperAdmin || state.propertyRoles[propertyId]?.any { - it == "ADMIN" || it == "MANAGER" - } == true + state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true } val canDeleteCashPayment: (String) -> Boolean = { propertyId -> state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true } + val canManagePropertyUsers: (String) -> Boolean = { propertyId -> + state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true + } + val canCreateBookingFor: (String) -> Boolean = { propertyId -> + state.isSuperAdmin || state.propertyRoles[propertyId]?.any { + it == "ADMIN" || it == "MANAGER" || it == "STAFF" + } == true + } + val allowedAccessCodeRoles: (String) -> List = { propertyId -> + if (state.isSuperAdmin) { + listOf("MANAGER", "STAFF", "AGENT") + } else { + val roles = state.propertyRoles[propertyId].orEmpty() + when { + roles.contains("ADMIN") -> listOf("MANAGER", "STAFF", "AGENT") + roles.contains("MANAGER") -> listOf("STAFF", "AGENT") + else -> emptyList() + } + } + } BackHandler(enabled = currentRoute != AppRoute.Home) { when (currentRoute) { AppRoute.Home -> Unit AppRoute.AddProperty -> route.value = AppRoute.Home - is AppRoute.ActiveRoomStays -> route.value = AppRoute.Home + AppRoute.SuperAdminUsers -> route.value = AppRoute.Home + is AppRoute.ActiveRoomStays -> { + val isAdmin = state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true + val blockBack = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size == 1 + if (!blockBack) { + route.value = AppRoute.Home + } + } + is AppRoute.PropertyUsers -> route.value = AppRoute.ActiveRoomStays( + currentRoute.propertyId, + selectedPropertyName.value ?: "Property" + ) + is AppRoute.PropertyAccessCode -> route.value = AppRoute.PropertyUsers( + currentRoute.propertyId + ) is AppRoute.Rooms -> route.value = AppRoute.ActiveRoomStays( currentRoute.propertyId, selectedPropertyName.value ?: "Property" @@ -185,11 +230,28 @@ class MainActivity : ComponentActivity() { } } + if (currentRoute == AppRoute.Home && + !state.isSuperAdmin && + !singlePropertyIsAdmin && + state.propertyRoles.size == 1 && + singlePropertyId != null + ) { + LaunchedEffect(singlePropertyId) { + selectedPropertyId.value = singlePropertyId + route.value = AppRoute.ActiveRoomStays( + singlePropertyId, + selectedPropertyName.value ?: "Property" + ) + } + } + when (currentRoute) { AppRoute.Home -> HomeScreen( userId = state.userId, userName = state.userName, isSuperAdmin = state.isSuperAdmin, + onUserDirectory = { route.value = AppRoute.SuperAdminUsers }, + onLogout = authViewModel::signOut, onAddProperty = { route.value = AppRoute.AddProperty }, onAmenities = { amenitiesReturnRoute.value = AppRoute.Home @@ -203,7 +265,20 @@ class MainActivity : ComponentActivity() { selectedPropertyName.value = name route.value = AppRoute.ActiveRoomStays(id, name) }, - onRefreshProfile = authViewModel::refreshMe + onRefreshProfile = authViewModel::refreshMe, + showJoinProperty = state.propertyRoles.isEmpty() && !state.isSuperAdmin, + onJoinPropertySuccess = { joinedPropertyId, joinedRoles -> + refreshKey.value++ + authViewModel.refreshMe() + val isAdmin = joinedRoles.contains("ADMIN") + val shouldAutoOpen = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size <= 1 + if (shouldAutoOpen) { + route.value = AppRoute.ActiveRoomStays( + joinedPropertyId, + selectedPropertyName.value ?: "Property" + ) + } + } ) AppRoute.AddProperty -> AddPropertyScreen( onBack = { route.value = AppRoute.Home }, @@ -275,11 +350,26 @@ class MainActivity : ComponentActivity() { is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen( propertyId = currentRoute.propertyId, propertyName = currentRoute.propertyName, - onBack = { route.value = AppRoute.Home }, + onBack = { + val isAdmin = state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true + val blockBack = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size == 1 + if (!blockBack) { + route.value = AppRoute.Home + } + }, + showBack = run { + val isAdmin = state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true + val blockBack = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size == 1 + !blockBack + }, onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, + canCreateBooking = canCreateBookingFor(currentRoute.propertyId), showRazorpaySettings = canManageRazorpaySettings(currentRoute.propertyId), onRazorpaySettings = { route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) }, + showUserAdmin = canManagePropertyUsers(currentRoute.propertyId), + onUserAdmin = { route.value = AppRoute.PropertyUsers(currentRoute.propertyId) }, + onLogout = authViewModel::signOut, onManageRoomStay = { booking -> val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } ?: booking.expectedCheckInAt.orEmpty() @@ -500,6 +590,37 @@ class MainActivity : ComponentActivity() { ) } ) + AppRoute.SuperAdminUsers -> SuperAdminUserDirectoryScreen( + onBack = { route.value = AppRoute.Home } + ) + is AppRoute.PropertyUsers -> PropertyUsersScreen( + propertyId = currentRoute.propertyId, + allowedRoleAssignments = when { + state.isSuperAdmin -> listOf("ADMIN", "MANAGER", "STAFF", "AGENT") + state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true -> + listOf("ADMIN", "MANAGER", "STAFF", "AGENT") + state.propertyRoles[currentRoute.propertyId]?.contains("MANAGER") == true -> + listOf("STAFF", "AGENT") + else -> emptyList() + }, + canDisableAdmin = state.isSuperAdmin || + state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true, + canDisableManager = state.propertyRoles[currentRoute.propertyId]?.contains("MANAGER") == true, + onBack = { + route.value = AppRoute.ActiveRoomStays( + currentRoute.propertyId, + selectedPropertyName.value ?: "Property" + ) + }, + onOpenAccessCode = { + route.value = AppRoute.PropertyAccessCode(currentRoute.propertyId) + } + ) + is AppRoute.PropertyAccessCode -> PropertyAccessCodeScreen( + propertyId = currentRoute.propertyId, + allowedRoles = allowedAccessCodeRoles(currentRoute.propertyId), + onBack = { route.value = AppRoute.PropertyUsers(currentRoute.propertyId) } + ) is AppRoute.Rooms -> RoomsScreen( propertyId = currentRoute.propertyId, onBack = { @@ -516,13 +637,14 @@ class MainActivity : ComponentActivity() { onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) }, canManageRooms = canManageProperty(currentRoute.propertyId), canViewCardInfo = canViewCardInfo(currentRoute.propertyId), + canIssueTemporaryCard = canIssueTemporaryCard(currentRoute.propertyId), onEditRoom = { selectedRoom.value = it roomFormKey.value++ route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "") }, onIssueTemporaryCard = { - if (it.id != null) { + if (it.id != null && canIssueTemporaryCard(currentRoute.propertyId)) { selectedRoom.value = it route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id) } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt index b1be5b4..5f425be 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt @@ -16,4 +16,5 @@ interface ApiService : InboundEmailApi, AmenityApi, RatePlanApi, - RazorpaySettingsApi + RazorpaySettingsApi, + UserAdminApi diff --git a/app/src/main/java/com/android/trisolarispms/data/api/PropertyApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/PropertyApi.kt index 45ad3ee..b814066 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/PropertyApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/PropertyApi.kt @@ -4,7 +4,9 @@ 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.PropertyUserResponse +import com.android.trisolarispms.data.api.model.PropertyUserDisabledRequest +import com.android.trisolarispms.data.api.model.PropertyCodeResponse import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest import retrofit2.Response import retrofit2.http.Body @@ -28,18 +30,30 @@ interface PropertyApi { ): Response @GET("properties/{propertyId}/users") - suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response> + suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response> @PUT("properties/{propertyId}/users/{userId}/roles") suspend fun updateUserRoles( @Path("propertyId") propertyId: String, @Path("userId") userId: String, @Body body: UserRolesUpdateRequest - ): Response + ): Response @DELETE("properties/{propertyId}/users/{userId}") suspend fun deletePropertyUser( @Path("propertyId") propertyId: String, @Path("userId") userId: String ): Response + + @GET("properties/{propertyId}/code") + suspend fun getPropertyCode( + @Path("propertyId") propertyId: String + ): Response + + @PUT("properties/{propertyId}/users/{userId}/disabled") + suspend fun updateUserDisabled( + @Path("propertyId") propertyId: String, + @Path("userId") userId: String, + @Body body: PropertyUserDisabledRequest + ): Response } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/UserAdminApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/UserAdminApi.kt new file mode 100644 index 0000000..f2b75a6 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/UserAdminApi.kt @@ -0,0 +1,38 @@ +package com.android.trisolarispms.data.api + +import com.android.trisolarispms.data.api.model.AppUserSummaryResponse +import com.android.trisolarispms.data.api.model.PropertyAccessCodeCreateRequest +import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest +import com.android.trisolarispms.data.api.model.PropertyAccessCodeResponse +import com.android.trisolarispms.data.api.model.PropertyUserDetailsResponse +import com.android.trisolarispms.data.api.model.PropertyUserResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface UserAdminApi { + @GET("users") + suspend fun listUsers( + @Query("phone") phone: String? = null + ): Response> + + @GET("properties/{propertyId}/users/search") + suspend fun searchPropertyUsers( + @Path("propertyId") propertyId: String, + @Query("phone") phone: String? = null + ): Response> + + @POST("properties/{propertyId}/access-codes") + suspend fun createAccessCode( + @Path("propertyId") propertyId: String, + @Body body: PropertyAccessCodeCreateRequest + ): Response + + @POST("properties/access-codes/join") + suspend fun joinAccessCode( + @Body body: PropertyAccessCodeJoinRequest + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt index 167d186..e6e2957 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt @@ -1,7 +1,7 @@ package com.android.trisolarispms.data.api.model data class PropertyCreateRequest( - val code: String, + val code: String? = null, val name: String, val addressText: String? = null, val timezone: String? = null, @@ -36,3 +36,7 @@ data class PropertyDto( val emailAddresses: List? = null, val allowedTransportModes: List? = null ) + +data class PropertyCodeResponse( + val code: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/UserAdminModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/UserAdminModels.kt new file mode 100644 index 0000000..2bea6a5 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/UserAdminModels.kt @@ -0,0 +1,45 @@ +package com.android.trisolarispms.data.api.model + +data class AppUserSummaryResponse( + val id: String? = null, + val phoneE164: String? = null, + val name: String? = null, + val disabled: Boolean = false, + val superAdmin: Boolean = false +) + +data class PropertyUserDetailsResponse( + val userId: String? = null, + val propertyId: String? = null, + val roles: List = emptyList(), + val name: String? = null, + val phoneE164: String? = null, + val disabled: Boolean = false, + val superAdmin: Boolean = false +) + +data class PropertyAccessCodeCreateRequest( + val roles: List +) + +data class PropertyAccessCodeResponse( + val propertyId: String? = null, + val code: String? = null, + val expiresAt: String? = null, + val roles: List = emptyList() +) + +data class PropertyAccessCodeJoinRequest( + val propertyId: String, + val code: String +) + +data class PropertyUserResponse( + val userId: String? = null, + val propertyId: String? = null, + val roles: List = emptyList() +) + +data class PropertyUserDisabledRequest( + val disabled: Boolean +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index 3dcd03f..88b083b 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -74,6 +74,9 @@ sealed interface AppRoute { val pendingAmount: Long?, val guestPhone: String? ) : AppRoute + data object SuperAdminUsers : AppRoute + data class PropertyUsers(val propertyId: String) : AppRoute + data class PropertyAccessCode(val propertyId: String) : AppRoute data object Amenities : AppRoute data object AddAmenity : AppRoute data class EditAmenity(val amenityId: String) : AppRoute diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt index 5ae180c..5a2ead2 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt @@ -17,47 +17,129 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember 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 +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import com.android.trisolarispms.ui.booking.findPhoneCountryOption +import com.android.trisolarispms.ui.booking.phoneCountryOptions +import kotlinx.coroutines.delay +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AuthScreen(viewModel: AuthViewModel = viewModel()) { val state by viewModel.state.collectAsState() val context = LocalContext.current val activity = context as? ComponentActivity + val phoneCountryMenuExpanded = remember { mutableStateOf(false) } + val phoneCountries = remember { phoneCountryOptions() } + val phoneCountrySearch = remember { mutableStateOf("") } + val now = remember { mutableStateOf(System.currentTimeMillis()) } + + LaunchedEffect(state.resendAvailableAt) { + while (state.resendAvailableAt != null) { + now.value = System.currentTimeMillis() + delay(1000) + } + } Column( modifier = Modifier .fillMaxSize() - .padding(24.dp), + .padding(24.dp) + .verticalScroll(rememberScrollState()), 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 - ) + val selectedCountry = findPhoneCountryOption(state.phoneCountryCode) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ExposedDropdownMenuBox( + expanded = phoneCountryMenuExpanded.value, + onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value }, + modifier = Modifier.weight(0.35f) + ) { + OutlinedTextField( + value = "${selectedCountry.code} +${selectedCountry.dialCode}", + onValueChange = {}, + readOnly = true, + label = { Text("Country") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + ExposedDropdownMenu( + expanded = phoneCountryMenuExpanded.value, + onDismissRequest = { phoneCountryMenuExpanded.value = false } + ) { + OutlinedTextField( + value = phoneCountrySearch.value, + onValueChange = { phoneCountrySearch.value = it }, + label = { Text("Search") }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + val filtered = phoneCountries.filter { option -> + val query = phoneCountrySearch.value.trim() + if (query.isBlank()) true + else option.name.contains(query, ignoreCase = true) || + option.code.contains(query, ignoreCase = true) || + option.dialCode.contains(query) + } + filtered.forEach { option -> + DropdownMenuItem( + text = { Text("${option.name} (+${option.dialCode})") }, + onClick = { + phoneCountryMenuExpanded.value = false + phoneCountrySearch.value = "" + viewModel.onPhoneCountryChange(option.code) + } + ) + } + } + } + OutlinedTextField( + value = state.phoneNationalNumber, + onValueChange = viewModel::onPhoneNationalNumberChange, + label = { Text("Number") }, + prefix = { Text("+${selectedCountry.dialCode}") }, + supportingText = { Text("Max ${selectedCountry.maxLength} digits") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier.weight(0.65f) + ) + } Spacer(modifier = Modifier.height(12.dp)) + val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true + val resendText = if (!state.isCodeSent) { + "Send code" + } else if (!canResend) { + val remaining = ((state.resendAvailableAt ?: 0L) - now.value) / 1000 + "Resend in ${remaining.coerceAtLeast(0)}s" + } else { + "Resend code" + } Button( onClick = { if (activity != null) { @@ -66,9 +148,9 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) { viewModel.reportError("Unable to access activity for phone auth") } }, - enabled = !state.isLoading + enabled = !state.isLoading && (!state.isCodeSent || canResend) ) { - Text(if (state.isCodeSent) "Resend code" else "Send code") + Text(resendText) } Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt index 5add7fe..c90aa1c 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt @@ -2,10 +2,13 @@ package com.android.trisolarispms.ui.auth data class AuthUiState( val countryCode: String = "+91", + val phoneCountryCode: String = "IN", + val phoneNationalNumber: String = "", val phone: String = "", val code: String = "", val isLoading: Boolean = false, val isCodeSent: Boolean = false, + val resendAvailableAt: Long? = null, val verificationId: String? = null, val error: String? = null, val userId: String? = null, diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt index 76a146b..2a599b7 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import java.util.concurrent.TimeUnit class AuthViewModel( @@ -23,6 +25,7 @@ class AuthViewModel( val state: StateFlow = _state private var resendToken: PhoneAuthProvider.ForceResendingToken? = null + private var resendCooldownJob: Job? = null init { val user = auth.currentUser @@ -31,13 +34,28 @@ class AuthViewModel( } } - fun onPhoneChange(value: String) { - val digits = value.filter { it.isDigit() }.take(10) - _state.update { it.copy(phone = digits, error = null) } + fun onPhoneCountryChange(value: String) { + val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(value) + val trimmed = state.value.phoneNationalNumber.take(option.maxLength) + _state.update { + it.copy( + phoneCountryCode = option.code, + countryCode = "+${option.dialCode}", + phoneNationalNumber = trimmed, + error = null + ) + } + } + + fun onPhoneNationalNumberChange(value: String) { + val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode) + val digits = value.filter { it.isDigit() }.take(option.maxLength) + _state.update { it.copy(phoneNationalNumber = digits, error = null) } } fun onCodeChange(value: String) { - _state.update { it.copy(code = value, error = null) } + val digits = value.filter { it.isDigit() }.take(6) + _state.update { it.copy(code = digits, error = null) } } fun onNameChange(value: String) { @@ -57,9 +75,14 @@ class AuthViewModel( } fun sendCode(activity: ComponentActivity) { + val now = System.currentTimeMillis() + val resendAt = state.value.resendAvailableAt + if (resendAt != null && now < resendAt) { + return + } val phone = buildE164Phone() if (phone == null) { - setError("Enter a valid 10-digit phone number") + setError("Enter a valid phone number") return } @@ -83,10 +106,12 @@ class AuthViewModel( it.copy( isLoading = false, isCodeSent = true, + resendAvailableAt = System.currentTimeMillis() + 60_000, verificationId = verificationId, error = null ) } + startResendCooldown() } } @@ -104,9 +129,10 @@ class AuthViewModel( } private fun buildE164Phone(): String? { - val digits = state.value.phone.trim() - if (digits.length != 10) return null - return "${state.value.countryCode}$digits" + val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode) + val digits = state.value.phoneNationalNumber.trim() + if (digits.length != option.maxLength) return null + return "+${option.dialCode}$digits" } fun verifyCode() { @@ -121,6 +147,10 @@ class AuthViewModel( setError("Enter the code") return } + if (code.length != 6) { + setError("Enter the 6-digit code") + return + } val credential = PhoneAuthProvider.getCredential(verificationId, code) signInWithCredential(credential) @@ -141,6 +171,25 @@ class AuthViewModel( } } + private fun startResendCooldown() { + resendCooldownJob?.cancel() + resendCooldownJob = viewModelScope.launch { + while (true) { + val remaining = (state.value.resendAvailableAt ?: 0L) - System.currentTimeMillis() + if (remaining <= 0) { + _state.update { it.copy(resendAvailableAt = null) } + break + } + delay(1000) + } + } + } + + override fun onCleared() { + super.onCleared() + resendCooldownJob?.cancel() + } + private fun verifyExistingSession(userId: String) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null, userId = userId) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt new file mode 100644 index 0000000..e7288de --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/home/HomeJoinPropertyViewModel.kt @@ -0,0 +1,64 @@ +package com.android.trisolarispms.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class HomeJoinPropertyState( + val isLoading: Boolean = false, + val error: String? = null, + val message: String? = null, + val joinedPropertyId: String? = null, + val joinedRoles: List = emptyList() +) + +class HomeJoinPropertyViewModel : ViewModel() { + private val _state = MutableStateFlow(HomeJoinPropertyState()) + val state: StateFlow = _state + + fun join(propertyId: String, code: String) { + val trimmedPropertyId = propertyId.trim() + val digits = code.filter { it.isDigit() } + if (trimmedPropertyId.isBlank()) { + _state.update { it.copy(error = "Property ID is required", message = null) } + return + } + if (digits.length != 6) { + _state.update { it.copy(error = "Code must be 6 digits", message = null) } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().joinAccessCode( + PropertyAccessCodeJoinRequest( + propertyId = trimmedPropertyId, + code = digits + ) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + message = "Joined property", + error = null, + joinedPropertyId = body.propertyId ?: trimmedPropertyId, + joinedRoles = body.roles + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") } + } + } + } + +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt index f56166b..821c33d 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -29,9 +30,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.android.trisolarispms.ui.property.PropertyListViewModel +import androidx.compose.foundation.text.KeyboardOptions @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -39,6 +42,8 @@ fun HomeScreen( userId: String?, userName: String?, isSuperAdmin: Boolean, + onUserDirectory: () -> Unit, + onLogout: () -> Unit, onAddProperty: () -> Unit, onAmenities: () -> Unit, onImageTags: () -> Unit, @@ -46,10 +51,16 @@ fun HomeScreen( selectedPropertyId: String?, onSelectProperty: (String, String) -> Unit, onRefreshProfile: () -> Unit, - viewModel: PropertyListViewModel = viewModel() + showJoinProperty: Boolean, + onJoinPropertySuccess: (String, List) -> Unit, + viewModel: PropertyListViewModel = viewModel(), + joinViewModel: HomeJoinPropertyViewModel = viewModel() ) { var menuExpanded by remember { mutableStateOf(false) } val state by viewModel.state.collectAsState() + val joinState by joinViewModel.state.collectAsState() + val joinPropertyId = remember { mutableStateOf("") } + val joinCode = remember { mutableStateOf("") } LaunchedEffect(refreshKey) { viewModel.refresh() @@ -58,6 +69,13 @@ fun HomeScreen( LaunchedEffect(Unit) { onRefreshProfile() } + LaunchedEffect(joinState.joinedPropertyId, joinState.joinedRoles) { + val joinedPropertyId = joinState.joinedPropertyId + if (!joinedPropertyId.isNullOrBlank()) { + onRefreshProfile() + onJoinPropertySuccess(joinedPropertyId, joinState.joinedRoles) + } + } Scaffold( topBar = { @@ -65,6 +83,11 @@ fun HomeScreen( title = { Text("Trisolaris PMS") }, colors = TopAppBarDefaults.topAppBarColors(), actions = { + if (isSuperAdmin) { + IconButton(onClick = onUserDirectory) { + Icon(Icons.Default.People, contentDescription = "User Directory") + } + } IconButton(onClick = { menuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "Menu") } @@ -72,14 +95,21 @@ fun HomeScreen( expanded = menuExpanded, onDismissRequest = { menuExpanded = false } ) { - DropdownMenuItem( - text = { Text("Add Property") }, - onClick = { - menuExpanded = false - onAddProperty() - } - ) - if (isSuperAdmin) { + DropdownMenuItem( + text = { Text("Add Property") }, + onClick = { + menuExpanded = false + onAddProperty() + } + ) + DropdownMenuItem( + text = { Text("Logout") }, + onClick = { + menuExpanded = false + onLogout() + } + ) + if (isSuperAdmin) { DropdownMenuItem( text = { Text("Update Tags") }, onClick = { @@ -115,6 +145,41 @@ fun HomeScreen( if (userId != null) { Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall) } + if (showJoinProperty) { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "Join property", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.material3.OutlinedTextField( + value = joinPropertyId.value, + onValueChange = { joinPropertyId.value = it }, + label = { Text("Property ID") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.OutlinedTextField( + value = joinCode.value, + onValueChange = { joinCode.value = it.filter { ch -> ch.isDigit() } }, + label = { Text("6-digit code") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.Button( + onClick = { joinViewModel.join(joinPropertyId.value, joinCode.value) }, + modifier = Modifier.fillMaxWidth(), + enabled = !joinState.isLoading + ) { + Text("Join property") + } + joinState.error?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + joinState.message?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = it, color = MaterialTheme.colorScheme.primary) + } + } if (state.isLoading) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt index 51e5061..5c786e6 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt @@ -92,20 +92,6 @@ fun AddPropertyScreen( ) { Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = state.code, - onValueChange = viewModel::onCodeChange, - label = { Text("Code") }, - isError = state.codeError != null, - modifier = Modifier.fillMaxWidth() - ) - state.codeError?.let { - Spacer(modifier = Modifier.height(4.dp)) - Text(text = it, color = MaterialTheme.colorScheme.error) - } - - Spacer(modifier = Modifier.height(12.dp)) - ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = it } diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt index 066a9a5..3142b3a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt @@ -1,13 +1,11 @@ package com.android.trisolarispms.ui.property data class AddPropertyState( - val code: String = "", val name: String = "", val addressText: String = "", val timezone: String = "Asia/Kolkata", val currency: String = "INR", val isLoading: Boolean = false, - val codeError: String? = null, val nameError: String? = null, val error: String? = null, val createdPropertyId: String? = null, diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt index bcc8601..4602d0c 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt @@ -13,15 +13,9 @@ class AddPropertyViewModel : ViewModel() { private val _state = MutableStateFlow(AddPropertyState()) val state: StateFlow = _state - fun onCodeChange(value: String) { - val normalized = value.trim().uppercase() - _state.update { it.copy(code = normalized, codeError = null, error = null, codeAuto = false) } - } - fun onNameChange(value: String) { _state.update { current -> - val nextCode = if (current.codeAuto) generateCode(value) else current.code - current.copy(name = value, nameError = null, error = null, code = nextCode) + current.copy(name = value, nameError = null, error = null) } } @@ -40,27 +34,21 @@ class AddPropertyViewModel : ViewModel() { fun applyPlace(name: String?, address: String?) { _state.update { current -> val nextName = if (current.name.isBlank() && !name.isNullOrBlank()) name else current.name - val nextCode = if (current.codeAuto && nextName.isNotBlank()) generateCode(nextName) else current.code current.copy( name = nextName, addressText = address ?: current.addressText, - code = nextCode, error = null, - nameError = null, - codeError = null + nameError = null ) } } fun submit() { - val code = state.value.code.trim() val name = state.value.name.trim() - val codeError = validateCode(code) val nameError = validateName(name) - if (codeError != null || nameError != null) { + if (nameError != null) { _state.update { it.copy( - codeError = codeError, nameError = nameError, error = "Please fix the highlighted fields" ) @@ -73,7 +61,7 @@ class AddPropertyViewModel : ViewModel() { try { val api = ApiClient.create() val body = PropertyCreateRequest( - code = code, + code = null, name = name, addressText = state.value.addressText.takeIf { it.isNotBlank() }, timezone = state.value.timezone.takeIf { it.isNotBlank() }, @@ -101,19 +89,6 @@ class AddPropertyViewModel : ViewModel() { _state.update { AddPropertyState() } } - private fun generateCode(name: String): String { - val base = name.filter { it.isLetterOrDigit() }.uppercase().take(4).padEnd(4, 'X') - val suffix = kotlin.random.Random.nextInt(100, 999) - return "$base$suffix" - } - - private fun validateCode(code: String): String? { - if (code.isBlank()) return "Code is required" - if (code.length < 2) return "Code is too short" - if (!code.matches(Regex("^[A-Z0-9_-]+$"))) return "Only A-Z, 0-9, _ and -" - return null - } - private fun validateName(name: String): String? { if (name.isBlank()) return "Name is required" if (name.length < 2) return "Name is too short" diff --git a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrScreen.kt index c3e6284..155c54b 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrScreen.kt @@ -96,7 +96,7 @@ fun RazorpayQrScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Razorpay QR") }, + title = { Text("Generate Payment Links | QR") }, navigationIcon = { IconButton(onClick = { if (isViewingQr) { diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt index e2f4104..b010915 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt @@ -61,6 +61,7 @@ fun RoomsScreen( onViewCardInfo: () -> Unit, canManageRooms: Boolean, canViewCardInfo: Boolean, + canIssueTemporaryCard: Boolean, onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, viewModel: RoomListViewModel = viewModel(), @@ -195,12 +196,17 @@ fun RoomsScreen( onClick = { if (nfcSupported && room.hasNfc != false && - room.tempCardActive != true + room.tempCardActive != true && + canIssueTemporaryCard ) { onIssueTemporaryCard(room) } }, - onLongClick = { onEditRoom(room) } + onLongClick = { + if (canManageRooms) { + onEditRoom(room) + } + } ), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt index cf399bd..070b498 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt @@ -14,8 +14,10 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.MeetingRoom import androidx.compose.material.icons.filled.Payment +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -30,6 +32,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -50,10 +56,15 @@ fun ActiveRoomStaysScreen( propertyId: String, propertyName: String, onBack: () -> Unit, + showBack: Boolean, onViewRooms: () -> Unit, onCreateBooking: () -> Unit, + canCreateBooking: Boolean, showRazorpaySettings: Boolean, onRazorpaySettings: () -> Unit, + showUserAdmin: Boolean, + onUserAdmin: () -> Unit, + onLogout: () -> Unit, onManageRoomStay: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit, @@ -61,6 +72,7 @@ fun ActiveRoomStaysScreen( ) { val state by viewModel.state.collectAsState() val selectedBooking = remember { mutableStateOf(null) } + val menuExpanded = remember { mutableStateOf(false) } LaunchedEffect(propertyId) { viewModel.load(propertyId) @@ -71,8 +83,10 @@ fun ActiveRoomStaysScreen( TopAppBar( title = { Text(propertyName) }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + if (showBack) { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } } }, actions = { @@ -84,13 +98,35 @@ fun ActiveRoomStaysScreen( Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings") } } + if (showUserAdmin) { + IconButton(onClick = onUserAdmin) { + Icon(Icons.Default.People, contentDescription = "Property Users") + } + } + IconButton(onClick = { menuExpanded.value = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "Menu") + } + DropdownMenu( + expanded = menuExpanded.value, + onDismissRequest = { menuExpanded.value = false } + ) { + DropdownMenuItem( + text = { Text("Logout") }, + onClick = { + menuExpanded.value = false + onLogout() + } + ) + } }, colors = TopAppBarDefaults.topAppBarColors() ) }, floatingActionButton = { - FloatingActionButton(onClick = onCreateBooking) { - Icon(Icons.Default.Add, contentDescription = "Create Booking") + if (canCreateBooking) { + FloatingActionButton(onClick = onCreateBooking) { + Icon(Icons.Default.Add, contentDescription = "Create Booking") + } } } ) { padding -> diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/PropertyAccessCodeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyAccessCodeScreen.kt new file mode 100644 index 0000000..51b2c98 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyAccessCodeScreen.kt @@ -0,0 +1,182 @@ +package com.android.trisolarispms.ui.users + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun PropertyAccessCodeScreen( + propertyId: String, + allowedRoles: List, + onBack: () -> Unit, + viewModel: PropertyUsersViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val clipboard = LocalClipboardManager.current + val roleSelections = remember { + mutableStateOf( + allowedRoles.associateWith { false } + ) + } + + LaunchedEffect(propertyId) { + viewModel.loadPropertyCode(propertyId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Generate Access Code") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + Text(text = "Select roles", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + if (allowedRoles.isEmpty()) { + Text( + text = "You don't have permission to generate access codes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else { + roleSelections.value.forEach { (role, selected) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = selected, + onCheckedChange = { checked -> + roleSelections.value = roleSelections.value.mapValues { entry -> + if (entry.key == role) checked else false + } + } + ) + Text(text = role) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + val roles = roleSelections.value.filterValues { it }.keys.toList() + viewModel.createAccessCode(propertyId, roles) + }, + modifier = Modifier.fillMaxWidth(), + enabled = allowedRoles.isNotEmpty() + ) { + Text("Generate code") + } + + 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) + } + state.message?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.primary) + } + + state.accessCode?.let { code -> + val isExpired = code.expiresAt?.let { + runCatching { OffsetDateTime.parse(it).isBefore(OffsetDateTime.now()) } + .getOrDefault(false) + } ?: false + if (isExpired) { + return@let + } + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + state.propertyCode?.let { propertyCode -> + Text(text = "Property Code: $propertyCode", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Button(onClick = { clipboard.setText(AnnotatedString(propertyCode)) }) { + Text("Copy property code") + } + Spacer(modifier = Modifier.height(12.dp)) + } + Text(text = "Code: ${code.code}", style = MaterialTheme.typography.titleLarge) + code.expiresAt?.let { + val formatted = runCatching { + OffsetDateTime.parse(it) + .format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")) + }.getOrDefault(it) + Text(text = "Expires: $formatted", style = MaterialTheme.typography.bodySmall) + } + if (code.roles.isNotEmpty()) { + Text( + text = "Roles: ${code.roles.joinToString()}", + style = MaterialTheme.typography.bodySmall + ) + } + Spacer(modifier = Modifier.height(8.dp)) + code.code?.let { accessCode -> + Button(onClick = { clipboard.setText(AnnotatedString(accessCode)) }) { + Text("Copy access code") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUserUi.kt b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUserUi.kt new file mode 100644 index 0000000..fcf81ac --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUserUi.kt @@ -0,0 +1,10 @@ +package com.android.trisolarispms.ui.users + +data class PropertyUserUi( + val userId: String? = null, + val roles: List = emptyList(), + val name: String? = null, + val phoneE164: String? = null, + val disabled: Boolean = false, + val superAdmin: Boolean = false +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersScreen.kt new file mode 100644 index 0000000..d2453c0 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersScreen.kt @@ -0,0 +1,189 @@ +package com.android.trisolarispms.ui.users + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun PropertyUsersScreen( + propertyId: String, + allowedRoleAssignments: List, + canDisableAdmin: Boolean, + canDisableManager: Boolean, + onBack: () -> Unit, + onOpenAccessCode: () -> Unit, + viewModel: PropertyUsersViewModel = viewModel(), + directoryViewModel: UserDirectoryViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val directoryState by directoryViewModel.state.collectAsState() + val showSearchDialog = remember { mutableStateOf(false) } + val searchQuery = remember { mutableStateOf("") } + val editTarget = remember { mutableStateOf(null) } + val editRoles = remember { mutableStateOf>(emptyMap()) } + val disableTarget = remember { mutableStateOf(null) } + + LaunchedEffect(propertyId) { + directoryViewModel.loadAll(UserDirectoryMode.Property(propertyId)) + } + + UserDirectoryScaffold( + title = "Property Users", + onBack = onBack, + showSearchIcon = true, + onSearchClick = { showSearchDialog.value = true }, + showAccessCodeIcon = true, + onAccessCodeClick = onOpenAccessCode, + isLoading = directoryState.isLoading, + error = directoryState.error, + users = directoryState.users, + emptyText = "No users found", + beforeListContent = { + state.message?.let { + Text(text = it, color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(8.dp)) + } + } + ) { user -> + PropertyUserCard( + user = user, + canEditRoles = allowedRoleAssignments.isNotEmpty() && !user.userId.isNullOrBlank(), + canDisable = canDisableUser( + user = user, + canDisableAdmin = canDisableAdmin, + canDisableManager = canDisableManager + ), + onEditRoles = { + editTarget.value = user + editRoles.value = allowedRoleAssignments.associateWith { role -> + user.roles.contains(role) + } + }, + onToggleDisabled = { + disableTarget.value = user + } + ) + } + + UserSearchDialog( + visible = showSearchDialog.value, + title = "Search users", + queryLabel = "Phone (min 6 digits)", + queryValue = searchQuery.value, + onQueryChange = { searchQuery.value = it }, + onSearch = { + showSearchDialog.value = false + directoryViewModel.search(UserDirectoryMode.Property(propertyId), it) + }, + onClear = { + showSearchDialog.value = false + searchQuery.value = "" + directoryViewModel.loadAll(UserDirectoryMode.Property(propertyId)) + } + ) + + editTarget.value?.let { user -> + AlertDialog( + onDismissRequest = { editTarget.value = null }, + title = { Text("Update roles") }, + text = { + Column { + Text( + text = "This replaces the user's role set.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + allowedRoleAssignments.forEach { role -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = editRoles.value[role] == true, + onCheckedChange = { checked -> + editRoles.value = editRoles.value.toMutableMap().apply { + put(role, checked) + } + } + ) + Text(text = role) + } + } + } + }, + confirmButton = { + Button( + onClick = { + val roles = editRoles.value.filterValues { it }.keys.toList() + val userId = user.userId + if (!userId.isNullOrBlank()) { + viewModel.updateRoles(propertyId, userId, roles) + } + editTarget.value = null + } + ) { + Text("Save") + } + }, + dismissButton = { + Button(onClick = { editTarget.value = null }) { + Text("Cancel") + } + } + ) + } + + disableTarget.value?.let { user -> + val newDisabled = !user.disabled + AlertDialog( + onDismissRequest = { disableTarget.value = null }, + title = { Text(if (newDisabled) "Disable user" else "Enable user") }, + text = { + Text( + text = if (newDisabled) { + "This will disable the user for this property." + } else { + "This will enable the user for this property." + }, + style = MaterialTheme.typography.bodySmall + ) + }, + confirmButton = { + Button( + onClick = { + val userId = user.userId + if (!userId.isNullOrBlank()) { + viewModel.updateDisabled(propertyId, userId, newDisabled) + } + disableTarget.value = null + } + ) { + Text(if (newDisabled) "Disable" else "Enable") + } + }, + dismissButton = { + Button(onClick = { disableTarget.value = null }) { + Text("Cancel") + } + } + ) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt new file mode 100644 index 0000000..01fd135 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/PropertyUsersViewModel.kt @@ -0,0 +1,235 @@ +package com.android.trisolarispms.ui.users + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.PropertyAccessCodeCreateRequest +import com.android.trisolarispms.data.api.model.PropertyAccessCodeResponse +import com.android.trisolarispms.data.api.model.PropertyUserResponse +import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest +import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest +import com.android.trisolarispms.data.api.model.PropertyUserDisabledRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class PropertyUsersState( + val isLoading: Boolean = false, + val error: String? = null, + val message: String? = null, + val users: List = emptyList(), + val accessCode: PropertyAccessCodeResponse? = null, + val propertyCode: String? = null +) + +class PropertyUsersViewModel : ViewModel() { + private val _state = MutableStateFlow(PropertyUsersState()) + val state: StateFlow = _state + + fun loadAll(propertyId: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().searchPropertyUsers( + propertyId = propertyId, + phone = null + ) + val body = response.body() + if (response.isSuccessful && body != null) { + val mapped = body.map { + PropertyUserUi( + userId = it.userId, + roles = it.roles, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + _state.update { it.copy(isLoading = false, users = mapped, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } + } + } + + fun searchUsers(propertyId: String, phoneInput: String) { + val digits = phoneInput.filter { it.isDigit() } + if (digits.length < 6) { + _state.update { it.copy(users = emptyList(), error = null, isLoading = false, message = null) } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().searchPropertyUsers( + propertyId = propertyId, + phone = digits + ) + val body = response.body() + if (response.isSuccessful && body != null) { + val mapped = body.map { + PropertyUserUi( + userId = it.userId, + roles = it.roles, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + _state.update { it.copy(isLoading = false, users = mapped, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Search failed") } + } + } + } + + fun createAccessCode(propertyId: String, roles: List) { + if (roles.isEmpty()) { + _state.update { it.copy(error = "Select at least one role", message = null) } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().createAccessCode( + propertyId = propertyId, + body = PropertyAccessCodeCreateRequest(roles = roles) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + accessCode = body, + message = "Access code created" + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } + } + } + } + + fun joinProperty(propertyId: String, code: String) { + val digits = code.filter { it.isDigit() } + if (digits.length != 6) { + _state.update { it.copy(error = "Code must be 6 digits", message = null) } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().joinAccessCode( + PropertyAccessCodeJoinRequest(propertyId = propertyId, code = digits) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { + it.copy( + isLoading = false, + message = "Joined property", + error = null + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") } + } + } + } + + fun updateRoles(propertyId: String, userId: String, roles: List) { + if (roles.isEmpty()) { + _state.update { it.copy(error = "Select at least one role", message = null) } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().updateUserRoles( + propertyId = propertyId, + userId = userId, + body = UserRolesUpdateRequest(roles = roles) + ) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { current -> + val updated = current.users.map { user -> + if (user.userId == userId) { + user.copy(roles = body.roles) + } else user + } + current.copy( + isLoading = false, + users = updated, + message = "Roles updated" + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + } + } + } + + fun updateDisabled(propertyId: String, userId: String, disabled: Boolean) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, message = null) } + try { + val response = ApiClient.create().updateUserDisabled( + propertyId = propertyId, + userId = userId, + body = PropertyUserDisabledRequest(disabled = disabled) + ) + if (response.isSuccessful) { + _state.update { current -> + val updated = current.users.map { user -> + if (user.userId == userId) { + user.copy(disabled = disabled) + } else user + } + current.copy( + isLoading = false, + users = updated, + message = if (disabled) "User disabled" else "User enabled" + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + } + } + } + + fun loadPropertyCode(propertyId: String) { + viewModelScope.launch { + try { + val response = ApiClient.create().getPropertyCode(propertyId) + val body = response.body() + if (response.isSuccessful && body != null) { + _state.update { it.copy(propertyCode = body.code) } + } + } catch (_: Exception) { + // ignore code load errors + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/SuperAdminUserDirectoryScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/users/SuperAdminUserDirectoryScreen.kt new file mode 100644 index 0000000..d58b887 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/SuperAdminUserDirectoryScreen.kt @@ -0,0 +1,61 @@ +package com.android.trisolarispms.ui.users + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun SuperAdminUserDirectoryScreen( + onBack: () -> Unit, + viewModel: UserDirectoryViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val showSearchDialog = remember { mutableStateOf(false) } + val searchQuery = remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + viewModel.loadAll(UserDirectoryMode.SuperAdmin) + } + + UserDirectoryScaffold( + title = "User Directory", + onBack = onBack, + showSearchIcon = state.users.isNotEmpty(), + onSearchClick = { showSearchDialog.value = true }, + showAccessCodeIcon = false, + onAccessCodeClick = {}, + isLoading = state.isLoading, + error = state.error, + users = state.users, + emptyText = "No users found" + ) { user -> + PropertyUserCard( + user = user, + canEditRoles = false, + canDisable = false, + onEditRoles = {}, + onToggleDisabled = {} + ) + } + + UserSearchDialog( + visible = showSearchDialog.value, + title = "Search users", + queryLabel = "Phone (min 6 digits)", + queryValue = searchQuery.value, + onQueryChange = { searchQuery.value = it }, + onSearch = { + showSearchDialog.value = false + viewModel.search(UserDirectoryMode.SuperAdmin, it) + }, + onClear = { + showSearchDialog.value = false + searchQuery.value = "" + viewModel.loadAll(UserDirectoryMode.SuperAdmin) + } + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/UserCards.kt b/app/src/main/java/com/android/trisolarispms/ui/users/UserCards.kt new file mode 100644 index 0000000..753ae21 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/UserCards.kt @@ -0,0 +1,78 @@ +package com.android.trisolarispms.ui.users + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PropertyUserCard( + user: PropertyUserUi, + canEditRoles: Boolean, + canDisable: Boolean, + onEditRoles: () -> Unit, + onToggleDisabled: () -> Unit +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = user.name ?: user.userId ?: "User", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (canEditRoles) { + Button(onClick = onEditRoles) { + Text("Edit roles") + } + } + if (canDisable) { + Button(onClick = onToggleDisabled) { + Text(if (user.disabled) "Enable" else "Disable") + } + } + } + } + user.phoneE164?.let { Text(text = it, style = MaterialTheme.typography.bodySmall) } + if (user.roles.isNotEmpty()) { + Text(text = "Roles: ${user.roles.joinToString()}", style = MaterialTheme.typography.bodySmall) + } + if (user.superAdmin) { + Text(text = "Super Admin", style = MaterialTheme.typography.bodySmall) + } + if (user.disabled) { + Text( + text = "Disabled", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + +fun canDisableUser( + user: PropertyUserUi, + canDisableAdmin: Boolean, + canDisableManager: Boolean +): Boolean { + if (user.userId.isNullOrBlank()) return false + if (canDisableAdmin) return true + if (!canDisableManager) return false + val allowed = setOf("STAFF", "AGENT", "HOUSEKEEPING", "FINANCE", "GUIDE", "SUPERVISOR") + return user.roles.all { allowed.contains(it) } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryScaffold.kt b/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryScaffold.kt new file mode 100644 index 0000000..79e9c9b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryScaffold.kt @@ -0,0 +1,141 @@ +package com.android.trisolarispms.ui.users + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun UserDirectoryScaffold( + title: String, + onBack: () -> Unit, + showSearchIcon: Boolean, + onSearchClick: () -> Unit, + showAccessCodeIcon: Boolean, + onAccessCodeClick: () -> Unit, + isLoading: Boolean, + error: String?, + users: List, + emptyText: String, + beforeListContent: @Composable () -> Unit = {}, + itemContent: @Composable (PropertyUserUi) -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (showAccessCodeIcon) { + IconButton(onClick = onAccessCodeClick) { + Icon(Icons.Default.VpnKey, contentDescription = "Generate access code") + } + } + if (showSearchIcon) { + IconButton(onClick = onSearchClick) { + Icon(Icons.Default.Search, contentDescription = "Search users") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.Top + ) { + beforeListContent() + + if (isLoading) { + Spacer(modifier = Modifier.height(12.dp)) + CircularProgressIndicator() + } + error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + + Spacer(modifier = Modifier.height(12.dp)) + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(users) { user -> + itemContent(user) + } + } + if (!isLoading && error == null && users.isEmpty()) { + Text(text = emptyText) + } + } + } +} + +@Composable +fun UserSearchDialog( + visible: Boolean, + title: String, + queryLabel: String, + queryValue: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + onClear: () -> Unit +) { + if (!visible) return + AlertDialog( + onDismissRequest = onClear, + title = { Text(title) }, + text = { + OutlinedTextField( + value = queryValue, + onValueChange = onQueryChange, + label = { Text(queryLabel) }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button( + onClick = { onSearch(queryValue) } + ) { + Text("Search") + } + }, + dismissButton = { + Button(onClick = onClear) { + Text("Clear") + } + } + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt new file mode 100644 index 0000000..c8aacce --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/users/UserDirectoryViewModel.kt @@ -0,0 +1,137 @@ +package com.android.trisolarispms.ui.users + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed class UserDirectoryMode { + data object SuperAdmin : UserDirectoryMode() + data class Property(val propertyId: String) : UserDirectoryMode() +} + +data class UserDirectoryState( + val isLoading: Boolean = false, + val error: String? = null, + val message: String? = null, + val users: List = emptyList() +) + +class UserDirectoryViewModel : ViewModel() { + private val _state = MutableStateFlow(UserDirectoryState()) + val state: StateFlow = _state + + fun loadAll(mode: UserDirectoryMode) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + when (mode) { + UserDirectoryMode.SuperAdmin -> { + val response = api.listUsers(null) + val body = response.body() + if (response.isSuccessful && body != null) { + val mapped = body.map { + PropertyUserUi( + userId = it.id, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + _state.update { it.copy(isLoading = false, users = mapped, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } + is UserDirectoryMode.Property -> { + val response = api.searchPropertyUsers( + propertyId = mode.propertyId, + phone = null + ) + val body = response.body() + if (response.isSuccessful && body != null) { + val mapped = body.map { + PropertyUserUi( + userId = it.userId, + roles = it.roles, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + _state.update { it.copy(isLoading = false, users = mapped, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } + } + } + + fun search(mode: UserDirectoryMode, phoneInput: String) { + val digits = phoneInput.filter { it.isDigit() } + if (digits.length < 6) { + _state.update { it.copy(users = emptyList(), error = null, isLoading = false) } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + when (mode) { + UserDirectoryMode.SuperAdmin -> { + val response = api.listUsers(digits) + val body = response.body() + if (response.isSuccessful && body != null) { + val mapped = body.map { + PropertyUserUi( + userId = it.id, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + _state.update { it.copy(isLoading = false, users = mapped, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") } + } + } + is UserDirectoryMode.Property -> { + val response = api.searchPropertyUsers( + propertyId = mode.propertyId, + phone = digits + ) + val body = response.body() + if (response.isSuccessful && body != null) { + val mapped = body.map { + PropertyUserUi( + userId = it.userId, + roles = it.roles, + name = it.name, + phoneE164 = it.phoneE164, + disabled = it.disabled, + superAdmin = it.superAdmin + ) + } + _state.update { it.copy(isLoading = false, users = mapped, error = null) } + } else { + _state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") } + } + } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Search failed") } + } + } + } +}