Compare commits
2 Commits
55f139f4f2
...
d8a40e4c9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a40e4c9a | ||
|
|
dbbcb6c4a6 |
@@ -16,6 +16,12 @@ import retrofit2.http.Streaming
|
|||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
|
|
||||||
interface RoomImageApi {
|
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")
|
@GET("properties/{propertyId}/rooms/{roomId}/images")
|
||||||
suspend fun listRoomImages(
|
suspend fun listRoomImages(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.android.trisolarispms.data.api.model
|
package com.android.trisolarispms.data.api.model
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.AmenityDto
|
import com.android.trisolarispms.data.api.model.AmenityDto
|
||||||
|
import com.android.trisolarispms.data.api.model.ImageDto
|
||||||
|
|
||||||
data class RoomTypeCreateRequest(
|
data class RoomTypeCreateRequest(
|
||||||
val code: String,
|
val code: String,
|
||||||
@@ -16,6 +17,7 @@ data class RoomTypeCreateRequest(
|
|||||||
data class RoomTypeUpdateRequest(
|
data class RoomTypeUpdateRequest(
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
|
val active: Boolean? = null,
|
||||||
val baseOccupancy: Int? = null,
|
val baseOccupancy: Int? = null,
|
||||||
val maxOccupancy: Int? = null,
|
val maxOccupancy: Int? = null,
|
||||||
val sqFeet: Int? = null,
|
val sqFeet: Int? = null,
|
||||||
@@ -29,10 +31,12 @@ data class RoomTypeDto(
|
|||||||
val propertyId: String? = null,
|
val propertyId: String? = null,
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
|
val active: Boolean? = null,
|
||||||
val baseOccupancy: Int? = null,
|
val baseOccupancy: Int? = null,
|
||||||
val maxOccupancy: Int? = null,
|
val maxOccupancy: Int? = null,
|
||||||
val sqFeet: Int? = null,
|
val sqFeet: Int? = null,
|
||||||
val bathroomSqFeet: Int? = null,
|
val bathroomSqFeet: Int? = null,
|
||||||
val amenities: List<AmenityDto>? = null,
|
val amenities: List<AmenityDto>? = null,
|
||||||
|
val images: List<ImageDto>? = null,
|
||||||
val otaAliases: List<String>? = null
|
val otaAliases: List<String>? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ 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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.material.icons.Icons
|
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.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Category
|
import androidx.compose.material.icons.filled.Category
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -28,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ fun RoomsScreen(
|
|||||||
title = { Text("Available Rooms") },
|
title = { Text("Available Rooms") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.filled.Done
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -41,7 +41,7 @@ fun AddImageTagScreen(
|
|||||||
title = { Text("Add Tag") },
|
title = { Text("Add Tag") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.android.trisolarispms.ui.roomimage
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImagePreviewDialog(
|
||||||
|
imageUrl: String,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Preview") },
|
||||||
|
text = {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = "Image preview",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Label
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.DragIndicator
|
||||||
|
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.AutoMirrored.Filled.Label, contentDescription = "Edit Tags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onDelete != null) {
|
||||||
|
IconButton(onClick = onDelete, enabled = hasId) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Delete Image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>) {
|
fun setLocalOrder(images: List<ImageDto>) {
|
||||||
_state.update { it.copy(images = images) }
|
_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
|
if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
@@ -160,6 +189,7 @@ class RoomImageViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
_state.update { it.copy(isLoading = false, error = null) }
|
_state.update { it.copy(isLoading = false, error = null) }
|
||||||
|
onDone?.invoke()
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
|
_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
|
if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
@@ -182,6 +217,50 @@ class RoomImageViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
_state.update { it.copy(isLoading = false, error = null) }
|
_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 {
|
} else {
|
||||||
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
|
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,18 @@ import android.provider.OpenableColumns
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
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.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.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.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.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.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.UploadFile
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -49,25 +37,14 @@ 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.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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 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 kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -88,9 +65,8 @@ fun RoomImagesScreen(
|
|||||||
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 originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
val isDraggingAny = remember { mutableStateOf(false) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val pickerLauncher = rememberLauncherForActivityResult(
|
val pickerLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.PickMultipleVisualMedia(20)
|
ActivityResultContracts.PickMultipleVisualMedia(20)
|
||||||
@@ -107,6 +83,7 @@ fun RoomImagesScreen(
|
|||||||
}
|
}
|
||||||
LaunchedEffect(state.images) {
|
LaunchedEffect(state.images) {
|
||||||
orderedImages.value = state.images
|
orderedImages.value = state.images
|
||||||
|
originalOrderIds.value = state.images.mapNotNull { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -203,146 +180,48 @@ fun RoomImagesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
Text(text = "Images", style = MaterialTheme.typography.titleMedium)
|
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))
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
LazyVerticalGrid(
|
ReorderableImageGrid(
|
||||||
columns = GridCells.Fixed(2),
|
images = orderedImages.value,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
gridState = gridState,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
state = gridState,
|
onOrderChange = { list ->
|
||||||
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
|
orderedImages.value = list
|
||||||
viewModel.setLocalOrder(list)
|
},
|
||||||
dragOffset.value = Offset.Zero
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,21 +250,9 @@ fun RoomImagesScreen(
|
|||||||
|
|
||||||
val preview = previewUrl.value
|
val preview = previewUrl.value
|
||||||
if (!preview.isNullOrBlank()) {
|
if (!preview.isNullOrBlank()) {
|
||||||
AlertDialog(
|
ImagePreviewDialog(
|
||||||
onDismissRequest = { previewUrl.value = null },
|
imageUrl = preview,
|
||||||
title = { Text("Preview") },
|
onDismiss = { previewUrl.value = null }
|
||||||
text = {
|
|
||||||
AsyncImage(
|
|
||||||
model = preview,
|
|
||||||
contentDescription = "Image preview",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { previewUrl.value = null }) {
|
|
||||||
Text("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
package com.android.trisolarispms.ui.roomtype
|
package com.android.trisolarispms.ui.roomtype
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Done
|
import androidx.compose.material.icons.filled.Done
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -31,12 +43,17 @@ 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.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.android.trisolarispms.data.api.ApiConstants
|
||||||
import com.android.trisolarispms.data.api.model.RoomTypeDto
|
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
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -46,11 +63,40 @@ fun EditRoomTypeScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
viewModel: RoomTypeFormViewModel = viewModel(),
|
viewModel: RoomTypeFormViewModel = viewModel(),
|
||||||
amenityViewModel: AmenityListViewModel = viewModel()
|
amenityViewModel: AmenityListViewModel = viewModel(),
|
||||||
|
roomImageViewModel: RoomImageViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val amenityState by amenityViewModel.state.collectAsState()
|
val amenityState by amenityViewModel.state.collectAsState()
|
||||||
|
val roomImageState by roomImageViewModel.state.collectAsState()
|
||||||
val showDeleteConfirm = remember { mutableStateOf(false) }
|
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 previewUrl = remember { mutableStateOf<String?>(null) }
|
||||||
|
val showAmenityDialog = remember { mutableStateOf(false) }
|
||||||
|
val amenitySearch = remember { mutableStateOf("") }
|
||||||
|
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) {
|
LaunchedEffect(roomType.id) {
|
||||||
viewModel.setRoomType(roomType)
|
viewModel.setRoomType(roomType)
|
||||||
@@ -58,6 +104,16 @@ fun EditRoomTypeScreen(
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
amenityViewModel.load()
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -86,7 +142,8 @@ fun EditRoomTypeScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp),
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.Top
|
verticalArrangement = Arrangement.Top
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -150,23 +207,62 @@ fun EditRoomTypeScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
|
|
||||||
if (amenityState.items.isEmpty()) {
|
|
||||||
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
|
|
||||||
} else {
|
|
||||||
amenityState.items.forEach { amenity ->
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Button(onClick = { showAmenityDialog.value = true }) {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val selectedCount = state.amenityIds.size
|
||||||
|
Text(
|
||||||
|
text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.wrapContentHeight()
|
.height(320.dp),
|
||||||
.padding(vertical = 4.dp)
|
onOrderChange = { list ->
|
||||||
) {
|
orderedImages.value = list
|
||||||
Checkbox(
|
},
|
||||||
checked = state.amenityIds.contains(amenity.id),
|
onDragEnd = {}
|
||||||
onCheckedChange = { amenity.id?.let { viewModel.onAmenityToggle(it) } }
|
) { image, dragHandleModifier, _ ->
|
||||||
|
RoomImageGridItem(
|
||||||
|
image = image,
|
||||||
|
modifier = dragHandleModifier,
|
||||||
|
onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl },
|
||||||
|
showTags = true
|
||||||
)
|
)
|
||||||
Text(text = amenity.name ?: "", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.error?.let {
|
state.error?.let {
|
||||||
@@ -184,7 +280,7 @@ fun EditRoomTypeScreen(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
showDeleteConfirm.value = false
|
showDeleteConfirm.value = false
|
||||||
viewModel.deleteRoomType(propertyId, roomType.id.orEmpty(), onSave)
|
viewModel.deleteRoomType(propertyId, roomType.id, onSave)
|
||||||
}) {
|
}) {
|
||||||
Text("Delete")
|
Text("Delete")
|
||||||
}
|
}
|
||||||
@@ -196,4 +292,93 @@ fun EditRoomTypeScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showAmenityDialog.value) {
|
||||||
|
val query = amenitySearch.value.trim()
|
||||||
|
val filtered = if (query.isBlank()) {
|
||||||
|
amenityState.items
|
||||||
|
} else {
|
||||||
|
amenityState.items.filter {
|
||||||
|
val name = it.name.orEmpty()
|
||||||
|
val category = it.category.orEmpty()
|
||||||
|
name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showAmenityDialog.value = false },
|
||||||
|
title = { Text("Select Amenities") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amenitySearch.value,
|
||||||
|
onValueChange = { amenitySearch.value = it },
|
||||||
|
label = { Text("Search") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
if (amenityState.items.isEmpty()) {
|
||||||
|
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else if (filtered.isEmpty()) {
|
||||||
|
Text(text = "No matches", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(320.dp)
|
||||||
|
) {
|
||||||
|
items(filtered) { amenity ->
|
||||||
|
val id = amenity.id ?: return@items
|
||||||
|
val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png")
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.amenityIds.contains(id),
|
||||||
|
onCheckedChange = { viewModel.onAmenityToggle(id) }
|
||||||
|
)
|
||||||
|
if (iconKey.isNotBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png",
|
||||||
|
contentDescription = amenity.name ?: iconKey,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = amenity.name.orEmpty())
|
||||||
|
if (!amenity.category.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = amenity.category.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showAmenityDialog.value = false }) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val preview = previewUrl.value
|
||||||
|
if (!preview.isNullOrBlank()) {
|
||||||
|
com.android.trisolarispms.ui.roomimage.ImagePreviewDialog(
|
||||||
|
imageUrl = preview,
|
||||||
|
onDismiss = { previewUrl.value = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ import com.android.trisolarispms.data.api.model.RoomTypeDto
|
|||||||
data class RoomTypeListState(
|
data class RoomTypeListState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ class RoomTypeListViewModel : ViewModel() {
|
|||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.listRoomTypes(propertyId)
|
val response = api.listRoomTypes(propertyId)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
|
val items = response.body().orEmpty()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items = response.body().orEmpty(),
|
items = items,
|
||||||
error = null
|
error = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
loadRoomTypeImages(propertyId, items)
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
_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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package com.android.trisolarispms.ui.roomtype
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
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.Add
|
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -84,15 +88,29 @@ fun RoomTypesScreen(
|
|||||||
Text(text = "No room types")
|
Text(text = "No room types")
|
||||||
} else {
|
} else {
|
||||||
state.items.forEach { item ->
|
state.items.forEach { item ->
|
||||||
|
val imageDto = state.imageByTypeCode[item.code.orEmpty()]
|
||||||
|
val imageUrl = imageDto?.thumbnailUrl ?: imageDto?.url
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.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(
|
||||||
text = "${item.code} • ${item.name}",
|
text = "${item.code} • ${item.name}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(enabled = item.id != null) {
|
|
||||||
onEdit(item)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user