Compare commits

..

2 Commits

Author SHA1 Message Date
androidlover5842
d8a40e4c9a Add amenity picker and room type active flag 2026-01-28 00:52:30 +05:30
androidlover5842
dbbcb6c4a6 Unify image grids and room type image ordering 2026-01-28 00:41:04 +05:30
13 changed files with 646 additions and 210 deletions

View File

@@ -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,

View File

@@ -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
) )

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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")
}
}
)
}

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,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")
}
}
}
}
}

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>) { 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()}") }
} }

View File

@@ -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,145 +180,47 @@ fun RoomImagesScreen(
} }
Spacer(modifier = Modifier.height(16.dp)) 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)) 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 orderedImages.value = list
) { },
itemsIndexed( onDragEnd = {}
orderedImages.value, ) { image, dragHandleModifier, _ ->
key = { index, image -> image.id ?: "image-$index" } RoomImageGridItem(
) { index, image -> image = image,
val imageId = image.id modifier = dragHandleModifier,
val dragOffset = remember { mutableStateOf(Offset.Zero) } onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl },
val itemSize = remember { mutableStateOf(IntSize.Zero) } onEditTags = { tagDialogImage.value = image },
val isDragging = remember { mutableStateOf(false) } onDelete = { deleteImageId.value = image.id },
Column( showTags = true
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")
}
}
}
}
}
}
} }
} }
@@ -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")
}
}
) )
} }

View File

@@ -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,25 +207,64 @@ fun EditRoomTypeScreen(
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall) Row(
if (amenityState.items.isEmpty()) { modifier = Modifier.fillMaxWidth(),
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall) horizontalArrangement = Arrangement.SpaceBetween,
} else { verticalAlignment = Alignment.CenterVertically
amenityState.items.forEach { amenity -> ) {
Row( Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
modifier = Modifier Button(onClick = { showAmenityDialog.value = true }) {
.fillMaxWidth() Text("Edit")
.wrapContentHeight() }
.padding(vertical = 4.dp) }
) { val selectedCount = state.amenityIds.size
Checkbox( Text(
checked = state.amenityIds.contains(amenity.id), text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected",
onCheckedChange = { amenity.id?.let { viewModel.onAmenityToggle(it) } } style = MaterialTheme.typography.bodySmall
) )
Text(text = amenity.name ?: "", modifier = Modifier.padding(start = 8.dp)) 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,
onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl },
showTags = true
)
}
}
state.error?.let { state.error?.let {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error) Text(text = it, color = MaterialTheme.colorScheme.error)
@@ -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 }
)
}
} }

View File

@@ -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()
) )

View File

@@ -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) }
}
}
}
} }

View File

@@ -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 ->
Text( val imageDto = state.imageByTypeCode[item.code.orEmpty()]
text = "${item.code}${item.name}", val imageUrl = imageDto?.thumbnailUrl ?: imageDto?.url
style = MaterialTheme.typography.titleMedium, Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = item.id != null) { .clickable(enabled = item.id != null) { onEdit(item) },
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)) Spacer(modifier = Modifier.height(12.dp))
} }
} }