Unify image grids and room type image ordering

This commit is contained in:
androidlover5842
2026-01-28 00:41:04 +05:30
parent 55f139f4f2
commit dbbcb6c4a6
11 changed files with 496 additions and 174 deletions

View File

@@ -16,6 +16,12 @@ import retrofit2.http.Streaming
import retrofit2.http.DELETE
interface RoomImageApi {
@GET("properties/{propertyId}/room-types/{roomTypeCode}/images")
suspend fun listRoomTypeImages(
@Path("propertyId") propertyId: String,
@Path("roomTypeCode") roomTypeCode: String
): Response<List<ImageDto>>
@GET("properties/{propertyId}/rooms/{roomId}/images")
suspend fun listRoomImages(
@Path("propertyId") propertyId: String,

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.data.api.model
import com.android.trisolarispms.data.api.model.AmenityDto
import com.android.trisolarispms.data.api.model.ImageDto
data class RoomTypeCreateRequest(
val code: String,
@@ -34,5 +35,6 @@ data class RoomTypeDto(
val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null,
val amenities: List<AmenityDto>? = null,
val images: List<ImageDto>? = null,
val otaAliases: List<String>? = null
)

View File

@@ -8,7 +8,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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -41,7 +41,7 @@ fun AddImageTagScreen(
title = { Text("Add Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {

View File

@@ -0,0 +1,123 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.android.trisolarispms.data.api.model.ImageDto
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
fun ReorderableImageGrid(
images: List<ImageDto>,
modifier: Modifier = Modifier,
columns: Int = 2,
gridState: LazyGridState = rememberLazyGridState(),
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(4.dp),
onOrderChange: (List<ImageDto>) -> Unit,
onDragEnd: (List<ImageDto>) -> Unit,
itemContent: @Composable (image: ImageDto, dragHandleModifier: Modifier, isDragging: Boolean) -> Unit
) {
val scope = rememberCoroutineScope()
val isDraggingAny = remember { mutableStateOf(false) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = modifier,
state = gridState,
userScrollEnabled = !isDraggingAny.value,
verticalArrangement = verticalArrangement,
horizontalArrangement = horizontalArrangement
) {
itemsIndexed(
images,
key = { index, image -> image.id ?: "image-$index" }
) { index, image ->
val imageId = image.id
val dragOffset = remember { mutableStateOf(Offset.Zero) }
val itemSize = remember { mutableStateOf(IntSize.Zero) }
val isDragging = remember { mutableStateOf(false) }
val dragHandleModifier = Modifier
.pointerInput(images, index) {
detectDragGestures(
onDragStart = {
isDraggingAny.value = true
isDragging.value = true
dragOffset.value = Offset.Zero
},
onDragCancel = {
isDraggingAny.value = false
isDragging.value = false
dragOffset.value = Offset.Zero
},
onDragEnd = {
isDraggingAny.value = false
isDragging.value = false
dragOffset.value = Offset.Zero
onDragEnd(images)
},
onDrag = { change, dragAmount ->
change.consume()
if (dragAmount.y != 0f) {
scope.launch {
gridState.scrollBy(dragAmount.y * 0.5f)
}
}
dragOffset.value += dragAmount
val size = itemSize.value
if (size.width > 0 && size.height > 0) {
val colDelta = (dragOffset.value.x / size.width).roundToInt()
val rowDelta = (dragOffset.value.y / size.height).roundToInt()
val currentIndex = images.indexOfFirst { it.id == imageId }
if (currentIndex == -1) return@detectDragGestures
val target = (currentIndex + rowDelta * columns + colDelta)
.coerceIn(0, images.lastIndex)
if (target != currentIndex) {
val list = images.toMutableList()
val item = list.removeAt(currentIndex)
list.add(target, item)
onOrderChange(list)
dragOffset.value = Offset.Zero
}
}
}
)
}
androidx.compose.foundation.layout.Column(
modifier = Modifier
.onGloballyPositioned { coords ->
if (itemSize.value.width == 0) {
itemSize.value = coords.size
}
}
.graphicsLayer {
if (isDragging.value) {
translationX = dragOffset.value.x
translationY = dragOffset.value.y
}
}
.zIndex(if (isDragging.value) 1f else 0f)
) {
itemContent(image, dragHandleModifier, isDragging.value)
}
}
}
}

View File

@@ -0,0 +1,98 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material.icons.filled.Label
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.android.trisolarispms.data.api.model.ImageDto
@Composable
fun RoomImageGridItem(
image: ImageDto,
modifier: Modifier,
onPreview: (() -> Unit)? = null,
onEditTags: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
showTags: Boolean = true
) {
Box(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = image.thumbnailUrl ?: image.url,
contentDescription = "Room image",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.then(
if (onPreview != null) Modifier.clickable { onPreview() } else Modifier
),
contentScale = ContentScale.Crop
)
val tags = if (showTags) {
image.tags
.orEmpty()
.mapNotNull { it.name ?: it.id }
.joinToString("")
} else ""
if (tags.isNotBlank()) {
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)
)
}
}
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 2.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
val hasId = !image.id.isNullOrBlank()
IconButton(
onClick = {},
enabled = hasId,
modifier = if (hasId) modifier else Modifier
) {
Icon(Icons.Default.DragIndicator, contentDescription = "Drag")
}
if (onEditTags != null) {
IconButton(onClick = onEditTags, enabled = hasId) {
Icon(Icons.Default.Label, contentDescription = "Edit Tags")
}
}
if (onDelete != null) {
IconButton(onClick = onDelete, enabled = hasId) {
Icon(Icons.Default.Delete, contentDescription = "Delete Image")
}
}
}
}
}

View File

@@ -39,6 +39,30 @@ class RoomImageViewModel : ViewModel() {
}
}
fun loadRoomTypeImages(propertyId: String, roomTypeCode: String) {
if (propertyId.isBlank() || roomTypeCode.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listRoomTypeImages(propertyId, roomTypeCode)
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
images = 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 setLocalOrder(images: List<ImageDto>) {
_state.update { it.copy(images = images) }
}
@@ -147,7 +171,12 @@ class RoomImageViewModel : ViewModel() {
}
}
fun reorderRoom(propertyId: String, roomId: String, imageIds: List<String>) {
fun reorderRoom(
propertyId: String,
roomId: String,
imageIds: List<String>,
onDone: (() -> Unit)? = null
) {
if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
@@ -160,6 +189,7 @@ class RoomImageViewModel : ViewModel() {
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
onDone?.invoke()
} else {
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
}
@@ -169,7 +199,12 @@ class RoomImageViewModel : ViewModel() {
}
}
fun reorderRoomType(propertyId: String, roomId: String, imageIds: List<String>) {
fun reorderRoomType(
propertyId: String,
roomId: String,
imageIds: List<String>,
onDone: (() -> Unit)? = null
) {
if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
@@ -182,6 +217,50 @@ class RoomImageViewModel : ViewModel() {
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
onDone?.invoke()
} else {
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Reorder failed") }
}
}
}
fun reorderRoomTypeWithFallback(
propertyId: String,
roomTypeCode: String,
roomId: String,
imageIds: List<String>,
onDone: (() -> Unit)? = null
) {
if (propertyId.isBlank() || roomTypeCode.isBlank() || imageIds.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val resolvedRoomId = if (roomId.isNotBlank()) {
roomId
} else {
val roomsResponse = api.listRooms(propertyId)
roomsResponse.body()
.orEmpty()
.firstOrNull { it.roomTypeCode == roomTypeCode }
?.id
.orEmpty()
}
if (resolvedRoomId.isBlank()) {
_state.update { it.copy(isLoading = false, error = "Missing roomId for reorder") }
return@launch
}
val response = api.reorderRoomTypeImages(
propertyId = propertyId,
roomId = resolvedRoomId,
body = RoomImageReorderRequest(imageIds)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
onDone?.invoke()
} else {
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
}

View File

@@ -5,30 +5,18 @@ import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.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.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -49,25 +37,15 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -88,9 +66,8 @@ fun RoomImagesScreen(
val deleteImageId = remember { mutableStateOf<String?>(null) }
val previewUrl = remember { mutableStateOf<String?>(null) }
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
val gridState = rememberLazyGridState()
val isDraggingAny = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val pickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20)
@@ -107,6 +84,7 @@ fun RoomImagesScreen(
}
LaunchedEffect(state.images) {
orderedImages.value = state.images
originalOrderIds.value = state.images.mapNotNull { it.id }
}
Scaffold(
@@ -203,145 +181,47 @@ fun RoomImagesScreen(
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Images", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Images", style = MaterialTheme.typography.titleMedium)
val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value
if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
Button(onClick = {
val ids = orderedImages.value.mapNotNull { it.id }
viewModel.reorderRoom(propertyId, roomId, ids) {
originalOrderIds.value = ids
}
}) {
Text("Save order")
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Use drag icon to reorder. Saves automatically.", style = MaterialTheme.typography.bodySmall)
Text(text = "Use drag icon to reorder.", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
ReorderableImageGrid(
images = orderedImages.value,
gridState = gridState,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
state = gridState,
userScrollEnabled = !isDraggingAny.value
) {
itemsIndexed(
orderedImages.value,
key = { index, image -> image.id ?: "image-$index" }
) { index, image ->
val imageId = image.id
val dragOffset = remember { mutableStateOf(Offset.Zero) }
val itemSize = remember { mutableStateOf(IntSize.Zero) }
val isDragging = remember { mutableStateOf(false) }
Column(
modifier = Modifier
.onGloballyPositioned { coords ->
if (itemSize.value.width == 0) {
itemSize.value = coords.size
}
}
.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(
onDragStart = {
isDraggingAny.value = true
isDragging.value = true
dragOffset.value = Offset.Zero
},
onDragCancel = {
isDraggingAny.value = false
isDragging.value = false
dragOffset.value = Offset.Zero
},
onDragEnd = {
isDraggingAny.value = false
isDragging.value = false
dragOffset.value = Offset.Zero
val ids = orderedImages.value.mapNotNull { it.id }
viewModel.reorderRoom(propertyId, roomId, ids)
},
onDrag = { change, dragAmount ->
change.consume()
if (dragAmount.y != 0f) {
scope.launch {
gridState.scrollBy(dragAmount.y * 0.5f)
}
}
dragOffset.value += dragAmount
val size = itemSize.value
if (size.width > 0 && size.height > 0) {
val colDelta = (dragOffset.value.x / size.width).roundToInt()
val rowDelta = (dragOffset.value.y / size.height).roundToInt()
val columns = 2
val currentIndex = orderedImages.value.indexOfFirst { it.id == imageId }
if (currentIndex == -1) return@detectDragGestures
val target = (currentIndex + rowDelta * columns + colDelta)
.coerceIn(0, orderedImages.value.lastIndex)
if (target != currentIndex) {
val list = orderedImages.value.toMutableList()
val item = list.removeAt(currentIndex)
list.add(target, item)
orderedImages.value = list
viewModel.setLocalOrder(list)
dragOffset.value = Offset.Zero
}
}
}
)
}
) {
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")
}
}
}
}
}
}
onOrderChange = { list ->
orderedImages.value = list
},
onDragEnd = {}
) { image, dragHandleModifier, _ ->
RoomImageGridItem(
image = image,
modifier = dragHandleModifier,
onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl },
onEditTags = { tagDialogImage.value = image },
onDelete = { deleteImageId.value = image.id },
showTags = true
)
}
}

View File

@@ -1,19 +1,28 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -31,12 +40,15 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomTypeDto
import com.android.trisolarispms.ui.roomimage.RoomImageGridItem
import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid
import com.android.trisolarispms.ui.roomimage.RoomImageViewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -46,11 +58,37 @@ fun EditRoomTypeScreen(
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: RoomTypeFormViewModel = viewModel(),
amenityViewModel: AmenityListViewModel = viewModel()
amenityViewModel: AmenityListViewModel = viewModel(),
roomImageViewModel: RoomImageViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
val roomImageState by roomImageViewModel.state.collectAsState()
val showDeleteConfirm = remember { mutableStateOf(false) }
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
val gridState = rememberLazyGridState()
val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) {
{
val code = roomType.code.orEmpty()
val ids = orderedImages.value.mapNotNull { it.id }
val roomId = orderedImages.value
.firstOrNull { !it.roomId.isNullOrBlank() }
?.roomId
.orEmpty()
if (code.isNotBlank() && ids.isNotEmpty()) {
roomImageViewModel.reorderRoomTypeWithFallback(
propertyId = propertyId,
roomTypeCode = code,
roomId = roomId,
imageIds = ids
) {
originalOrderIds.value = ids
roomImageViewModel.loadRoomTypeImages(propertyId, code)
}
}
}
}
LaunchedEffect(roomType.id) {
viewModel.setRoomType(roomType)
@@ -58,6 +96,16 @@ fun EditRoomTypeScreen(
LaunchedEffect(Unit) {
amenityViewModel.load()
}
LaunchedEffect(roomType.code) {
val code = roomType.code.orEmpty()
if (code.isNotBlank()) {
roomImageViewModel.loadRoomTypeImages(propertyId, code)
}
}
LaunchedEffect(roomImageState.images) {
orderedImages.value = roomImageState.images
originalOrderIds.value = roomImageState.images.mapNotNull { it.id }
}
Scaffold(
topBar = {
@@ -86,7 +134,8 @@ fun EditRoomTypeScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
@@ -169,6 +218,48 @@ fun EditRoomTypeScreen(
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Room Type Images", style = MaterialTheme.typography.titleSmall)
val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value
if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
Button(onClick = saveRoomTypeImageOrder) {
Text("Save order")
}
}
}
if (roomImageState.isLoading) {
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.CircularProgressIndicator()
} else if (orderedImages.value.isEmpty()) {
Text(text = "No images yet", style = MaterialTheme.typography.bodySmall)
} else {
Spacer(modifier = Modifier.height(8.dp))
ReorderableImageGrid(
images = orderedImages.value,
gridState = gridState,
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
onOrderChange = { list ->
orderedImages.value = list
},
onDragEnd = {}
) { image, dragHandleModifier, _ ->
RoomImageGridItem(
image = image,
modifier = dragHandleModifier,
showTags = true
)
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
@@ -184,7 +275,7 @@ fun EditRoomTypeScreen(
confirmButton = {
TextButton(onClick = {
showDeleteConfirm.value = false
viewModel.deleteRoomType(propertyId, roomType.id.orEmpty(), onSave)
viewModel.deleteRoomType(propertyId, roomType.id, onSave)
}) {
Text("Delete")
}

View File

@@ -5,5 +5,6 @@ import com.android.trisolarispms.data.api.model.RoomTypeDto
data class RoomTypeListState(
val isLoading: Boolean = false,
val error: String? = null,
val items: List<RoomTypeDto> = emptyList()
val items: List<RoomTypeDto> = emptyList(),
val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap()
)

View File

@@ -20,13 +20,15 @@ class RoomTypeListViewModel : ViewModel() {
val api = ApiClient.create()
val response = api.listRoomTypes(propertyId)
if (response.isSuccessful) {
val items = response.body().orEmpty()
_state.update {
it.copy(
isLoading = false,
items = response.body().orEmpty(),
items = items,
error = null
)
}
loadRoomTypeImages(propertyId, items)
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
@@ -35,4 +37,26 @@ class RoomTypeListViewModel : ViewModel() {
}
}
}
private fun loadRoomTypeImages(propertyId: String, items: List<com.android.trisolarispms.data.api.model.RoomTypeDto>) {
viewModelScope.launch {
val api = ApiClient.create()
val updates = mutableMapOf<String, com.android.trisolarispms.data.api.model.ImageDto?>()
for (item in items) {
val code = item.code?.trim().orEmpty()
if (code.isBlank()) continue
try {
val response = api.listRoomTypeImages(propertyId, code)
if (response.isSuccessful) {
updates[code] = response.body().orEmpty().firstOrNull()
}
} catch (_: Exception) {
// Ignore per-item failures to avoid blocking the list.
}
}
if (updates.isNotEmpty()) {
_state.update { it.copy(imageByTypeCode = it.imageByTypeCode + updates) }
}
}
}
}

View File

@@ -3,11 +3,13 @@ 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.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.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
@@ -25,8 +27,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -84,15 +88,29 @@ fun RoomTypesScreen(
Text(text = "No room types")
} else {
state.items.forEach { item ->
Text(
text = "${item.code}${item.name}",
style = MaterialTheme.typography.titleMedium,
val imageDto = state.imageByTypeCode[item.code.orEmpty()]
val imageUrl = imageDto?.thumbnailUrl ?: imageDto?.url
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = item.id != null) {
onEdit(item)
}
)
.clickable(enabled = item.id != null) { onEdit(item) },
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = item.name ?: item.code ?: "Room type image",
modifier = Modifier
.size(56.dp)
.clip(MaterialTheme.shapes.medium)
)
}
Text(
text = "${item.code}${item.name}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
}
}