Compare commits

..

3 Commits

Author SHA1 Message Date
androidlover5842
4642102ff5 Update amenities API and category suggestions 2026-01-27 04:55:28 +05:30
androidlover5842
94eb4f9be4 Fix amenities back navigation 2026-01-27 04:29:05 +05:30
androidlover5842
2c296a2cb3 Move amenities management to home menu 2026-01-27 04:27:11 +05:30
14 changed files with 234 additions and 63 deletions

View File

@@ -52,6 +52,7 @@ class MainActivity : ComponentActivity() {
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(null) }
val selectedAmenity = remember { mutableStateOf<com.android.trisolarispms.data.api.model.AmenityDto?>(null) }
val roomFormKey = remember { mutableStateOf(0) }
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
val currentRoute = route.value
val canManageProperty: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true)
@@ -63,6 +64,10 @@ class MainActivity : ComponentActivity() {
userName = state.userName,
isSuperAdmin = state.isSuperAdmin,
onAddProperty = { route.value = AppRoute.AddProperty },
onAmenities = {
amenitiesReturnRoute.value = AppRoute.Home
route.value = AppRoute.Amenities
},
refreshKey = refreshKey.value,
selectedPropertyId = selectedPropertyId.value,
onSelectProperty = { id, name ->
@@ -109,7 +114,6 @@ class MainActivity : ComponentActivity() {
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddRoomType(currentRoute.propertyId) },
onAmenities = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
canManageRoomTypes = canManageProperty(currentRoute.propertyId),
onEdit = {
selectedRoomType.value = it
@@ -128,26 +132,24 @@ class MainActivity : ComponentActivity() {
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }
)
is AppRoute.Amenities -> AmenitiesScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddAmenity(currentRoute.propertyId) },
AppRoute.Amenities -> AmenitiesScreen(
onBack = { route.value = amenitiesReturnRoute.value },
onAdd = { route.value = AppRoute.AddAmenity },
canManageAmenities = state.isSuperAdmin,
onEdit = {
selectedAmenity.value = it
route.value = AppRoute.EditAmenity(currentRoute.propertyId, it.id ?: "")
route.value = AppRoute.EditAmenity(it.id ?: "")
}
)
is AppRoute.AddAmenity -> AddAmenityScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Amenities(currentRoute.propertyId) }
AppRoute.AddAmenity -> AddAmenityScreen(
onBack = { route.value = AppRoute.Amenities },
onSave = { route.value = AppRoute.Amenities }
)
is AppRoute.EditAmenity -> EditAmenityScreen(
propertyId = currentRoute.propertyId,
amenity = selectedAmenity.value
?: com.android.trisolarispms.data.api.model.AmenityDto(id = currentRoute.amenityId, name = ""),
onBack = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Amenities(currentRoute.propertyId) }
onBack = { route.value = AppRoute.Amenities },
onSave = { route.value = AppRoute.Amenities }
)
is AppRoute.AddRoom -> RoomFormScreen(
title = "Add Room",

View File

@@ -12,25 +12,22 @@ import retrofit2.http.PUT
import retrofit2.http.Path
interface AmenityApi {
@GET("properties/{propertyId}/amenities")
suspend fun listAmenities(@Path("propertyId") propertyId: String): Response<List<AmenityDto>>
@GET("amenities")
suspend fun listAmenities(): Response<List<AmenityDto>>
@POST("properties/{propertyId}/amenities")
@POST("amenities")
suspend fun createAmenity(
@Path("propertyId") propertyId: String,
@Body body: AmenityCreateRequest
): Response<AmenityDto>
@PUT("properties/{propertyId}/amenities/{amenityId}")
@PUT("amenities/{amenityId}")
suspend fun updateAmenity(
@Path("propertyId") propertyId: String,
@Path("amenityId") amenityId: String,
@Body body: AmenityUpdateRequest
): Response<AmenityDto>
@DELETE("properties/{propertyId}/amenities/{amenityId}")
@DELETE("amenities/{amenityId}")
suspend fun deleteAmenity(
@Path("propertyId") propertyId: String,
@Path("amenityId") amenityId: String
): Response<Unit>
}

View File

@@ -2,13 +2,19 @@ package com.android.trisolarispms.data.api.model
data class AmenityDto(
val id: String? = null,
val name: String? = null
val name: String? = null,
val category: String? = null,
val iconKey: String? = null
)
data class AmenityCreateRequest(
val name: String
val name: String,
val category: String? = null,
val iconKey: String? = null
)
data class AmenityUpdateRequest(
val name: String
val name: String,
val category: String? = null,
val iconKey: String? = null
)

View File

@@ -10,7 +10,7 @@ sealed interface AppRoute {
data class RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
data class Amenities(val propertyId: String) : AppRoute
data class AddAmenity(val propertyId: String) : AppRoute
data class EditAmenity(val propertyId: String, val amenityId: String) : AppRoute
data object Amenities : AppRoute
data object AddAmenity : AppRoute
data class EditAmenity(val amenityId: String) : AppRoute
}

View File

@@ -40,6 +40,7 @@ fun HomeScreen(
userName: String?,
isSuperAdmin: Boolean,
onAddProperty: () -> Unit,
onAmenities: () -> Unit,
refreshKey: Int,
selectedPropertyId: String?,
onSelectProperty: (String, String) -> Unit,
@@ -77,6 +78,15 @@ fun HomeScreen(
onAddProperty()
}
)
if (isSuperAdmin) {
DropdownMenuItem(
text = { Text("Modify Amenities") },
onClick = {
menuExpanded = false
onAmenities()
}
)
}
}
}
)

