Update amenity form and icon selection

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
package com.android.trisolarispms.ui.roomimage
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ImageTagViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagState())
val state: StateFlow<ImageTagState> = _state
fun load() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listImageTags()
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
tags = response.body().orEmpty(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
fun delete(tagId: String) {
if (tagId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteImageTag(tagId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
tags = current.tags.filterNot { it.id == tagId },
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,122 +1,23 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddAmenityScreen(
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityListViewModel.state.collectAsState()
LaunchedEffect(Unit) {
amenityListViewModel.load()
}
val categorySuggestions = remember(state.category, amenityState.items) {
val all = amenityState.items.mapNotNull { it.category?.trim() }
.filter { it.isNotBlank() }
.distinct()
.sorted()
if (state.category.isBlank()) all
else all.filter { it.contains(state.category, ignoreCase = true) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Amenity") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitCreate(onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.category,
onValueChange = viewModel::onCategoryChange,
label = { Text("Category") },
modifier = Modifier.fillMaxWidth()
)
if (categorySuggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
categorySuggestions.take(5).forEach { suggestion ->
Text(
text = suggestion,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(vertical = 2.dp)
.clickable { viewModel.onCategoryChange(suggestion) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.iconKey,
onValueChange = viewModel::onIconKeyChange,
label = { Text("Icon Key") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.resetForm(preserveOptions = true)
}
AmenityFormScreen(
title = "Add Amenity",
onBack = onBack,
onSaveClick = { viewModel.submitCreate(onSave) },
viewModel = viewModel,
amenityListViewModel = amenityListViewModel
)
}

View File

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

View File

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

View File

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

View File

@@ -1,37 +1,11 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.AmenityDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditAmenityScreen(
amenity: AmenityDto,
onBack: () -> Unit,
@@ -39,92 +13,17 @@ fun EditAmenityScreen(
viewModel: AmenityFormViewModel = viewModel(),
amenityListViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityListViewModel.state.collectAsState()
LaunchedEffect(amenity.id) {
viewModel.setAmenity(amenity)
}
LaunchedEffect(Unit) {
amenityListViewModel.load()
}
val categorySuggestions = remember(state.category, amenityState.items) {
val all = amenityState.items.mapNotNull { it.category?.trim() }
.filter { it.isNotBlank() }
.distinct()
.sorted()
if (state.category.isBlank()) all
else all.filter { it.contains(state.category, ignoreCase = true) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Amenity") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
val id = amenity.id.orEmpty()
viewModel.submitUpdate(id, onSave)
}) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.category,
onValueChange = viewModel::onCategoryChange,
label = { Text("Category") },
modifier = Modifier.fillMaxWidth()
)
if (categorySuggestions.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
categorySuggestions.take(5).forEach { suggestion ->
Text(
text = suggestion,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(vertical = 2.dp)
.clickable { viewModel.onCategoryChange(suggestion) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.iconKey,
onValueChange = viewModel::onIconKeyChange,
label = { Text("Icon Key") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
AmenityFormScreen(
title = "Edit Amenity",
onBack = onBack,
onSaveClick = {
val id = amenity.id.orEmpty()
viewModel.submitUpdate(id, onSave)
},
viewModel = viewModel,
amenityListViewModel = amenityListViewModel
)
}