From 55f139f4f2d1120c95b3c8e055d63d8695d61635 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Tue, 27 Jan 2026 23:20:50 +0530 Subject: [PATCH] Update amenity form and icon selection --- .../com/android/trisolarispms/MainActivity.kt | 23 ++ .../trisolarispms/data/api/AmenityApi.kt | 3 + .../trisolarispms/data/api/ApiService.kt | 1 + .../trisolarispms/data/api/ImageTagApi.kt | 27 ++ .../trisolarispms/data/api/RoomImageApi.kt | 10 +- .../data/api/model/RoomModels.kt | 11 +- .../com/android/trisolarispms/ui/AppRoute.kt | 3 + .../trisolarispms/ui/home/HomeScreen.kt | 8 + .../ui/roomimage/AddImageTagScreen.kt | 75 +++++ .../ui/roomimage/EditImageTagScreen.kt | 82 ++++++ .../ui/roomimage/ImageTagFormState.kt | 8 + .../ui/roomimage/ImageTagFormViewModel.kt | 69 +++++ .../ui/roomimage/ImageTagState.kt | 9 + .../ui/roomimage/ImageTagViewModel.kt | 61 +++++ .../ui/roomimage/ImageTagsScreen.kt | 109 ++++++++ .../ui/roomimage/RoomImageViewModel.kt | 32 ++- .../ui/roomimage/RoomImagesScreen.kt | 257 ++++++++++++------ .../ui/roomtype/AddAmenityScreen.kt | 117 +------- .../ui/roomtype/AmenityFormScreen.kt | 254 +++++++++++++++++ .../ui/roomtype/AmenityFormState.kt | 3 + .../ui/roomtype/AmenityFormViewModel.kt | 56 +++- .../ui/roomtype/EditAmenityScreen.kt | 121 +-------- 22 files changed, 1026 insertions(+), 313 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/ImageTagApi.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormScreen.kt diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index fe76118..e387d65 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -19,6 +19,9 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomsScreen import com.android.trisolarispms.ui.roomimage.RoomImagesScreen +import com.android.trisolarispms.ui.roomimage.ImageTagsScreen +import com.android.trisolarispms.ui.roomimage.AddImageTagScreen +import com.android.trisolarispms.ui.roomimage.EditImageTagScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen @@ -52,6 +55,7 @@ class MainActivity : ComponentActivity() { val selectedRoom = remember { mutableStateOf(null) } val selectedRoomType = remember { mutableStateOf(null) } val selectedAmenity = remember { mutableStateOf(null) } + val selectedImageTag = remember { mutableStateOf(null) } val roomFormKey = remember { mutableStateOf(0) } val amenitiesReturnRoute = remember { mutableStateOf(AppRoute.Home) } val currentRoute = route.value @@ -69,6 +73,7 @@ class MainActivity : ComponentActivity() { amenitiesReturnRoute.value = AppRoute.Home route.value = AppRoute.Amenities }, + onImageTags = { route.value = AppRoute.ImageTags }, refreshKey = refreshKey.value, selectedPropertyId = selectedPropertyId.value, onSelectProperty = { id, name -> @@ -152,6 +157,24 @@ class MainActivity : ComponentActivity() { onBack = { route.value = AppRoute.Amenities }, onSave = { route.value = AppRoute.Amenities } ) + AppRoute.ImageTags -> ImageTagsScreen( + onBack = { route.value = AppRoute.Home }, + onAdd = { route.value = AppRoute.AddImageTag }, + onEdit = { + selectedImageTag.value = it + route.value = AppRoute.EditImageTag(it.id ?: "") + } + ) + AppRoute.AddImageTag -> AddImageTagScreen( + onBack = { route.value = AppRoute.ImageTags }, + onSave = { route.value = AppRoute.ImageTags } + ) + is AppRoute.EditImageTag -> EditImageTagScreen( + tag = selectedImageTag.value + ?: com.android.trisolarispms.data.api.model.RoomImageTagDto(id = currentRoute.tagId, name = ""), + onBack = { route.value = AppRoute.ImageTags }, + onSave = { route.value = AppRoute.ImageTags } + ) is AppRoute.AddRoom -> RoomFormScreen( title = "Add Room", propertyId = currentRoute.propertyId, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt index 7b74b26..58f0d35 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt @@ -30,4 +30,7 @@ interface AmenityApi { suspend fun deleteAmenity( @Path("amenityId") amenityId: String ): Response + + @GET("icons/png") + suspend fun listAmenityIconKeys(): Response> } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt index 2265253..1884514 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt @@ -6,6 +6,7 @@ interface ApiService : RoomTypeApi, RoomApi, RoomImageApi, + ImageTagApi, BookingApi, RoomStayApi, CardApi, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/ImageTagApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/ImageTagApi.kt new file mode 100644 index 0000000..74dbc8f --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/ImageTagApi.kt @@ -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> + + @POST("image-tags") + suspend fun createImageTag(@Body body: RoomImageTagDto): Response + + @PUT("image-tags/{tagId}") + suspend fun updateImageTag( + @Path("tagId") tagId: String, + @Body body: RoomImageTagDto + ): Response + + @DELETE("image-tags/{tagId}") + suspend fun deleteImageTag(@Path("tagId") tagId: String): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt index 20b951d..8e768ec 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/RoomImageApi.kt @@ -28,7 +28,7 @@ interface RoomImageApi { @Path("propertyId") propertyId: String, @Path("roomId") roomId: String, @Part file: MultipartBody.Part, - @Part tags: List? = null + @Query("tagIds") tagIds: List? = null ): Response @Streaming @@ -47,6 +47,14 @@ interface RoomImageApi { @Path("imageId") imageId: String ): Response + @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 + @PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room") suspend fun reorderRoomImages( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt index 1e83761..2c55b75 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt @@ -60,7 +60,7 @@ data class ImageDto( val thumbnailUrl: String? = null, val contentType: String? = null, val sizeBytes: Long? = null, - val tags: List? = null, + val tags: List? = null, val roomSortOrder: Int? = null, val roomTypeSortOrder: Int? = null, val createdAt: String? = null @@ -69,3 +69,12 @@ data class ImageDto( data class RoomImageReorderRequest( val imageIds: List ) + +data class RoomImageTagDto( + val id: String? = null, + val name: String? = null +) + +data class RoomImageTagUpsertRequest( + val tagIds: List +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index 7398d7a..2b26627 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -14,4 +14,7 @@ sealed interface AppRoute { data object AddAmenity : AppRoute data class EditAmenity(val amenityId: String) : AppRoute data class RoomImages(val propertyId: String, val roomId: String) : AppRoute + data object ImageTags : AppRoute + data object AddImageTag : AppRoute + data class EditImageTag(val tagId: String) : AppRoute } diff --git a/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt index 922e9b2..f56166b 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt @@ -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 = { diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt new file mode 100644 index 0000000..eb866e5 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt new file mode 100644 index 0000000..3170e80 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/EditImageTagScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormState.kt new file mode 100644 index 0000000..f7f77a0 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormState.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt new file mode 100644 index 0000000..f99b479 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagState.kt new file mode 100644 index 0000000..a3bb08a --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagState.kt @@ -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 = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt new file mode 100644 index 0000000..f6913d3 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt new file mode 100644 index 0000000..87f5118 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt @@ -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") + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt index 8e4608a..dc32aed 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt @@ -47,7 +47,7 @@ class RoomImageViewModel : ViewModel() { propertyId: String, roomId: String, filePart: MultipartBody.Part, - tags: List?, + tagIds: List?, onDone: (ImageDto) -> Unit ) { if (propertyId.isBlank() || roomId.isBlank()) return @@ -59,7 +59,7 @@ class RoomImageViewModel : ViewModel() { propertyId = propertyId, roomId = roomId, file = filePart, - tags = tags + tagIds = tagIds ) val body = response.body() if (response.isSuccessful && body != null) { @@ -89,7 +89,7 @@ class RoomImageViewModel : ViewModel() { propertyId: String, roomId: String, fileParts: List, - tags: List?, + tagIds: List?, onDone: () -> Unit ) { if (propertyId.isBlank() || roomId.isBlank() || fileParts.isEmpty()) return @@ -102,7 +102,7 @@ class RoomImageViewModel : ViewModel() { propertyId = propertyId, roomId = roomId, file = part, - tags = tags + tagIds = tagIds ) val body = response.body() if (response.isSuccessful && body != null) { @@ -190,4 +190,28 @@ class RoomImageViewModel : ViewModel() { } } } + + fun updateTags(propertyId: String, roomId: String, imageId: String, tagIds: List) { + 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") } + } + } + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt index 17452a5..996b149 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt @@ -7,18 +7,27 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material.icons.filled.Label import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog @@ -40,25 +49,25 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.zIndex +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt +import kotlinx.coroutines.launch @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -66,16 +75,22 @@ fun RoomImagesScreen( propertyId: String, roomId: String, onBack: () -> Unit, - viewModel: RoomImageViewModel = viewModel() + viewModel: RoomImageViewModel = viewModel(), + tagViewModel: ImageTagViewModel = viewModel() ) { val context = LocalContext.current val state by viewModel.state.collectAsState() - val tagsText = remember { mutableStateOf("") } + val tagState by tagViewModel.state.collectAsState() + val selectedTagIds = remember { mutableStateOf>(emptySet()) } + val tagDialogImage = remember { mutableStateOf(null) } val selectedUris = remember { mutableStateOf>(emptyList()) } val selectedNames = remember { mutableStateOf>(emptyList()) } val deleteImageId = remember { mutableStateOf(null) } val previewUrl = remember { mutableStateOf(null) } val orderedImages = remember { mutableStateOf>(emptyList()) } + val gridState = rememberLazyGridState() + val isDraggingAny = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() val pickerLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(20) @@ -87,6 +102,9 @@ fun RoomImagesScreen( LaunchedEffect(propertyId, roomId) { viewModel.load(propertyId, roomId) } + LaunchedEffect(Unit) { + tagViewModel.load() + } LaunchedEffect(state.images) { orderedImages.value = state.images } @@ -150,12 +168,7 @@ fun RoomImagesScreen( } Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = tagsText.value, - onValueChange = { tagsText.value = it }, - label = { Text("Tags (comma separated)") }, - modifier = Modifier.fillMaxWidth() - ) + Text(text = "Tags can be managed after upload.", style = MaterialTheme.typography.bodySmall) Spacer(modifier = Modifier.height(12.dp)) Button( @@ -169,22 +182,14 @@ fun RoomImagesScreen( MultipartBody.Part.createFormData("file", name, requestBody) } if (parts.isEmpty()) return@Button - val tagBodies = tagsText.value.split(',') - .map { it.trim() } - .filter { it.isNotBlank() } - .map { tag -> - MultipartBody.Part.createFormData("tags", tag) - } - .ifEmpty { null } viewModel.uploadBatch( propertyId = propertyId, roomId = roomId, fileParts = parts, - tags = tagBodies, + tagIds = null, onDone = { selectedUris.value = emptyList() selectedNames.value = emptyList() - tagsText.value = "" } ) }, @@ -197,17 +202,21 @@ fun RoomImagesScreen( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) Text(text = "Images", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) - Text(text = "Drag an image onto another to reorder. Saves automatically.", style = MaterialTheme.typography.bodySmall) + Text(text = "Use drag icon to reorder. Saves automatically.", style = MaterialTheme.typography.bodySmall) Spacer(modifier = Modifier.height(8.dp)) LazyVerticalGrid( columns = GridCells.Fixed(2), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + state = gridState, + userScrollEnabled = !isDraggingAny.value ) { itemsIndexed( orderedImages.value, @@ -224,53 +233,10 @@ fun RoomImagesScreen( itemSize.value = coords.size } } - .pointerInput(orderedImages.value, index) { - detectDragGestures( - onDragStart = { - isDragging.value = true - dragOffset.value = Offset.Zero - }, - onDragCancel = { - isDragging.value = false - dragOffset.value = Offset.Zero - }, - onDragEnd = { - isDragging.value = false - dragOffset.value = Offset.Zero - val ids = orderedImages.value.mapNotNull { it.id } - viewModel.reorderRoom(propertyId, roomId, ids) - }, - onDrag = { change, dragAmount -> - change.consume() - dragOffset.value += dragAmount - val size = itemSize.value - if (size.width > 0 && size.height > 0) { - val colDelta = (dragOffset.value.x / size.width).roundToInt() - val rowDelta = (dragOffset.value.y / size.height).roundToInt() - val columns = 2 - val currentIndex = orderedImages.value.indexOfFirst { it.id == imageId } - if (currentIndex == -1) return@detectDragGestures - val target = (currentIndex + rowDelta * columns + colDelta) - .coerceIn(0, orderedImages.value.lastIndex) - if (target != currentIndex) { - val list = orderedImages.value.toMutableList() - val item = list.removeAt(currentIndex) - list.add(target, item) - orderedImages.value = list - viewModel.setLocalOrder(list) - dragOffset.value = Offset.Zero - } - } - } - ) - } .graphicsLayer { if (isDragging.value) { translationX = dragOffset.value.x translationY = dragOffset.value.y - alpha = 0.85f - scaleX = 1.02f - scaleY = 1.02f } } .zIndex(if (isDragging.value) 1f else 0f) @@ -281,25 +247,98 @@ fun RoomImagesScreen( contentDescription = "Room image", modifier = Modifier .fillMaxWidth() - .height(160.dp) - .clickable { previewUrl.value = image.url ?: image.thumbnailUrl } + .aspectRatio(4f / 3f) + .clickable { previewUrl.value = image.url ?: image.thumbnailUrl }, + contentScale = ContentScale.Crop ) - if (!image.id.isNullOrBlank()) { - IconButton( - onClick = { deleteImageId.value = image.id }, + val tags = image.tags + .orEmpty() + .mapNotNull { it.name ?: it.id } + .joinToString(" • ") + if (tags.isNotBlank()) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), + shape = MaterialTheme.shapes.small, modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 10.dp) - .offset(y = 2.dp) + .align(Alignment.BottomStart) + .padding(6.dp) ) { - Icon(Icons.Default.Delete, contentDescription = "Delete Image") + Text( + text = tags, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + if (!image.id.isNullOrBlank()) { + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 2.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = {}, + modifier = Modifier.pointerInput(orderedImages.value, index) { + detectDragGestures( + onDragStart = { + isDraggingAny.value = true + isDragging.value = true + dragOffset.value = Offset.Zero + }, + onDragCancel = { + isDraggingAny.value = false + isDragging.value = false + dragOffset.value = Offset.Zero + }, + onDragEnd = { + isDraggingAny.value = false + isDragging.value = false + dragOffset.value = Offset.Zero + val ids = orderedImages.value.mapNotNull { it.id } + viewModel.reorderRoom(propertyId, roomId, ids) + }, + onDrag = { change, dragAmount -> + change.consume() + if (dragAmount.y != 0f) { + scope.launch { + gridState.scrollBy(dragAmount.y * 0.5f) + } + } + dragOffset.value += dragAmount + val size = itemSize.value + if (size.width > 0 && size.height > 0) { + val colDelta = (dragOffset.value.x / size.width).roundToInt() + val rowDelta = (dragOffset.value.y / size.height).roundToInt() + val columns = 2 + val currentIndex = orderedImages.value.indexOfFirst { it.id == imageId } + if (currentIndex == -1) return@detectDragGestures + val target = (currentIndex + rowDelta * columns + colDelta) + .coerceIn(0, orderedImages.value.lastIndex) + if (target != currentIndex) { + val list = orderedImages.value.toMutableList() + val item = list.removeAt(currentIndex) + list.add(target, item) + orderedImages.value = list + viewModel.setLocalOrder(list) + dragOffset.value = Offset.Zero + } + } + } + ) + } + ) { + Icon(Icons.Default.DragIndicator, contentDescription = "Drag") + } + IconButton(onClick = { tagDialogImage.value = image }) { + Icon(Icons.Default.Label, contentDescription = "Edit Tags") + } + IconButton(onClick = { deleteImageId.value = image.id }) { + Icon(Icons.Default.Delete, contentDescription = "Delete Image") + } } } - } - val tags = image.tags?.joinToString(" • ").orEmpty() - if (tags.isNotBlank()) { - Spacer(modifier = Modifier.height(6.dp)) - Text(text = tags, style = MaterialTheme.typography.bodySmall) } } } @@ -349,6 +388,52 @@ fun RoomImagesScreen( } ) } + + val tagImage = tagDialogImage.value + if (tagImage != null) { + val currentIds = tagImage.tags.orEmpty().mapNotNull { it.id }.toSet() + val editIds = remember(tagImage.id) { mutableStateOf(currentIds) } + AlertDialog( + onDismissRequest = { tagDialogImage.value = null }, + title = { Text("Edit Tags") }, + text = { + Column { + if (tagState.tags.isEmpty()) { + Text(text = "No tags available", style = MaterialTheme.typography.bodySmall) + } else { + tagState.tags.forEach { tag -> + val id = tag.id ?: return@forEach + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + androidx.compose.material3.Checkbox( + checked = editIds.value.contains(id), + onCheckedChange = { + val next = editIds.value.toMutableSet() + if (next.contains(id)) next.remove(id) else next.add(id) + editIds.value = next + } + ) + Text(text = tag.name ?: id, modifier = Modifier.padding(start = 8.dp)) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + tagDialogImage.value = null + val ids = editIds.value.toList() + viewModel.updateTags(propertyId, roomId, tagImage.id.orEmpty(), ids) + }) { + Text("Save") + } + } + ) + } } private fun getDisplayName(context: android.content.Context, uri: Uri): String? { diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt index 909e48f..edea759 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt @@ -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 + ) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormScreen.kt new file mode 100644 index 0000000..f098d0a --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt index f12b68e..146a44a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt @@ -4,6 +4,9 @@ data class AmenityFormState( val name: String = "", val category: String = "", val iconKey: String = "", + val iconKeyOptions: List = emptyList(), + val iconKeyLoading: Boolean = false, + val iconKeyError: String? = null, val isLoading: Boolean = false, val error: String? = null, val success: Boolean = false diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt index b7f3705..1916c7a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt @@ -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) { diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt index 41bd957..86d7dd1 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt @@ -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 + ) }