From dbbcb6c4a6684ece4e6e13379fadb9700fd832c8 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Wed, 28 Jan 2026 00:41:04 +0530 Subject: [PATCH] Unify image grids and room type image ordering --- .../trisolarispms/data/api/RoomImageApi.kt | 6 + .../data/api/model/RoomTypeModels.kt | 2 + .../ui/roomimage/AddImageTagScreen.kt | 4 +- .../ui/roomimage/ReorderableImageGrid.kt | 123 +++++++++++ .../ui/roomimage/RoomImageGridItem.kt | 98 +++++++++ .../ui/roomimage/RoomImageViewModel.kt | 83 +++++++- .../ui/roomimage/RoomImagesScreen.kt | 194 ++++-------------- .../ui/roomtype/EditRoomTypeScreen.kt | 99 ++++++++- .../ui/roomtype/RoomTypeListState.kt | 3 +- .../ui/roomtype/RoomTypeListViewModel.kt | 26 ++- .../ui/roomtype/RoomTypesScreen.kt | 32 ++- 11 files changed, 496 insertions(+), 174 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ReorderableImageGrid.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageGridItem.kt diff --git a/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt index 8e768ec..9498442 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt @@ -16,6 +16,12 @@ import retrofit2.http.Streaming import retrofit2.http.DELETE interface RoomImageApi { + @GET("properties/{propertyId}/room-types/{roomTypeCode}/images") + suspend fun listRoomTypeImages( + @Path("propertyId") propertyId: String, + @Path("roomTypeCode") roomTypeCode: String + ): Response> + @GET("properties/{propertyId}/rooms/{roomId}/images") suspend fun listRoomImages( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt index 4497cad..0ac4249 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt @@ -1,6 +1,7 @@ package com.android.trisolarispms.data.api.model import com.android.trisolarispms.data.api.model.AmenityDto +import com.android.trisolarispms.data.api.model.ImageDto data class RoomTypeCreateRequest( val code: String, @@ -34,5 +35,6 @@ data class RoomTypeDto( val sqFeet: Int? = null, val bathroomSqFeet: Int? = null, val amenities: List? = null, + val images: List? = null, val otaAliases: List? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt index eb866e5..ee79fb2 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Done import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -41,7 +41,7 @@ fun AddImageTagScreen( title = { Text("Add Tag") }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ReorderableImageGrid.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ReorderableImageGrid.kt new file mode 100644 index 0000000..4919fe6 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ReorderableImageGrid.kt @@ -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, + 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) -> Unit, + onDragEnd: (List) -> 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) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageGridItem.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageGridItem.kt new file mode 100644 index 0000000..f817e69 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageGridItem.kt @@ -0,0 +1,98 @@ +package com.android.trisolarispms.ui.roomimage + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material.icons.filled.Label +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.android.trisolarispms.data.api.model.ImageDto + +@Composable +fun RoomImageGridItem( + image: ImageDto, + modifier: Modifier, + onPreview: (() -> Unit)? = null, + onEditTags: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, + showTags: Boolean = true +) { + Box(modifier = Modifier.fillMaxWidth()) { + AsyncImage( + model = image.thumbnailUrl ?: image.url, + contentDescription = "Room image", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3f) + .then( + if (onPreview != null) Modifier.clickable { onPreview() } else Modifier + ), + contentScale = ContentScale.Crop + ) + val tags = if (showTags) { + image.tags + .orEmpty() + .mapNotNull { it.name ?: it.id } + .joinToString(" • ") + } else "" + if (tags.isNotBlank()) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), + shape = MaterialTheme.shapes.small, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + ) { + Text( + text = tags, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 2.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val hasId = !image.id.isNullOrBlank() + IconButton( + onClick = {}, + enabled = hasId, + modifier = if (hasId) modifier else Modifier + ) { + Icon(Icons.Default.DragIndicator, contentDescription = "Drag") + } + if (onEditTags != null) { + IconButton(onClick = onEditTags, enabled = hasId) { + Icon(Icons.Default.Label, contentDescription = "Edit Tags") + } + } + if (onDelete != null) { + IconButton(onClick = onDelete, enabled = hasId) { + Icon(Icons.Default.Delete, contentDescription = "Delete Image") + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt index dc32aed..faa4f31 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt @@ -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) { _state.update { it.copy(images = images) } } @@ -147,7 +171,12 @@ class RoomImageViewModel : ViewModel() { } } - fun reorderRoom(propertyId: String, roomId: String, imageIds: List) { + fun reorderRoom( + propertyId: String, + roomId: String, + imageIds: List, + onDone: (() -> Unit)? = null + ) { if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } @@ -160,6 +189,7 @@ class RoomImageViewModel : ViewModel() { ) if (response.isSuccessful) { _state.update { it.copy(isLoading = false, error = null) } + onDone?.invoke() } else { _state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") } } @@ -169,7 +199,12 @@ class RoomImageViewModel : ViewModel() { } } - fun reorderRoomType(propertyId: String, roomId: String, imageIds: List) { + fun reorderRoomType( + propertyId: String, + roomId: String, + imageIds: List, + onDone: (() -> Unit)? = null + ) { if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } @@ -182,6 +217,50 @@ class RoomImageViewModel : ViewModel() { ) if (response.isSuccessful) { _state.update { it.copy(isLoading = false, error = null) } + onDone?.invoke() + } else { + _state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Reorder failed") } + } + } + } + + fun reorderRoomTypeWithFallback( + propertyId: String, + roomTypeCode: String, + roomId: String, + imageIds: List, + onDone: (() -> Unit)? = null + ) { + if (propertyId.isBlank() || roomTypeCode.isBlank() || imageIds.isEmpty()) return + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val resolvedRoomId = if (roomId.isNotBlank()) { + roomId + } else { + val roomsResponse = api.listRooms(propertyId) + roomsResponse.body() + .orEmpty() + .firstOrNull { it.roomTypeCode == roomTypeCode } + ?.id + .orEmpty() + } + if (resolvedRoomId.isBlank()) { + _state.update { it.copy(isLoading = false, error = "Missing roomId for reorder") } + return@launch + } + val response = api.reorderRoomTypeImages( + propertyId = propertyId, + roomId = resolvedRoomId, + body = RoomImageReorderRequest(imageIds) + ) + if (response.isSuccessful) { + _state.update { it.copy(isLoading = false, error = null) } + onDone?.invoke() } else { _state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt index 996b149..c461983 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt @@ -5,30 +5,18 @@ import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.DragIndicator -import androidx.compose.material.icons.filled.Label -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -49,25 +37,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody -import kotlin.math.roundToInt -import kotlinx.coroutines.launch @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -88,9 +66,8 @@ fun RoomImagesScreen( val deleteImageId = remember { mutableStateOf(null) } val previewUrl = remember { mutableStateOf(null) } val orderedImages = remember { mutableStateOf>(emptyList()) } + val originalOrderIds = remember { mutableStateOf>(emptyList()) } val gridState = rememberLazyGridState() - val isDraggingAny = remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() val pickerLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(20) @@ -107,6 +84,7 @@ fun RoomImagesScreen( } LaunchedEffect(state.images) { orderedImages.value = state.images + originalOrderIds.value = state.images.mapNotNull { it.id } } Scaffold( @@ -203,145 +181,47 @@ fun RoomImagesScreen( } Spacer(modifier = Modifier.height(16.dp)) - Text(text = "Images", style = MaterialTheme.typography.titleMedium) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Images", style = MaterialTheme.typography.titleMedium) + val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value + if (orderedImages.value.isNotEmpty() && hasOrderChanged) { + Button(onClick = { + val ids = orderedImages.value.mapNotNull { it.id } + viewModel.reorderRoom(propertyId, roomId, ids) { + originalOrderIds.value = ids + } + }) { + Text("Save order") + } + } + } Spacer(modifier = Modifier.height(8.dp)) - Text(text = "Use drag icon to reorder. Saves automatically.", style = MaterialTheme.typography.bodySmall) + Text(text = "Use drag icon to reorder.", style = MaterialTheme.typography.bodySmall) Spacer(modifier = Modifier.height(8.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), + ReorderableImageGrid( + images = orderedImages.value, + gridState = gridState, modifier = Modifier .fillMaxWidth() .fillMaxHeight(), - state = gridState, - userScrollEnabled = !isDraggingAny.value - ) { - itemsIndexed( - orderedImages.value, - key = { index, image -> image.id ?: "image-$index" } - ) { index, image -> - val imageId = image.id - val dragOffset = remember { mutableStateOf(Offset.Zero) } - val itemSize = remember { mutableStateOf(IntSize.Zero) } - val isDragging = remember { mutableStateOf(false) } - Column( - modifier = Modifier - .onGloballyPositioned { coords -> - if (itemSize.value.width == 0) { - itemSize.value = coords.size - } - } - .graphicsLayer { - if (isDragging.value) { - translationX = dragOffset.value.x - translationY = dragOffset.value.y - } - } - .zIndex(if (isDragging.value) 1f else 0f) - ) { - Box(modifier = Modifier.fillMaxWidth()) { - AsyncImage( - model = image.thumbnailUrl ?: image.url, - contentDescription = "Room image", - modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3f) - .clickable { previewUrl.value = image.url ?: image.thumbnailUrl }, - contentScale = ContentScale.Crop - ) - val tags = image.tags - .orEmpty() - .mapNotNull { it.name ?: it.id } - .joinToString(" • ") - if (tags.isNotBlank()) { - androidx.compose.material3.Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), - shape = MaterialTheme.shapes.small, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(6.dp) - ) { - Text( - text = tags, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - if (!image.id.isNullOrBlank()) { - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 2.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = {}, - modifier = Modifier.pointerInput(orderedImages.value, index) { - detectDragGestures( - onDragStart = { - isDraggingAny.value = true - isDragging.value = true - dragOffset.value = Offset.Zero - }, - onDragCancel = { - isDraggingAny.value = false - isDragging.value = false - dragOffset.value = Offset.Zero - }, - onDragEnd = { - isDraggingAny.value = false - isDragging.value = false - dragOffset.value = Offset.Zero - val ids = orderedImages.value.mapNotNull { it.id } - viewModel.reorderRoom(propertyId, roomId, ids) - }, - onDrag = { change, dragAmount -> - change.consume() - if (dragAmount.y != 0f) { - scope.launch { - gridState.scrollBy(dragAmount.y * 0.5f) - } - } - dragOffset.value += dragAmount - val size = itemSize.value - if (size.width > 0 && size.height > 0) { - val colDelta = (dragOffset.value.x / size.width).roundToInt() - val rowDelta = (dragOffset.value.y / size.height).roundToInt() - val columns = 2 - val currentIndex = orderedImages.value.indexOfFirst { it.id == imageId } - if (currentIndex == -1) return@detectDragGestures - val target = (currentIndex + rowDelta * columns + colDelta) - .coerceIn(0, orderedImages.value.lastIndex) - if (target != currentIndex) { - val list = orderedImages.value.toMutableList() - val item = list.removeAt(currentIndex) - list.add(target, item) - orderedImages.value = list - viewModel.setLocalOrder(list) - dragOffset.value = Offset.Zero - } - } - } - ) - } - ) { - Icon(Icons.Default.DragIndicator, contentDescription = "Drag") - } - IconButton(onClick = { tagDialogImage.value = image }) { - Icon(Icons.Default.Label, contentDescription = "Edit Tags") - } - IconButton(onClick = { deleteImageId.value = image.id }) { - Icon(Icons.Default.Delete, contentDescription = "Delete Image") - } - } - } - } - } - } + onOrderChange = { list -> + orderedImages.value = list + }, + onDragEnd = {} + ) { image, dragHandleModifier, _ -> + RoomImageGridItem( + image = image, + modifier = dragHandleModifier, + onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl }, + onEditTags = { tagDialogImage.value = image }, + onDelete = { deleteImageId.value = image.id }, + showTags = true + ) } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt index 79b7d13..59ecc47 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt @@ -1,19 +1,28 @@ package com.android.trisolarispms.ui.roomtype import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.DragIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Done import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -31,12 +40,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.android.trisolarispms.data.api.model.RoomTypeDto +import com.android.trisolarispms.ui.roomimage.RoomImageGridItem +import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid +import com.android.trisolarispms.ui.roomimage.RoomImageViewModel @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -46,11 +58,37 @@ fun EditRoomTypeScreen( onBack: () -> Unit, onSave: () -> Unit, viewModel: RoomTypeFormViewModel = viewModel(), - amenityViewModel: AmenityListViewModel = viewModel() + amenityViewModel: AmenityListViewModel = viewModel(), + roomImageViewModel: RoomImageViewModel = viewModel() ) { val state by viewModel.state.collectAsState() val amenityState by amenityViewModel.state.collectAsState() + val roomImageState by roomImageViewModel.state.collectAsState() val showDeleteConfirm = remember { mutableStateOf(false) } + val orderedImages = remember { mutableStateOf>(emptyList()) } + val originalOrderIds = remember { mutableStateOf>(emptyList()) } + val gridState = rememberLazyGridState() + val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) { + { + val code = roomType.code.orEmpty() + val ids = orderedImages.value.mapNotNull { it.id } + val roomId = orderedImages.value + .firstOrNull { !it.roomId.isNullOrBlank() } + ?.roomId + .orEmpty() + if (code.isNotBlank() && ids.isNotEmpty()) { + roomImageViewModel.reorderRoomTypeWithFallback( + propertyId = propertyId, + roomTypeCode = code, + roomId = roomId, + imageIds = ids + ) { + originalOrderIds.value = ids + roomImageViewModel.loadRoomTypeImages(propertyId, code) + } + } + } + } LaunchedEffect(roomType.id) { viewModel.setRoomType(roomType) @@ -58,6 +96,16 @@ fun EditRoomTypeScreen( LaunchedEffect(Unit) { amenityViewModel.load() } + LaunchedEffect(roomType.code) { + val code = roomType.code.orEmpty() + if (code.isNotBlank()) { + roomImageViewModel.loadRoomTypeImages(propertyId, code) + } + } + LaunchedEffect(roomImageState.images) { + orderedImages.value = roomImageState.images + originalOrderIds.value = roomImageState.images.mapNotNull { it.id } + } Scaffold( topBar = { @@ -86,7 +134,8 @@ fun EditRoomTypeScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(24.dp), + .padding(24.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.Top ) { OutlinedTextField( @@ -169,6 +218,48 @@ fun EditRoomTypeScreen( } } } + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Room Type Images", style = MaterialTheme.typography.titleSmall) + val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value + if (orderedImages.value.isNotEmpty() && hasOrderChanged) { + Button(onClick = saveRoomTypeImageOrder) { + Text("Save order") + } + } + } + if (roomImageState.isLoading) { + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.CircularProgressIndicator() + } else if (orderedImages.value.isEmpty()) { + Text(text = "No images yet", style = MaterialTheme.typography.bodySmall) + } else { + Spacer(modifier = Modifier.height(8.dp)) + ReorderableImageGrid( + images = orderedImages.value, + gridState = gridState, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + onOrderChange = { list -> + orderedImages.value = list + }, + onDragEnd = {} + ) { image, dragHandleModifier, _ -> + RoomImageGridItem( + image = image, + modifier = dragHandleModifier, + showTags = true + ) + } + } state.error?.let { Spacer(modifier = Modifier.height(12.dp)) Text(text = it, color = MaterialTheme.colorScheme.error) @@ -184,7 +275,7 @@ fun EditRoomTypeScreen( confirmButton = { TextButton(onClick = { showDeleteConfirm.value = false - viewModel.deleteRoomType(propertyId, roomType.id.orEmpty(), onSave) + viewModel.deleteRoomType(propertyId, roomType.id, onSave) }) { Text("Delete") } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt index 0380870..b489a59 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt @@ -5,5 +5,6 @@ import com.android.trisolarispms.data.api.model.RoomTypeDto data class RoomTypeListState( val isLoading: Boolean = false, val error: String? = null, - val items: List = emptyList() + val items: List = emptyList(), + val imageByTypeCode: Map = emptyMap() ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt index 46f2b4e..a248d10 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt @@ -20,13 +20,15 @@ class RoomTypeListViewModel : ViewModel() { val api = ApiClient.create() val response = api.listRoomTypes(propertyId) if (response.isSuccessful) { + val items = response.body().orEmpty() _state.update { it.copy( isLoading = false, - items = response.body().orEmpty(), + items = items, error = null ) } + loadRoomTypeImages(propertyId, items) } else { _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } @@ -35,4 +37,26 @@ class RoomTypeListViewModel : ViewModel() { } } } + + private fun loadRoomTypeImages(propertyId: String, items: List) { + viewModelScope.launch { + val api = ApiClient.create() + val updates = mutableMapOf() + 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) } + } + } + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt index b247b14..3d235aa 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt @@ -3,11 +3,13 @@ package com.android.trisolarispms.ui.roomtype import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -25,8 +27,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -84,15 +88,29 @@ fun RoomTypesScreen( Text(text = "No room types") } else { state.items.forEach { item -> - Text( - text = "${item.code} • ${item.name}", - style = MaterialTheme.typography.titleMedium, + val imageDto = state.imageByTypeCode[item.code.orEmpty()] + val imageUrl = imageDto?.thumbnailUrl ?: imageDto?.url + Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = item.id != null) { - onEdit(item) - } - ) + .clickable(enabled = item.id != null) { onEdit(item) }, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (!imageUrl.isNullOrBlank()) { + AsyncImage( + model = imageUrl, + contentDescription = item.name ?: item.code ?: "Room type image", + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + ) + } + Text( + text = "${item.code} • ${item.name}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + } Spacer(modifier = Modifier.height(12.dp)) } }