View File

@@ -1,5 +1,6 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -20,8 +21,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -29,12 +32,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddAmenityScreen(
propertyId: String,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: AmenityFormViewModel = viewModel()
viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityListViewModel.state.collectAsState()
LaunchedEffect(Unit) {
amenityListViewModel.load()
}
val categorySuggestions = remember(state.category, amenityState.items) {
val all = amenityState.items.mapNotNull { it.category?.trim() }
.filter { it.isNotBlank() }
.distinct()
.sorted()
if (state.category.isBlank()) all
else all.filter { it.contains(state.category, ignoreCase = true) }
}
Scaffold(
topBar = {
@@ -46,7 +63,7 @@ fun AddAmenityScreen(
}
},
actions = {
IconButton(onClick = { viewModel.submitCreate(propertyId, onSave) }) {
IconButton(onClick = { viewModel.submitCreate(onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
@@ -67,6 +84,35 @@ fun AddAmenityScreen(
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.category,
onValueChange = viewModel::onCategoryChange,
label = { Text("Category") },
modifier = Modifier.fillMaxWidth()
)
if (categorySuggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
categorySuggestions.take(5).forEach { suggestion ->
Text(
text = suggestion,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(vertical = 2.dp)
.clickable { viewModel.onCategoryChange(suggestion) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.iconKey,
onValueChange = viewModel::onIconKeyChange,
label = { Text("Icon Key") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)

View File

@@ -44,8 +44,8 @@ fun AddRoomTypeScreen(
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
LaunchedEffect(propertyId) {
amenityViewModel.load(propertyId)
LaunchedEffect(Unit) {
amenityViewModel.load()
}
Scaffold(

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
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.Delete
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -32,16 +33,16 @@ import com.android.trisolarispms.data.api.model.AmenityDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AmenitiesScreen(
propertyId: String,
onBack: () -> Unit,
onAdd: () -> Unit,
canManageAmenities: Boolean,
onEdit: (AmenityDto) -> Unit,
viewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
LaunchedEffect(Unit) {
viewModel.load()
}
Scaffold(
@@ -54,8 +55,10 @@ fun AmenitiesScreen(
}
},
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Amenity")
if (canManageAmenities) {
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Amenity")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
@@ -82,14 +85,29 @@ fun AmenitiesScreen(
Text(text = "No amenities")
} else {
state.items.forEach { item ->
Text(
text = item.name ?: "",
style = MaterialTheme.typography.titleMedium,
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = item.id != null) { onEdit(item) }
.padding(vertical = 8.dp)
)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = item.name ?: "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.clickable(enabled = canManageAmenities && item.id != null) { onEdit(item) }
)
if (canManageAmenities && item.id != null) {
IconButton(onClick = { viewModel.deleteAmenity(item.id) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Amenity")
}
}
}
val meta = listOfNotNull(item.category, item.iconKey).joinToString("")
if (meta.isNotBlank()) {
Text(text = meta, style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@@ -2,6 +2,8 @@ package com.android.trisolarispms.ui.roomtype
data class AmenityFormState(
val name: String = "",
val category: String = "",
val iconKey: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false

View File

@@ -14,15 +14,30 @@ class AmenityFormViewModel : ViewModel() {
private val _state = MutableStateFlow(AmenityFormState())
val state: StateFlow<AmenityFormState> = _state
fun setAmenityName(name: String) {
_state.update { it.copy(name = name, error = null) }
fun setAmenity(amenity: com.android.trisolarispms.data.api.model.AmenityDto) {
_state.update {
it.copy(
name = amenity.name.orEmpty(),
category = amenity.category.orEmpty(),
iconKey = amenity.iconKey.orEmpty(),
error = null
)
}
}
fun onNameChange(value: String) {
_state.update { it.copy(name = value, error = null) }
}
fun submitCreate(propertyId: String, onDone: () -> Unit) {
fun onCategoryChange(value: String) {
_state.update { it.copy(category = value, error = null) }
}
fun onIconKeyChange(value: String) {
_state.update { it.copy(iconKey = value, error = null) }
}
fun submitCreate(onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
@@ -32,7 +47,13 @@ class AmenityFormViewModel : ViewModel() {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.createAmenity(propertyId, AmenityCreateRequest(name))
val response = api.createAmenity(
AmenityCreateRequest(
name = name,
category = state.value.category.trim().ifBlank { null },
iconKey = state.value.iconKey.trim().ifBlank { null }
)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
@@ -45,7 +66,7 @@ class AmenityFormViewModel : ViewModel() {
}
}
fun submitUpdate(propertyId: String, amenityId: String, onDone: () -> Unit) {
fun submitUpdate(amenityId: String, onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
@@ -55,7 +76,14 @@ class AmenityFormViewModel : ViewModel() {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateAmenity(propertyId, amenityId, AmenityUpdateRequest(name))
val response = api.updateAmenity(
amenityId,
AmenityUpdateRequest(
name = name,
category = state.value.category.trim().ifBlank { null },
iconKey = state.value.iconKey.trim().ifBlank { null }
)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()

View File

@@ -12,13 +12,12 @@ class AmenityListViewModel : ViewModel() {
private val _state = MutableStateFlow(AmenityListState())
val state: StateFlow<AmenityListState> = _state
fun load(propertyId: String) {
if (propertyId.isBlank()) return
fun load() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listAmenities(propertyId)
val response = api.listAmenities()
if (response.isSuccessful) {
_state.update {
it.copy(
@@ -35,4 +34,28 @@ class AmenityListViewModel : ViewModel() {
}
}
}
fun deleteAmenity(amenityId: String) {
if (amenityId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteAmenity(amenityId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
items = current.items.filterNot { it.id == amenityId },
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
}
}
}
}

View File

@@ -1,5 +1,6 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -23,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -31,16 +33,29 @@ import com.android.trisolarispms.data.api.model.AmenityDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditAmenityScreen(
propertyId: String,
amenity: AmenityDto,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: AmenityFormViewModel = viewModel()
viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityListViewModel.state.collectAsState()
LaunchedEffect(amenity.id) {
viewModel.setAmenityName(amenity.name.orEmpty())
viewModel.setAmenity(amenity)
}
LaunchedEffect(Unit) {
amenityListViewModel.load()
}
val categorySuggestions = remember(state.category, amenityState.items) {
val all = amenityState.items.mapNotNull { it.category?.trim() }
.filter { it.isNotBlank() }
.distinct()
.sorted()
if (state.category.isBlank()) all
else all.filter { it.contains(state.category, ignoreCase = true) }
}
Scaffold(
@@ -55,7 +70,7 @@ fun EditAmenityScreen(
actions = {
IconButton(onClick = {
val id = amenity.id.orEmpty()
viewModel.submitUpdate(propertyId, id, onSave)
viewModel.submitUpdate(id, onSave)
}) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
@@ -77,6 +92,35 @@ fun EditAmenityScreen(
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.category,
onValueChange = viewModel::onCategoryChange,
label = { Text("Category") },
modifier = Modifier.fillMaxWidth()
)
if (categorySuggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
categorySuggestions.take(5).forEach { suggestion ->
Text(
text = suggestion,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(vertical = 2.dp)
.clickable { viewModel.onCategoryChange(suggestion) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.iconKey,
onValueChange = viewModel::onIconKeyChange,
label = { Text("Icon Key") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)

View File

@@ -49,8 +49,8 @@ fun EditRoomTypeScreen(
LaunchedEffect(roomType.id) {
viewModel.setRoomType(roomType)
}
LaunchedEffect(propertyId) {
amenityViewModel.load(propertyId)
LaunchedEffect(Unit) {
amenityViewModel.load()
}
Scaffold(

View File

@@ -27,7 +27,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.material.icons.filled.Settings
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -35,7 +34,6 @@ fun RoomTypesScreen(
propertyId: String,
onBack: () -> Unit,
onAdd: () -> Unit,
onAmenities: () -> Unit,
canManageRoomTypes: Boolean,
onEdit: (com.android.trisolarispms.data.api.model.RoomTypeDto) -> Unit,
viewModel: RoomTypeListViewModel = viewModel()
@@ -57,9 +55,6 @@ fun RoomTypesScreen(
},
actions = {
if (canManageRoomTypes) {
IconButton(onClick = onAmenities) {
Icon(Icons.Default.Settings, contentDescription = "Amenities")
}
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Room Type")
}