Update amenity form and icon selection

This commit is contained in:
androidlover5842
2026-01-27 23:20:50 +05:30
parent 053b7c2544
commit 55f139f4f2
22 changed files with 1026 additions and 313 deletions

View File

@@ -19,6 +19,9 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomFormScreen
import com.android.trisolarispms.ui.room.RoomsScreen import com.android.trisolarispms.ui.room.RoomsScreen
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
import com.android.trisolarispms.ui.roomimage.EditImageTagScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
@@ -52,6 +55,7 @@ class MainActivity : ComponentActivity() {
val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) } val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(null) } 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 selectedAmenity = remember { mutableStateOf<com.android.trisolarispms.data.api.model.AmenityDto?>(null) }
val selectedImageTag = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomImageTagDto?>(null) }
val roomFormKey = remember { mutableStateOf(0) } val roomFormKey = remember { mutableStateOf(0) }
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) } val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
val currentRoute = route.value val currentRoute = route.value
@@ -69,6 +73,7 @@ class MainActivity : ComponentActivity() {
amenitiesReturnRoute.value = AppRoute.Home amenitiesReturnRoute.value = AppRoute.Home
route.value = AppRoute.Amenities route.value = AppRoute.Amenities
}, },
onImageTags = { route.value = AppRoute.ImageTags },
refreshKey = refreshKey.value, refreshKey = refreshKey.value,
selectedPropertyId = selectedPropertyId.value, selectedPropertyId = selectedPropertyId.value,
onSelectProperty = { id, name -> onSelectProperty = { id, name ->
@@ -152,6 +157,24 @@ class MainActivity : ComponentActivity() {
onBack = { route.value = AppRoute.Amenities }, onBack = { route.value = AppRoute.Amenities },
onSave = { route.value = AppRoute.Amenities } onSave = { route.value = AppRoute.Amenities }
) )
AppRoute.ImageTags -> ImageTagsScreen(
onBack = { route.value = AppRoute.Home },
onAdd = { route.value = AppRoute.AddImageTag },
onEdit = {
selectedImageTag.value = it
route.value = AppRoute.EditImageTag(it.id ?: "")
}
)
AppRoute.AddImageTag -> AddImageTagScreen(
onBack = { route.value = AppRoute.ImageTags },
onSave = { route.value = AppRoute.ImageTags }
)
is AppRoute.EditImageTag -> EditImageTagScreen(
tag = selectedImageTag.value
?: com.android.trisolarispms.data.api.model.RoomImageTagDto(id = currentRoute.tagId, name = ""),
onBack = { route.value = AppRoute.ImageTags },
onSave = { route.value = AppRoute.ImageTags }
)
is AppRoute.AddRoom -> RoomFormScreen( is AppRoute.AddRoom -> RoomFormScreen(
title = "Add Room", title = "Add Room",
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,

View File

@@ -30,4 +30,7 @@ interface AmenityApi {
suspend fun deleteAmenity( suspend fun deleteAmenity(
@Path("amenityId") amenityId: String @Path("amenityId") amenityId: String
): Response<Unit> ): Response<Unit>
@GET("icons/png")
suspend fun listAmenityIconKeys(): Response<List<String>>
} }

View File

@@ -6,6 +6,7 @@ interface ApiService :
RoomTypeApi, RoomTypeApi,
RoomApi, RoomApi,
RoomImageApi, RoomImageApi,
ImageTagApi,
BookingApi, BookingApi,
RoomStayApi, RoomStayApi,
CardApi, CardApi,

View File

@@ -0,0 +1,27 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.RoomImageTagDto
import retrofit2.Response
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Body
interface ImageTagApi {
@GET("image-tags")
suspend fun listImageTags(): Response<List<RoomImageTagDto>>
@POST("image-tags")
suspend fun createImageTag(@Body body: RoomImageTagDto): Response<RoomImageTagDto>
@PUT("image-tags/{tagId}")
suspend fun updateImageTag(
@Path("tagId") tagId: String,
@Body body: RoomImageTagDto
): Response<RoomImageTagDto>
@DELETE("image-tags/{tagId}")
suspend fun deleteImageTag(@Path("tagId") tagId: String): Response<Unit>
}

View File

@@ -28,7 +28,7 @@ interface RoomImageApi {
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("roomId") roomId: String, @Path("roomId") roomId: String,
@Part file: MultipartBody.Part, @Part file: MultipartBody.Part,
@Part tags: List<MultipartBody.Part>? = null @Query("tagIds") tagIds: List<String>? = null
): Response<ImageDto> ): Response<ImageDto>
@Streaming @Streaming
@@ -47,6 +47,14 @@ interface RoomImageApi {
@Path("imageId") imageId: String @Path("imageId") imageId: String
): Response<Unit> ): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/{imageId}/tags")
suspend fun updateRoomImageTags(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Path("imageId") imageId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageTagUpsertRequest
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room") @PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room")
suspend fun reorderRoomImages( suspend fun reorderRoomImages(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -60,7 +60,7 @@ data class ImageDto(
val thumbnailUrl: String? = null, val thumbnailUrl: String? = null,
val contentType: String? = null, val contentType: String? = null,
val sizeBytes: Long? = null, val sizeBytes: Long? = null,
val tags: List<String>? = null, val tags: List<RoomImageTagDto>? = null,
val roomSortOrder: Int? = null, val roomSortOrder: Int? = null,
val roomTypeSortOrder: Int? = null, val roomTypeSortOrder: Int? = null,
val createdAt: String? = null val createdAt: String? = null
@@ -69,3 +69,12 @@ data class ImageDto(
data class RoomImageReorderRequest( data class RoomImageReorderRequest(
val imageIds: List<String> val imageIds: List<String>
) )
data class RoomImageTagDto(
val id: String? = null,
val name: String? = null
)
data class RoomImageTagUpsertRequest(
val tagIds: List<String>
)

View File

@@ -14,4 +14,7 @@ sealed interface AppRoute {
data object AddAmenity : AppRoute data object AddAmenity : AppRoute
data class EditAmenity(val amenityId: String) : AppRoute data class EditAmenity(val amenityId: String) : AppRoute
data class RoomImages(val propertyId: String, val roomId: String) : AppRoute data class RoomImages(val propertyId: String, val roomId: String) : AppRoute
data object ImageTags : AppRoute
data object AddImageTag : AppRoute
data class EditImageTag(val tagId: String) : AppRoute
} }

View File

@@ -41,6 +41,7 @@ fun HomeScreen(
isSuperAdmin: Boolean, isSuperAdmin: Boolean,
onAddProperty: () -> Unit, onAddProperty: () -> Unit,
onAmenities: () -> Unit, onAmenities: () -> Unit,
onImageTags: () -> Unit,
refreshKey: Int, refreshKey: Int,
selectedPropertyId: String?, selectedPropertyId: String?,
onSelectProperty: (String, String) -> Unit, onSelectProperty: (String, String) -> Unit,
@@ -79,6 +80,13 @@ fun HomeScreen(
} }
) )
if (isSuperAdmin) { if (isSuperAdmin) {
DropdownMenuItem(
text = { Text("Update Tags") },
onClick = {
menuExpanded = false
onImageTags()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Modify Amenities") }, text = { Text("Modify Amenities") },
onClick = { onClick = {

View File

@@ -0,0 +1,75 @@
package com.android.trisolarispms.ui.roomimage
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddImageTagScreen(
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: ImageTagFormViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitCreate(onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -0,0 +1,82 @@
package com.android.trisolarispms.ui.roomimage
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomImageTagDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditImageTagScreen(
tag: RoomImageTagDto,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: ImageTagFormViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(tag.id) {
viewModel.setTag(tag)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitUpdate(tag.id.orEmpty(), onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package com.android.trisolarispms.ui.roomimage
data class ImageTagFormState(
val name: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
)

View File

@@ -0,0 +1,69 @@
package com.android.trisolarispms.ui.roomimage
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.RoomImageTagDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ImageTagFormViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagFormState())
val state: StateFlow<ImageTagFormState> = _state
fun setTag(tag: RoomImageTagDto) {
_state.update { it.copy(name = tag.name.orEmpty(), error = null) }
}
fun onNameChange(value: String) {
_state.update { it.copy(name = value, error = null) }
}
fun submitCreate(onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.createImageTag(RoomImageTagDto(name = name))
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} 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 submitUpdate(tagId: String, onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateImageTag(tagId, RoomImageTagDto(name = name))
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} 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") }
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.android.trisolarispms.ui.roomimage
import com.android.trisolarispms.data.api.model.RoomImageTagDto
data class ImageTagState(
val isLoading: Boolean = false,
val error: String? = null,
val tags: List<RoomImageTagDto> = emptyList()
)

View File

@@ -0,0 +1,61 @@
package com.android.trisolarispms.ui.roomimage
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
class ImageTagViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagState())
val state: StateFlow<ImageTagState> = _state
fun load() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listImageTags()
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
tags = response.body().orEmpty(),
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 delete(tagId: String) {
if (tagId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteImageTag(tagId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
tags = current.tags.filterNot { it.id == tagId },
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

@@ -0,0 +1,109 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.clickable
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.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
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomImageTagDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ImageTagsScreen(
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (RoomImageTagDto) -> Unit,
viewModel: ImageTagViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.load()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Image Tags") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Tag")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!state.isLoading && state.error == null) {
if (state.tags.isEmpty()) {
Text(text = "No tags")
} else {
state.tags.forEach { tag ->
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = tag.name ?: "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.clickable(enabled = tag.id != null) { onEdit(tag) }
)
if (!tag.id.isNullOrBlank()) {
IconButton(onClick = { viewModel.delete(tag.id) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Tag")
}
}
}
}
}
}
}
}
}

View File

@@ -47,7 +47,7 @@ class RoomImageViewModel : ViewModel() {
propertyId: String, propertyId: String,
roomId: String, roomId: String,
filePart: MultipartBody.Part, filePart: MultipartBody.Part,
tags: List<MultipartBody.Part>?, tagIds: List<String>?,
onDone: (ImageDto) -> Unit onDone: (ImageDto) -> Unit
) { ) {
if (propertyId.isBlank() || roomId.isBlank()) return if (propertyId.isBlank() || roomId.isBlank()) return
@@ -59,7 +59,7 @@ class RoomImageViewModel : ViewModel() {
propertyId = propertyId, propertyId = propertyId,
roomId = roomId, roomId = roomId,
file = filePart, file = filePart,
tags = tags tagIds = tagIds
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
@@ -89,7 +89,7 @@ class RoomImageViewModel : ViewModel() {
propertyId: String, propertyId: String,
roomId: String, roomId: String,
fileParts: List<MultipartBody.Part>, fileParts: List<MultipartBody.Part>,
tags: List<MultipartBody.Part>?, tagIds: List<String>?,
onDone: () -> Unit onDone: () -> Unit
) { ) {
if (propertyId.isBlank() || roomId.isBlank() || fileParts.isEmpty()) return if (propertyId.isBlank() || roomId.isBlank() || fileParts.isEmpty()) return
@@ -102,7 +102,7 @@ class RoomImageViewModel : ViewModel() {
propertyId = propertyId, propertyId = propertyId,
roomId = roomId, roomId = roomId,
file = part, file = part,
tags = tags tagIds = tagIds
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
@@ -190,4 +190,28 @@ class RoomImageViewModel : ViewModel() {
} }
} }
} }
fun updateTags(propertyId: String, roomId: String, imageId: String, tagIds: List<String>) {
if (propertyId.isBlank() || roomId.isBlank() || imageId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateRoomImageTags(
propertyId = propertyId,
roomId = roomId,
imageId = imageId,
body = com.android.trisolarispms.data.api.model.RoomImageTagUpsertRequest(tagIds)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
load(propertyId, roomId)
} else {
_state.update { it.copy(isLoading = false, error = "Update tags failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update tags failed") }
}
}
}
} }

View File

@@ -7,18 +7,27 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -40,25 +49,25 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.zIndex import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -66,16 +75,22 @@ fun RoomImagesScreen(
propertyId: String, propertyId: String,
roomId: String, roomId: String,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: RoomImageViewModel = viewModel() viewModel: RoomImageViewModel = viewModel(),
tagViewModel: ImageTagViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val tagsText = remember { mutableStateOf("") } val tagState by tagViewModel.state.collectAsState()
val selectedTagIds = remember { mutableStateOf<Set<String>>(emptySet()) }
val tagDialogImage = remember { mutableStateOf<com.android.trisolarispms.data.api.model.ImageDto?>(null) }
val selectedUris = remember { mutableStateOf<List<Uri>>(emptyList()) } val selectedUris = remember { mutableStateOf<List<Uri>>(emptyList()) }
val selectedNames = remember { mutableStateOf<List<String>>(emptyList()) } val selectedNames = remember { mutableStateOf<List<String>>(emptyList()) }
val deleteImageId = remember { mutableStateOf<String?>(null) } val deleteImageId = remember { mutableStateOf<String?>(null) }
val previewUrl = remember { mutableStateOf<String?>(null) } val previewUrl = remember { mutableStateOf<String?>(null) }
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) } val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
val gridState = rememberLazyGridState()
val isDraggingAny = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val pickerLauncher = rememberLauncherForActivityResult( val pickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20) ActivityResultContracts.PickMultipleVisualMedia(20)
@@ -87,6 +102,9 @@ fun RoomImagesScreen(
LaunchedEffect(propertyId, roomId) { LaunchedEffect(propertyId, roomId) {
viewModel.load(propertyId, roomId) viewModel.load(propertyId, roomId)
} }
LaunchedEffect(Unit) {
tagViewModel.load()
}
LaunchedEffect(state.images) { LaunchedEffect(state.images) {
orderedImages.value = state.images orderedImages.value = state.images
} }
@@ -150,12 +168,7 @@ fun RoomImagesScreen(
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( Text(text = "Tags can be managed after upload.", style = MaterialTheme.typography.bodySmall)
value = tagsText.value,
onValueChange = { tagsText.value = it },
label = { Text("Tags (comma separated)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Button( Button(
@@ -169,22 +182,14 @@ fun RoomImagesScreen(
MultipartBody.Part.createFormData("file", name, requestBody) MultipartBody.Part.createFormData("file", name, requestBody)
} }
if (parts.isEmpty()) return@Button if (parts.isEmpty()) return@Button
val tagBodies = tagsText.value.split(',')
.map { it.trim() }
.filter { it.isNotBlank() }
.map { tag ->
MultipartBody.Part.createFormData("tags", tag)
}
.ifEmpty { null }
viewModel.uploadBatch( viewModel.uploadBatch(
propertyId = propertyId, propertyId = propertyId,
roomId = roomId, roomId = roomId,
fileParts = parts, fileParts = parts,
tags = tagBodies, tagIds = null,
onDone = { onDone = {
selectedUris.value = emptyList() selectedUris.value = emptyList()
selectedNames.value = emptyList() selectedNames.value = emptyList()
tagsText.value = ""
} }
) )
}, },
@@ -197,17 +202,21 @@ fun RoomImagesScreen(
} }
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(text = "Images", style = MaterialTheme.typography.titleMedium) Text(text = "Images", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text(text = "Drag an image onto another to reorder. Saves automatically.", style = MaterialTheme.typography.bodySmall) Text(text = "Use drag icon to reorder. Saves automatically.", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
state = gridState,
userScrollEnabled = !isDraggingAny.value
) { ) {
itemsIndexed( itemsIndexed(
orderedImages.value, orderedImages.value,
@@ -224,17 +233,67 @@ fun RoomImagesScreen(
itemSize.value = coords.size itemSize.value = coords.size
} }
} }
.pointerInput(orderedImages.value, index) { .graphicsLayer {
if (isDragging.value) {
translationX = dragOffset.value.x
translationY = dragOffset.value.y
}
}
.zIndex(if (isDragging.value) 1f else 0f)
) {
Box(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = image.thumbnailUrl ?: image.url,
contentDescription = "Room image",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.clickable { previewUrl.value = image.url ?: image.thumbnailUrl },
contentScale = ContentScale.Crop
)
val tags = image.tags
.orEmpty()
.mapNotNull { it.name ?: it.id }
.joinToString("")
if (tags.isNotBlank()) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shape = MaterialTheme.shapes.small,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(6.dp)
) {
Text(
text = tags,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
if (!image.id.isNullOrBlank()) {
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 2.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {},
modifier = Modifier.pointerInput(orderedImages.value, index) {
detectDragGestures( detectDragGestures(
onDragStart = { onDragStart = {
isDraggingAny.value = true
isDragging.value = true isDragging.value = true
dragOffset.value = Offset.Zero dragOffset.value = Offset.Zero
}, },
onDragCancel = { onDragCancel = {
isDraggingAny.value = false
isDragging.value = false isDragging.value = false
dragOffset.value = Offset.Zero dragOffset.value = Offset.Zero
}, },
onDragEnd = { onDragEnd = {
isDraggingAny.value = false
isDragging.value = false isDragging.value = false
dragOffset.value = Offset.Zero dragOffset.value = Offset.Zero
val ids = orderedImages.value.mapNotNull { it.id } val ids = orderedImages.value.mapNotNull { it.id }
@@ -242,6 +301,11 @@ fun RoomImagesScreen(
}, },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
change.consume() change.consume()
if (dragAmount.y != 0f) {
scope.launch {
gridState.scrollBy(dragAmount.y * 0.5f)
}
}
dragOffset.value += dragAmount dragOffset.value += dragAmount
val size = itemSize.value val size = itemSize.value
if (size.width > 0 && size.height > 0) { if (size.width > 0 && size.height > 0) {
@@ -264,42 +328,17 @@ fun RoomImagesScreen(
} }
) )
} }
.graphicsLayer {
if (isDragging.value) {
translationX = dragOffset.value.x
translationY = dragOffset.value.y
alpha = 0.85f
scaleX = 1.02f
scaleY = 1.02f
}
}
.zIndex(if (isDragging.value) 1f else 0f)
) {
Box(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = image.thumbnailUrl ?: image.url,
contentDescription = "Room image",
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clickable { previewUrl.value = image.url ?: image.thumbnailUrl }
)
if (!image.id.isNullOrBlank()) {
IconButton(
onClick = { deleteImageId.value = image.id },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 10.dp)
.offset(y = 2.dp)
) { ) {
Icon(Icons.Default.DragIndicator, contentDescription = "Drag")
}
IconButton(onClick = { tagDialogImage.value = image }) {
Icon(Icons.Default.Label, contentDescription = "Edit Tags")
}
IconButton(onClick = { deleteImageId.value = image.id }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Image") Icon(Icons.Default.Delete, contentDescription = "Delete Image")
} }
} }
} }
val tags = image.tags?.joinToString("").orEmpty()
if (tags.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(text = tags, style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@@ -349,6 +388,52 @@ fun RoomImagesScreen(
} }
) )
} }
val tagImage = tagDialogImage.value
if (tagImage != null) {
val currentIds = tagImage.tags.orEmpty().mapNotNull { it.id }.toSet()
val editIds = remember(tagImage.id) { mutableStateOf(currentIds) }
AlertDialog(
onDismissRequest = { tagDialogImage.value = null },
title = { Text("Edit Tags") },
text = {
Column {
if (tagState.tags.isEmpty()) {
Text(text = "No tags available", style = MaterialTheme.typography.bodySmall)
} else {
tagState.tags.forEach { tag ->
val id = tag.id ?: return@forEach
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.material3.Checkbox(
checked = editIds.value.contains(id),
onCheckedChange = {
val next = editIds.value.toMutableSet()
if (next.contains(id)) next.remove(id) else next.add(id)
editIds.value = next
}
)
Text(text = tag.name ?: id, modifier = Modifier.padding(start = 8.dp))
}
}
}
}
},
confirmButton = {
TextButton(onClick = {
tagDialogImage.value = null
val ids = editIds.value.toList()
viewModel.updateTags(propertyId, roomId, tagImage.id.orEmpty(), ids)
}) {
Text("Save")
}
}
)
}
} }
private fun getDisplayName(context: android.content.Context, uri: Uri): String? { private fun getDisplayName(context: android.content.Context, uri: Uri): String? {

View File

@@ -1,122 +1,23 @@
package com.android.trisolarispms.ui.roomtype 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
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.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 import androidx.lifecycle.viewmodel.compose.viewModel
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddAmenityScreen( fun AddAmenityScreen(
onBack: () -> Unit, onBack: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
viewModel: AmenityFormViewModel = viewModel(), viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel() amenityListViewModel: AmenityListViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() androidx.compose.runtime.LaunchedEffect(Unit) {
val amenityState by amenityListViewModel.state.collectAsState() viewModel.resetForm(preserveOptions = true)
LaunchedEffect(Unit) {
amenityListViewModel.load()
} }
AmenityFormScreen(
val categorySuggestions = remember(state.category, amenityState.items) { title = "Add Amenity",
val all = amenityState.items.mapNotNull { it.category?.trim() } onBack = onBack,
.filter { it.isNotBlank() } onSaveClick = { viewModel.submitCreate(onSave) },
.distinct() viewModel = viewModel,
.sorted() amenityListViewModel = amenityListViewModel
if (state.category.isBlank()) all
else all.filter { it.contains(state.category, ignoreCase = true) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Amenity") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitCreate(onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
) )
} }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
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

@@ -0,0 +1,254 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.android.trisolarispms.data.api.ApiConstants
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AmenityFormScreen(
title: String,
onBack: () -> Unit,
onSaveClick: () -> Unit,
viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityListViewModel.state.collectAsState()
LaunchedEffect(Unit) {
amenityListViewModel.load()
viewModel.loadIconKeys(force = true)
}
val density = LocalDensity.current
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) }
}
var categoryMenuExpanded by remember { mutableStateOf(false) }
var categoryFieldSize by remember { mutableStateOf(IntSize.Zero) }
val categoryMenuOffsetY = remember(categoryFieldSize) { with(density) { categoryFieldSize.height.toDp() } }
val categoryMenuOffsetYAdjusted = remember(categoryMenuOffsetY) {
(categoryMenuOffsetY - 32.dp).coerceAtLeast(0.dp)
}
val iconSuggestions = remember(state.iconKey, state.iconKeyOptions) {
val input = state.iconKey.trim()
if (input.isBlank()) state.iconKeyOptions
else state.iconKeyOptions.filter { it.contains(input, ignoreCase = true) }
}
var iconMenuExpanded by remember { mutableStateOf(false) }
var iconFieldSize by remember { mutableStateOf(IntSize.Zero) }
val menuOffsetY = remember(iconFieldSize) { with(density) { iconFieldSize.height.toDp() } }
val menuOffsetYAdjusted = remember(menuOffsetY) {
(menuOffsetY - 32.dp).coerceAtLeast(0.dp)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onSaveClick) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = state.category,
onValueChange = {
viewModel.onCategoryChange(it)
categoryMenuExpanded = true
},
label = { Text("Category") },
trailingIcon = {
IconButton(onClick = { categoryMenuExpanded = !categoryMenuExpanded }) {
Icon(Icons.Default.ArrowDropDown, contentDescription = "Show categories")
}
},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { categoryFieldSize = it.size }
)
DropdownMenu(
expanded = categoryMenuExpanded,
onDismissRequest = { categoryMenuExpanded = false },
offset = DpOffset(0.dp, categoryMenuOffsetYAdjusted),
properties = PopupProperties(focusable = false),
modifier = Modifier.width(with(density) { categoryFieldSize.width.toDp() })
) {
Column(
modifier = Modifier
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState())
) {
if (categorySuggestions.isEmpty()) {
DropdownMenuItem(
text = { Text("No categories found") },
onClick = {},
enabled = false
)
} else {
categorySuggestions.take(100).forEach { suggestion ->
DropdownMenuItem(
text = { Text(suggestion) },
onClick = {
viewModel.onCategoryChange(suggestion)
categoryMenuExpanded = false
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = state.iconKey,
onValueChange = {
viewModel.onIconKeyChange(it)
iconMenuExpanded = true
},
label = { Text("Icon Key") },
trailingIcon = {
IconButton(onClick = { iconMenuExpanded = !iconMenuExpanded }) {
Icon(Icons.Default.ArrowDropDown, contentDescription = "Show icons")
}
},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { iconFieldSize = it.size }
)
DropdownMenu(
expanded = iconMenuExpanded,
onDismissRequest = { iconMenuExpanded = false },
offset = DpOffset(0.dp, menuOffsetYAdjusted),
properties = PopupProperties(focusable = false),
modifier = Modifier.width(with(density) { iconFieldSize.width.toDp() })
) {
Column(
modifier = Modifier
.heightIn(max = 280.dp)
.verticalScroll(rememberScrollState())
) {
when {
state.iconKeyLoading -> {
DropdownMenuItem(
text = { Text("Loading icons...") },
onClick = {},
enabled = false
)
}
iconSuggestions.isEmpty() -> {
DropdownMenuItem(
text = { Text("No icons found") },
onClick = {},
enabled = false
)
}
else -> {
iconSuggestions.take(200).forEach { key ->
DropdownMenuItem(
text = { Text(key) },
leadingIcon = {
AsyncImage(
model = "${ApiConstants.BASE_URL}icons/png/$key",
contentDescription = key,
modifier = Modifier
.height(24.dp)
.width(24.dp)
)
},
onClick = {
viewModel.onIconKeyChange(key)
iconMenuExpanded = false
}
)
}
}
}
}
}
}
state.iconKeyError?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -4,6 +4,9 @@ data class AmenityFormState(
val name: String = "", val name: String = "",
val category: String = "", val category: String = "",
val iconKey: String = "", val iconKey: String = "",
val iconKeyOptions: List<String> = emptyList(),
val iconKeyLoading: Boolean = false,
val iconKeyError: String? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val success: Boolean = false val success: Boolean = false

View File

@@ -37,6 +37,56 @@ class AmenityFormViewModel : ViewModel() {
_state.update { it.copy(iconKey = value, error = null) } _state.update { it.copy(iconKey = value, error = null) }
} }
fun resetForm(preserveOptions: Boolean = true) {
_state.update {
AmenityFormState(
iconKeyOptions = if (preserveOptions) it.iconKeyOptions else emptyList(),
iconKeyLoading = if (preserveOptions) it.iconKeyLoading else false,
iconKeyError = if (preserveOptions) it.iconKeyError else null
)
}
}
fun loadIconKeys(force: Boolean = false) {
if (!force && (state.value.iconKeyOptions.isNotEmpty() || state.value.iconKeyLoading)) return
viewModelScope.launch {
_state.update {
it.copy(
iconKeyLoading = true,
iconKeyError = null,
iconKeyOptions = if (force) emptyList() else it.iconKeyOptions
)
}
try {
val api = ApiClient.create()
val response = api.listAmenityIconKeys()
if (response.isSuccessful) {
_state.update {
it.copy(
iconKeyLoading = false,
iconKeyOptions = response.body().orEmpty().sorted(),
iconKeyError = null
)
}
} else {
_state.update {
it.copy(
iconKeyLoading = false,
iconKeyError = "Icon keys failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
iconKeyLoading = false,
iconKeyError = e.localizedMessage ?: "Icon keys failed"
)
}
}
}
}
fun submitCreate(onDone: () -> Unit) { fun submitCreate(onDone: () -> Unit) {
val name = state.value.name.trim() val name = state.value.name.trim()
if (name.isBlank()) { if (name.isBlank()) {
@@ -46,12 +96,13 @@ class AmenityFormViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
try { try {
val iconKey = state.value.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG")
val api = ApiClient.create() val api = ApiClient.create()
val response = api.createAmenity( val response = api.createAmenity(
AmenityCreateRequest( AmenityCreateRequest(
name = name, name = name,
category = state.value.category.trim().ifBlank { null }, category = state.value.category.trim().ifBlank { null },
iconKey = state.value.iconKey.trim().ifBlank { null } iconKey = iconKey.ifBlank { null }
) )
) )
if (response.isSuccessful) { if (response.isSuccessful) {
@@ -75,13 +126,14 @@ class AmenityFormViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
try { try {
val iconKey = state.value.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG")
val api = ApiClient.create() val api = ApiClient.create()
val response = api.updateAmenity( val response = api.updateAmenity(
amenityId, amenityId,
AmenityUpdateRequest( AmenityUpdateRequest(
name = name, name = name,
category = state.value.category.trim().ifBlank { null }, category = state.value.category.trim().ifBlank { null },
iconKey = state.value.iconKey.trim().ifBlank { null } iconKey = iconKey.ifBlank { null }
) )
) )
if (response.isSuccessful) { if (response.isSuccessful) {

View File

@@ -1,37 +1,11 @@
package com.android.trisolarispms.ui.roomtype 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
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.Composable
import androidx.compose.runtime.LaunchedEffect 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 import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.AmenityDto import com.android.trisolarispms.data.api.model.AmenityDto
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditAmenityScreen( fun EditAmenityScreen(
amenity: AmenityDto, amenity: AmenityDto,
onBack: () -> Unit, onBack: () -> Unit,
@@ -39,92 +13,17 @@ fun EditAmenityScreen(
viewModel: AmenityFormViewModel = viewModel(), viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel() amenityListViewModel: AmenityListViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState()
val amenityState by amenityListViewModel.state.collectAsState()
LaunchedEffect(amenity.id) { LaunchedEffect(amenity.id) {
viewModel.setAmenity(amenity) viewModel.setAmenity(amenity)
} }
LaunchedEffect(Unit) { AmenityFormScreen(
amenityListViewModel.load() title = "Edit Amenity",
} onBack = onBack,
onSaveClick = {
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 = {
TopAppBar(
title = { Text("Edit Amenity") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
val id = amenity.id.orEmpty() val id = amenity.id.orEmpty()
viewModel.submitUpdate(id, onSave) viewModel.submitUpdate(id, onSave)
}) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
}, },
colors = TopAppBarDefaults.topAppBarColors() viewModel = viewModel,
amenityListViewModel = amenityListViewModel
) )
} }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
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)
}
}
}
}