Compare commits

...

3 Commits

Author SHA1 Message Date
androidlover5842
55f139f4f2 Update amenity form and icon selection 2026-01-27 23:20:50 +05:30
androidlover5842
053b7c2544 Add room image management UI 2026-01-27 18:14:42 +05:30
androidlover5842
6e87eb76a1 Update room image models and upload params 2026-01-27 16:24:07 +05:30
26 changed files with 1552 additions and 224 deletions

View File

@@ -55,6 +55,7 @@ dependencies {
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services)

View File

@@ -18,6 +18,10 @@ import com.android.trisolarispms.ui.home.HomeScreen
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
@@ -51,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
@@ -68,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 ->
@@ -151,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,
@@ -158,7 +182,8 @@ class MainActivity : ComponentActivity() {
roomData = null,
formKey = roomFormKey.value,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onViewImages = { }
)
is AppRoute.EditRoom -> RoomFormScreen(
title = "Modify Room",
@@ -167,7 +192,15 @@ class MainActivity : ComponentActivity() {
roomData = selectedRoom.value,
formKey = roomFormKey.value,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onViewImages = { roomId ->
route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId)
}
)
is AppRoute.RoomImages -> RoomImagesScreen(
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,
onBack = { route.value = AppRoute.EditRoom(currentRoute.propertyId, currentRoute.roomId) }
)
}
} else {

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

@@ -4,13 +4,16 @@ import com.android.trisolarispms.data.api.model.ImageDto
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import retrofit2.http.DELETE
interface RoomImageApi {
@GET("properties/{propertyId}/rooms/{roomId}/images")
@@ -24,7 +27,8 @@ interface RoomImageApi {
suspend fun uploadRoomImage(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Part file: MultipartBody.Part
@Part file: MultipartBody.Part,
@Query("tagIds") tagIds: List<String>? = null
): Response<ImageDto>
@Streaming
@@ -35,4 +39,33 @@ interface RoomImageApi {
@Path("imageId") imageId: String,
@Query("size") size: String? = null
): Response<ResponseBody>
@DELETE("properties/{propertyId}/rooms/{roomId}/images/{imageId}")
suspend fun deleteRoomImage(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@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,
@Path("roomId") roomId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageReorderRequest
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room-type")
suspend fun reorderRoomTypeImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageReorderRequest
): Response<Unit>
}

View File

@@ -55,9 +55,26 @@ data class ImageDto(
val id: String? = null,
val propertyId: String? = null,
val roomId: String? = null,
val roomTypeCode: String? = null,
val url: String? = null,
val thumbnailUrl: String? = null,
val contentType: String? = null,
val sizeBytes: Long? = null,
val tags: List<RoomImageTagDto>? = null,
val roomSortOrder: Int? = null,
val roomTypeSortOrder: Int? = null,
val createdAt: String? = null
)
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

@@ -13,4 +13,8 @@ sealed interface AppRoute {
data object Amenities : 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

@@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -49,6 +50,7 @@ fun RoomFormScreen(
formKey: Int,
onBack: () -> Unit,
onSave: () -> Unit,
onViewImages: (String) -> Unit,
viewModel: RoomFormViewModel = viewModel(),
roomTypesViewModel: RoomTypeListViewModel = viewModel()
) {
@@ -90,6 +92,9 @@ fun RoomFormScreen(
Icon(Icons.Default.Done, contentDescription = "Save")
}
if (roomId != null) {
IconButton(onClick = { onViewImages(roomId) }) {
Icon(Icons.Default.PhotoLibrary, contentDescription = "Images")
}
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Room")
}

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

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.roomimage
import com.android.trisolarispms.data.api.model.ImageDto
data class RoomImageState(
val isLoading: Boolean = false,
val isUploading: Boolean = false,
val error: String? = null,
val images: List<ImageDto> = emptyList()
)

View File

@@ -0,0 +1,217 @@
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.ImageDto
import com.android.trisolarispms.data.api.model.RoomImageReorderRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MultipartBody
class RoomImageViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomImageState())
val state: StateFlow<RoomImageState> = _state
fun load(propertyId: String, roomId: String) {
if (propertyId.isBlank() || roomId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listRoomImages(propertyId, roomId)
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
images = response.body().orEmpty(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
fun setLocalOrder(images: List<ImageDto>) {
_state.update { it.copy(images = images) }
}
fun upload(
propertyId: String,
roomId: String,
filePart: MultipartBody.Part,
tagIds: List<String>?,
onDone: (ImageDto) -> Unit
) {
if (propertyId.isBlank() || roomId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.uploadRoomImage(
propertyId = propertyId,
roomId = roomId,
file = filePart,
tagIds = tagIds
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(
isUploading = false,
images = listOf(body) + current.images,
error = null
)
}
onDone(body)
} else {
val message = if (response.code() == 409) {
"Duplicate image for room"
} else {
"Upload failed: ${response.code()}"
}
_state.update { it.copy(isUploading = false, error = message) }
}
} catch (e: Exception) {
_state.update { it.copy(isUploading = false, error = e.localizedMessage ?: "Upload failed") }
}
}
}
fun uploadBatch(
propertyId: String,
roomId: String,
fileParts: List<MultipartBody.Part>,
tagIds: List<String>?,
onDone: () -> Unit
) {
if (propertyId.isBlank() || roomId.isBlank() || fileParts.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
try {
val api = ApiClient.create()
for (part in fileParts) {
val response = api.uploadRoomImage(
propertyId = propertyId,
roomId = roomId,
file = part,
tagIds = tagIds
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(images = listOf(body) + current.images)
}
} else if (response.code() == 409) {
_state.update { it.copy(error = "Duplicate image for room") }
} else {
_state.update { it.copy(error = "Upload failed: ${response.code()}") }
}
}
_state.update { it.copy(isUploading = false) }
onDone()
} catch (e: Exception) {
_state.update { it.copy(isUploading = false, error = e.localizedMessage ?: "Upload failed") }
}
}
}
fun delete(propertyId: String, roomId: String, imageId: 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.deleteRoomImage(propertyId, roomId, imageId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
images = current.images.filterNot { it.id == imageId },
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") }
}
}
}
fun reorderRoom(propertyId: String, roomId: String, imageIds: List<String>) {
if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.reorderRoomImages(
propertyId = propertyId,
roomId = roomId,
body = RoomImageReorderRequest(imageIds)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
} else {
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Reorder failed") }
}
}
}
fun reorderRoomType(propertyId: String, roomId: String, imageIds: List<String>) {
if (propertyId.isBlank() || roomId.isBlank() || imageIds.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.reorderRoomTypeImages(
propertyId = propertyId,
roomId = roomId,
body = RoomImageReorderRequest(imageIds)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
} else {
_state.update { it.copy(isLoading = false, error = "Reorder failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Reorder failed") }
}
}
}
fun 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

@@ -0,0 +1,445 @@
package com.android.trisolarispms.ui.roomimage
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RoomImagesScreen(
propertyId: String,
roomId: String,
onBack: () -> Unit,
viewModel: RoomImageViewModel = viewModel(),
tagViewModel: ImageTagViewModel = viewModel()
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
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)
) { uris ->
selectedUris.value = uris
selectedNames.value = uris.mapNotNull { getDisplayName(context, it) }
}
LaunchedEffect(propertyId, roomId) {
viewModel.load(propertyId, roomId)
}
LaunchedEffect(Unit) {
tagViewModel.load()
}
LaunchedEffect(state.images) {
orderedImages.value = state.images
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Room Images") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
pickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}) {
Icon(Icons.Default.UploadFile, contentDescription = "Pick Image")
}
},
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))
}
Text(text = "Upload", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val selectionText = when (selectedNames.value.size) {
0 -> "No images selected"
1 -> selectedNames.value.firstOrNull() ?: "1 image selected"
else -> "${selectedNames.value.size} images selected"
}
Text(text = selectionText)
Button(onClick = {
pickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}) {
Text("Choose")
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(text = "Tags can be managed after upload.", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val contentResolver = context.contentResolver
val parts = selectedUris.value.mapNotNull { uri ->
val mimeType = contentResolver.getType(uri) ?: "image/*"
val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@mapNotNull null
val name = getDisplayName(context, uri) ?: "image.jpg"
val requestBody = bytes.toRequestBody(mimeType.toMediaType())
MultipartBody.Part.createFormData("file", name, requestBody)
}
if (parts.isEmpty()) return@Button
viewModel.uploadBatch(
propertyId = propertyId,
roomId = roomId,
fileParts = parts,
tagIds = null,
onDone = {
selectedUris.value = emptyList()
selectedNames.value = emptyList()
}
)
},
enabled = selectedUris.value.isNotEmpty() && !state.isUploading
) {
if (state.isUploading) {
Text("Uploading...")
} else {
Text("Upload")
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Images", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
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(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
state = gridState,
userScrollEnabled = !isDraggingAny.value
) {
itemsIndexed(
orderedImages.value,
key = { index, image -> image.id ?: "image-$index" }
) { index, image ->
val imageId = image.id
val dragOffset = remember { mutableStateOf(Offset.Zero) }
val itemSize = remember { mutableStateOf(IntSize.Zero) }
val isDragging = remember { mutableStateOf(false) }
Column(
modifier = Modifier
.onGloballyPositioned { coords ->
if (itemSize.value.width == 0) {
itemSize.value = coords.size
}
}
.graphicsLayer {
if (isDragging.value) {
translationX = dragOffset.value.x
translationY = dragOffset.value.y
}
}
.zIndex(if (isDragging.value) 1f else 0f)
) {
Box(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = image.thumbnailUrl ?: image.url,
contentDescription = "Room image",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.clickable { previewUrl.value = image.url ?: image.thumbnailUrl },
contentScale = ContentScale.Crop
)
val tags = image.tags
.orEmpty()
.mapNotNull { it.name ?: it.id }
.joinToString("")
if (tags.isNotBlank()) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shape = MaterialTheme.shapes.small,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(6.dp)
) {
Text(
text = tags,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
if (!image.id.isNullOrBlank()) {
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 2.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {},
modifier = Modifier.pointerInput(orderedImages.value, index) {
detectDragGestures(
onDragStart = {
isDraggingAny.value = true
isDragging.value = true
dragOffset.value = Offset.Zero
},
onDragCancel = {
isDraggingAny.value = false
isDragging.value = false
dragOffset.value = Offset.Zero
},
onDragEnd = {
isDraggingAny.value = false
isDragging.value = false
dragOffset.value = Offset.Zero
val ids = orderedImages.value.mapNotNull { it.id }
viewModel.reorderRoom(propertyId, roomId, ids)
},
onDrag = { change, dragAmount ->
change.consume()
if (dragAmount.y != 0f) {
scope.launch {
gridState.scrollBy(dragAmount.y * 0.5f)
}
}
dragOffset.value += dragAmount
val size = itemSize.value
if (size.width > 0 && size.height > 0) {
val colDelta = (dragOffset.value.x / size.width).roundToInt()
val rowDelta = (dragOffset.value.y / size.height).roundToInt()
val columns = 2
val currentIndex = orderedImages.value.indexOfFirst { it.id == imageId }
if (currentIndex == -1) return@detectDragGestures
val target = (currentIndex + rowDelta * columns + colDelta)
.coerceIn(0, orderedImages.value.lastIndex)
if (target != currentIndex) {
val list = orderedImages.value.toMutableList()
val item = list.removeAt(currentIndex)
list.add(target, item)
orderedImages.value = list
viewModel.setLocalOrder(list)
dragOffset.value = Offset.Zero
}
}
}
)
}
) {
Icon(Icons.Default.DragIndicator, contentDescription = "Drag")
}
IconButton(onClick = { tagDialogImage.value = image }) {
Icon(Icons.Default.Label, contentDescription = "Edit Tags")
}
IconButton(onClick = { deleteImageId.value = image.id }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Image")
}
}
}
}
}
}
}
}
}
val imageToDelete = deleteImageId.value
if (imageToDelete != null) {
AlertDialog(
onDismissRequest = { deleteImageId.value = null },
title = { Text("Delete image?") },
text = { Text("This will remove the image. Continue?") },
confirmButton = {
TextButton(onClick = {
deleteImageId.value = null
viewModel.delete(propertyId, roomId, imageToDelete)
}) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { deleteImageId.value = null }) {
Text("Cancel")
}
}
)
}
val preview = previewUrl.value
if (!preview.isNullOrBlank()) {
AlertDialog(
onDismissRequest = { previewUrl.value = null },
title = { Text("Preview") },
text = {
AsyncImage(
model = preview,
contentDescription = "Image preview",
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = { previewUrl.value = null }) {
Text("Close")
}
}
)
}
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? {
val cursor = context.contentResolver.query(uri, null, null, null, null) ?: return null
cursor.use {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
return if (it.moveToFirst() && nameIndex >= 0) it.getString(nameIndex) else null
}
}

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

View File

@@ -17,6 +17,7 @@ googleServices = "4.4.4"
lifecycleViewModelCompose = "2.10.0"
firebaseAuthKtx = "24.0.1"
vectordrawable = "1.2.0"
coilCompose = "2.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -44,6 +45,7 @@ firebase-auth-ktx = { module = "com.google.firebase:firebase-auth" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }