Update amenity form and icon selection
This commit is contained in:
@@ -19,6 +19,9 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen
|
|||||||
import com.android.trisolarispms.ui.room.RoomFormScreen
|
import com.android.trisolarispms.ui.room.RoomFormScreen
|
||||||
import com.android.trisolarispms.ui.room.RoomsScreen
|
import com.android.trisolarispms.ui.room.RoomsScreen
|
||||||
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
|
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.roomstay.ActiveRoomStaysScreen
|
||||||
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
|
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
|
||||||
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
|
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 selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
|
||||||
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(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 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 roomFormKey = remember { mutableStateOf(0) }
|
||||||
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
|
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
|
||||||
val currentRoute = route.value
|
val currentRoute = route.value
|
||||||
@@ -69,6 +73,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
amenitiesReturnRoute.value = AppRoute.Home
|
amenitiesReturnRoute.value = AppRoute.Home
|
||||||
route.value = AppRoute.Amenities
|
route.value = AppRoute.Amenities
|
||||||
},
|
},
|
||||||
|
onImageTags = { route.value = AppRoute.ImageTags },
|
||||||
refreshKey = refreshKey.value,
|
refreshKey = refreshKey.value,
|
||||||
selectedPropertyId = selectedPropertyId.value,
|
selectedPropertyId = selectedPropertyId.value,
|
||||||
onSelectProperty = { id, name ->
|
onSelectProperty = { id, name ->
|
||||||
@@ -152,6 +157,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
onBack = { route.value = AppRoute.Amenities },
|
onBack = { route.value = AppRoute.Amenities },
|
||||||
onSave = { 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(
|
is AppRoute.AddRoom -> RoomFormScreen(
|
||||||
title = "Add Room",
|
title = "Add Room",
|
||||||
propertyId = currentRoute.propertyId,
|
propertyId = currentRoute.propertyId,
|
||||||
|
|||||||
@@ -30,4 +30,7 @@ interface AmenityApi {
|
|||||||
suspend fun deleteAmenity(
|
suspend fun deleteAmenity(
|
||||||
@Path("amenityId") amenityId: String
|
@Path("amenityId") amenityId: String
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("icons/png")
|
||||||
|
suspend fun listAmenityIconKeys(): Response<List<String>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface ApiService :
|
|||||||
RoomTypeApi,
|
RoomTypeApi,
|
||||||
RoomApi,
|
RoomApi,
|
||||||
RoomImageApi,
|
RoomImageApi,
|
||||||
|
ImageTagApi,
|
||||||
BookingApi,
|
BookingApi,
|
||||||
RoomStayApi,
|
RoomStayApi,
|
||||||
CardApi,
|
CardApi,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ interface RoomImageApi {
|
|||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("roomId") roomId: String,
|
@Path("roomId") roomId: String,
|
||||||
@Part file: MultipartBody.Part,
|
@Part file: MultipartBody.Part,
|
||||||
@Part tags: List<MultipartBody.Part>? = null
|
@Query("tagIds") tagIds: List<String>? = null
|
||||||
): Response<ImageDto>
|
): Response<ImageDto>
|
||||||
|
|
||||||
@Streaming
|
@Streaming
|
||||||
@@ -47,6 +47,14 @@ interface RoomImageApi {
|
|||||||
@Path("imageId") imageId: String
|
@Path("imageId") imageId: String
|
||||||
): Response<Unit>
|
): 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")
|
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room")
|
||||||
suspend fun reorderRoomImages(
|
suspend fun reorderRoomImages(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ data class ImageDto(
|
|||||||
val thumbnailUrl: String? = null,
|
val thumbnailUrl: String? = null,
|
||||||
val contentType: String? = null,
|
val contentType: String? = null,
|
||||||
val sizeBytes: Long? = null,
|
val sizeBytes: Long? = null,
|
||||||
val tags: List<String>? = null,
|
val tags: List<RoomImageTagDto>? = null,
|
||||||
val roomSortOrder: Int? = null,
|
val roomSortOrder: Int? = null,
|
||||||
val roomTypeSortOrder: Int? = null,
|
val roomTypeSortOrder: Int? = null,
|
||||||
val createdAt: String? = null
|
val createdAt: String? = null
|
||||||
@@ -69,3 +69,12 @@ data class ImageDto(
|
|||||||
data class RoomImageReorderRequest(
|
data class RoomImageReorderRequest(
|
||||||
val imageIds: List<String>
|
val imageIds: List<String>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RoomImageTagDto(
|
||||||
|
val id: String? = null,
|
||||||
|
val name: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoomImageTagUpsertRequest(
|
||||||
|
val tagIds: List<String>
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,4 +14,7 @@ sealed interface AppRoute {
|
|||||||
data object AddAmenity : AppRoute
|
data object AddAmenity : AppRoute
|
||||||
data class EditAmenity(val amenityId: String) : AppRoute
|
data class EditAmenity(val amenityId: String) : AppRoute
|
||||||
data class RoomImages(val propertyId: String, val roomId: 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fun HomeScreen(
|
|||||||
isSuperAdmin: Boolean,
|
isSuperAdmin: Boolean,
|
||||||
onAddProperty: () -> Unit,
|
onAddProperty: () -> Unit,
|
||||||
onAmenities: () -> Unit,
|
onAmenities: () -> Unit,
|
||||||
|
onImageTags: () -> Unit,
|
||||||
refreshKey: Int,
|
refreshKey: Int,
|
||||||
selectedPropertyId: String?,
|
selectedPropertyId: String?,
|
||||||
onSelectProperty: (String, String) -> Unit,
|
onSelectProperty: (String, String) -> Unit,
|
||||||
@@ -79,6 +80,13 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Update Tags") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onImageTags()
|
||||||
|
}
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Modify Amenities") },
|
text = { Text("Modify Amenities") },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ class RoomImageViewModel : ViewModel() {
|
|||||||
propertyId: String,
|
propertyId: String,
|
||||||
roomId: String,
|
roomId: String,
|
||||||
filePart: MultipartBody.Part,
|
filePart: MultipartBody.Part,
|
||||||
tags: List<MultipartBody.Part>?,
|
tagIds: List<String>?,
|
||||||
onDone: (ImageDto) -> Unit
|
onDone: (ImageDto) -> Unit
|
||||||
) {
|
) {
|
||||||
if (propertyId.isBlank() || roomId.isBlank()) return
|
if (propertyId.isBlank() || roomId.isBlank()) return
|
||||||
@@ -59,7 +59,7 @@ class RoomImageViewModel : ViewModel() {
|
|||||||
propertyId = propertyId,
|
propertyId = propertyId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
file = filePart,
|
file = filePart,
|
||||||
tags = tags
|
tagIds = tagIds
|
||||||
)
|
)
|
||||||
val body = response.body()
|
val body = response.body()
|
||||||
if (response.isSuccessful && body != null) {
|
if (response.isSuccessful && body != null) {
|
||||||
@@ -89,7 +89,7 @@ class RoomImageViewModel : ViewModel() {
|
|||||||
propertyId: String,
|
propertyId: String,
|
||||||
roomId: String,
|
roomId: String,
|
||||||
fileParts: List<MultipartBody.Part>,
|
fileParts: List<MultipartBody.Part>,
|
||||||
tags: List<MultipartBody.Part>?,
|
tagIds: List<String>?,
|
||||||
onDone: () -> Unit
|
onDone: () -> Unit
|
||||||
) {
|
) {
|
||||||
if (propertyId.isBlank() || roomId.isBlank() || fileParts.isEmpty()) return
|
if (propertyId.isBlank() || roomId.isBlank() || fileParts.isEmpty()) return
|
||||||
@@ -102,7 +102,7 @@ class RoomImageViewModel : ViewModel() {
|
|||||||
propertyId = propertyId,
|
propertyId = propertyId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
file = part,
|
file = part,
|
||||||
tags = tags
|
tagIds = tagIds
|
||||||
)
|
)
|
||||||
val body = response.body()
|
val body = response.body()
|
||||||
if (response.isSuccessful && body != null) {
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,27 @@ 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.clickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
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.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.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.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.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.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
|
||||||
@@ -40,25 +49,25 @@ 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.ui.Modifier
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
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.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
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 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 kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -66,16 +75,22 @@ fun RoomImagesScreen(
|
|||||||
propertyId: String,
|
propertyId: String,
|
||||||
roomId: String,
|
roomId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: RoomImageViewModel = viewModel()
|
viewModel: RoomImageViewModel = viewModel(),
|
||||||
|
tagViewModel: ImageTagViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val state by viewModel.state.collectAsState()
|
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 selectedUris = remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||||
val selectedNames = remember { mutableStateOf<List<String>>(emptyList()) }
|
val selectedNames = remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
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 gridState = rememberLazyGridState()
|
||||||
|
val isDraggingAny = remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val pickerLauncher = rememberLauncherForActivityResult(
|
val pickerLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.PickMultipleVisualMedia(20)
|
ActivityResultContracts.PickMultipleVisualMedia(20)
|
||||||
@@ -87,6 +102,9 @@ fun RoomImagesScreen(
|
|||||||
LaunchedEffect(propertyId, roomId) {
|
LaunchedEffect(propertyId, roomId) {
|
||||||
viewModel.load(propertyId, roomId)
|
viewModel.load(propertyId, roomId)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
tagViewModel.load()
|
||||||
|
}
|
||||||
LaunchedEffect(state.images) {
|
LaunchedEffect(state.images) {
|
||||||
orderedImages.value = state.images
|
orderedImages.value = state.images
|
||||||
}
|
}
|
||||||
@@ -150,12 +168,7 @@ fun RoomImagesScreen(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
Text(text = "Tags can be managed after upload.", style = MaterialTheme.typography.bodySmall)
|
||||||
value = tagsText.value,
|
|
||||||
onValueChange = { tagsText.value = it },
|
|
||||||
label = { Text("Tags (comma separated)") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -169,22 +182,14 @@ fun RoomImagesScreen(
|
|||||||
MultipartBody.Part.createFormData("file", name, requestBody)
|
MultipartBody.Part.createFormData("file", name, requestBody)
|
||||||
}
|
}
|
||||||
if (parts.isEmpty()) return@Button
|
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(
|
viewModel.uploadBatch(
|
||||||
propertyId = propertyId,
|
propertyId = propertyId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
fileParts = parts,
|
fileParts = parts,
|
||||||
tags = tagBodies,
|
tagIds = null,
|
||||||
onDone = {
|
onDone = {
|
||||||
selectedUris.value = emptyList()
|
selectedUris.value = emptyList()
|
||||||
selectedNames.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)
|
Text(text = "Images", style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(2),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(),
|
||||||
|
state = gridState,
|
||||||
|
userScrollEnabled = !isDraggingAny.value
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
orderedImages.value,
|
orderedImages.value,
|
||||||
@@ -224,17 +233,67 @@ fun RoomImagesScreen(
|
|||||||
itemSize.value = coords.size
|
itemSize.value = coords.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pointerInput(orderedImages.value, index) {
|
.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(
|
detectDragGestures(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
|
isDraggingAny.value = true
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
dragOffset.value = Offset.Zero
|
dragOffset.value = Offset.Zero
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
|
isDraggingAny.value = false
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
dragOffset.value = Offset.Zero
|
dragOffset.value = Offset.Zero
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
|
isDraggingAny.value = false
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
dragOffset.value = Offset.Zero
|
dragOffset.value = Offset.Zero
|
||||||
val ids = orderedImages.value.mapNotNull { it.id }
|
val ids = orderedImages.value.mapNotNull { it.id }
|
||||||
@@ -242,6 +301,11 @@ fun RoomImagesScreen(
|
|||||||
},
|
},
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
|
if (dragAmount.y != 0f) {
|
||||||
|
scope.launch {
|
||||||
|
gridState.scrollBy(dragAmount.y * 0.5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
dragOffset.value += dragAmount
|
dragOffset.value += dragAmount
|
||||||
val size = itemSize.value
|
val size = itemSize.value
|
||||||
if (size.width > 0 && size.height > 0) {
|
if (size.width > 0 && size.height > 0) {
|
||||||
@@ -264,42 +328,17 @@ fun RoomImagesScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.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)
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = image.thumbnailUrl ?: image.url,
|
|
||||||
contentDescription = "Room image",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(160.dp)
|
|
||||||
.clickable { previewUrl.value = image.url ?: image.thumbnailUrl }
|
|
||||||
)
|
|
||||||
if (!image.id.isNullOrBlank()) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { deleteImageId.value = image.id },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(top = 10.dp)
|
|
||||||
.offset(y = 2.dp)
|
|
||||||
) {
|
) {
|
||||||
|
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")
|
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? {
|
private fun getDisplayName(context: android.content.Context, uri: Uri): String? {
|
||||||
|
|||||||
@@ -1,122 +1,23 @@
|
|||||||
package com.android.trisolarispms.ui.roomtype
|
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.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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
fun AddAmenityScreen(
|
fun AddAmenityScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
viewModel: AmenityFormViewModel = viewModel(),
|
viewModel: AmenityFormViewModel = viewModel(),
|
||||||
amenityListViewModel: AmenityListViewModel = viewModel()
|
amenityListViewModel: AmenityListViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
val amenityState by amenityListViewModel.state.collectAsState()
|
viewModel.resetForm(preserveOptions = true)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
amenityListViewModel.load()
|
|
||||||
}
|
}
|
||||||
|
AmenityFormScreen(
|
||||||
val categorySuggestions = remember(state.category, amenityState.items) {
|
title = "Add Amenity",
|
||||||
val all = amenityState.items.mapNotNull { it.category?.trim() }
|
onBack = onBack,
|
||||||
.filter { it.isNotBlank() }
|
onSaveClick = { viewModel.submitCreate(onSave) },
|
||||||
.distinct()
|
viewModel = viewModel,
|
||||||
.sorted()
|
amenityListViewModel = amenityListViewModel
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ data class AmenityFormState(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
val category: String = "",
|
val category: String = "",
|
||||||
val iconKey: String = "",
|
val iconKey: String = "",
|
||||||
|
val iconKeyOptions: List<String> = emptyList(),
|
||||||
|
val iconKeyLoading: Boolean = false,
|
||||||
|
val iconKeyError: String? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val success: Boolean = false
|
val success: Boolean = false
|
||||||
|
|||||||
@@ -37,6 +37,56 @@ class AmenityFormViewModel : ViewModel() {
|
|||||||
_state.update { it.copy(iconKey = value, error = null) }
|
_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) {
|
fun submitCreate(onDone: () -> Unit) {
|
||||||
val name = state.value.name.trim()
|
val name = state.value.name.trim()
|
||||||
if (name.isBlank()) {
|
if (name.isBlank()) {
|
||||||
@@ -46,12 +96,13 @@ class AmenityFormViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
|
val iconKey = state.value.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG")
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.createAmenity(
|
val response = api.createAmenity(
|
||||||
AmenityCreateRequest(
|
AmenityCreateRequest(
|
||||||
name = name,
|
name = name,
|
||||||
category = state.value.category.trim().ifBlank { null },
|
category = state.value.category.trim().ifBlank { null },
|
||||||
iconKey = state.value.iconKey.trim().ifBlank { null }
|
iconKey = iconKey.ifBlank { null }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
@@ -75,13 +126,14 @@ class AmenityFormViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
|
val iconKey = state.value.iconKey.trim().removeSuffix(".png").removeSuffix(".PNG")
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.updateAmenity(
|
val response = api.updateAmenity(
|
||||||
amenityId,
|
amenityId,
|
||||||
AmenityUpdateRequest(
|
AmenityUpdateRequest(
|
||||||
name = name,
|
name = name,
|
||||||
category = state.value.category.trim().ifBlank { null },
|
category = state.value.category.trim().ifBlank { null },
|
||||||
iconKey = state.value.iconKey.trim().ifBlank { null }
|
iconKey = iconKey.ifBlank { null }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
|
|||||||
@@ -1,37 +1,11 @@
|
|||||||
package com.android.trisolarispms.ui.roomtype
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.android.trisolarispms.data.api.model.AmenityDto
|
import com.android.trisolarispms.data.api.model.AmenityDto
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
fun EditAmenityScreen(
|
fun EditAmenityScreen(
|
||||||
amenity: AmenityDto,
|
amenity: AmenityDto,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -39,92 +13,17 @@ fun EditAmenityScreen(
|
|||||||
viewModel: AmenityFormViewModel = viewModel(),
|
viewModel: AmenityFormViewModel = viewModel(),
|
||||||
amenityListViewModel: AmenityListViewModel = viewModel()
|
amenityListViewModel: AmenityListViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
|
||||||
val amenityState by amenityListViewModel.state.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(amenity.id) {
|
LaunchedEffect(amenity.id) {
|
||||||
viewModel.setAmenity(amenity)
|
viewModel.setAmenity(amenity)
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
AmenityFormScreen(
|
||||||
amenityListViewModel.load()
|
title = "Edit Amenity",
|
||||||
}
|
onBack = onBack,
|
||||||
|
onSaveClick = {
|
||||||
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()
|
val id = amenity.id.orEmpty()
|
||||||
viewModel.submitUpdate(id, onSave)
|
viewModel.submitUpdate(id, onSave)
|
||||||
}) {
|
|
||||||
Icon(Icons.Default.Done, contentDescription = "Save")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors()
|
viewModel = viewModel,
|
||||||
|
amenityListViewModel = amenityListViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user