Add room image management UI
This commit is contained in:
@@ -55,6 +55,7 @@ dependencies {
|
|||||||
implementation(libs.retrofit.converter.gson)
|
implementation(libs.retrofit.converter.gson)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
implementation(platform(libs.firebase.bom))
|
implementation(platform(libs.firebase.bom))
|
||||||
implementation(libs.firebase.auth.ktx)
|
implementation(libs.firebase.auth.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.android.trisolarispms.ui.home.HomeScreen
|
|||||||
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
||||||
import com.android.trisolarispms.ui.room.RoomFormScreen
|
import com.android.trisolarispms.ui.room.RoomFormScreen
|
||||||
import com.android.trisolarispms.ui.room.RoomsScreen
|
import com.android.trisolarispms.ui.room.RoomsScreen
|
||||||
|
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
|
||||||
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
|
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
|
||||||
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
|
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
|
||||||
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
|
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
|
||||||
@@ -158,7 +159,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
roomData = null,
|
roomData = null,
|
||||||
formKey = roomFormKey.value,
|
formKey = roomFormKey.value,
|
||||||
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
|
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(
|
is AppRoute.EditRoom -> RoomFormScreen(
|
||||||
title = "Modify Room",
|
title = "Modify Room",
|
||||||
@@ -167,7 +169,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
roomData = selectedRoom.value,
|
roomData = selectedRoom.value,
|
||||||
formKey = roomFormKey.value,
|
formKey = roomFormKey.value,
|
||||||
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
|
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 {
|
} else {
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ package com.android.trisolarispms.data.api
|
|||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.ImageDto
|
import com.android.trisolarispms.data.api.model.ImageDto
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Multipart
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Part
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
|
||||||
interface RoomImageApi {
|
interface RoomImageApi {
|
||||||
@GET("properties/{propertyId}/rooms/{roomId}/images")
|
@GET("properties/{propertyId}/rooms/{roomId}/images")
|
||||||
@@ -26,9 +28,7 @@ interface RoomImageApi {
|
|||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("roomId") roomId: String,
|
@Path("roomId") roomId: String,
|
||||||
@Part file: MultipartBody.Part,
|
@Part file: MultipartBody.Part,
|
||||||
@Part("roomSortOrder") roomSortOrder: RequestBody? = null,
|
@Part tags: List<MultipartBody.Part>? = null
|
||||||
@Part("roomTypeSortOrder") roomTypeSortOrder: RequestBody? = null,
|
|
||||||
@Part("tags") tags: List<RequestBody>? = null
|
|
||||||
): Response<ImageDto>
|
): Response<ImageDto>
|
||||||
|
|
||||||
@Streaming
|
@Streaming
|
||||||
@@ -39,4 +39,25 @@ interface RoomImageApi {
|
|||||||
@Path("imageId") imageId: String,
|
@Path("imageId") imageId: String,
|
||||||
@Query("size") size: String? = null
|
@Query("size") size: String? = null
|
||||||
): Response<ResponseBody>
|
): 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/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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,7 @@ data class ImageDto(
|
|||||||
val roomTypeSortOrder: Int? = null,
|
val roomTypeSortOrder: Int? = null,
|
||||||
val createdAt: String? = null
|
val createdAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RoomImageReorderRequest(
|
||||||
|
val imageIds: List<String>
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ sealed interface AppRoute {
|
|||||||
data object Amenities : AppRoute
|
data object Amenities : AppRoute
|
||||||
data object AddAmenity : AppRoute
|
data object AddAmenity : AppRoute
|
||||||
data class EditAmenity(val amenityId: String) : AppRoute
|
data class EditAmenity(val amenityId: String) : AppRoute
|
||||||
|
data class RoomImages(val propertyId: String, val roomId: String) : AppRoute
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Done
|
import androidx.compose.material.icons.filled.Done
|
||||||
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -49,6 +50,7 @@ fun RoomFormScreen(
|
|||||||
formKey: Int,
|
formKey: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
|
onViewImages: (String) -> Unit,
|
||||||
viewModel: RoomFormViewModel = viewModel(),
|
viewModel: RoomFormViewModel = viewModel(),
|
||||||
roomTypesViewModel: RoomTypeListViewModel = viewModel()
|
roomTypesViewModel: RoomTypeListViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
@@ -90,6 +92,9 @@ fun RoomFormScreen(
|
|||||||
Icon(Icons.Default.Done, contentDescription = "Save")
|
Icon(Icons.Default.Done, contentDescription = "Save")
|
||||||
}
|
}
|
||||||
if (roomId != null) {
|
if (roomId != null) {
|
||||||
|
IconButton(onClick = { onViewImages(roomId) }) {
|
||||||
|
Icon(Icons.Default.PhotoLibrary, contentDescription = "Images")
|
||||||
|
}
|
||||||
IconButton(onClick = { showDeleteConfirm = true }) {
|
IconButton(onClick = { showDeleteConfirm = true }) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete Room")
|
Icon(Icons.Default.Delete, contentDescription = "Delete Room")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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,
|
||||||
|
tags: List<MultipartBody.Part>?,
|
||||||
|
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,
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
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>,
|
||||||
|
tags: List<MultipartBody.Part>?,
|
||||||
|
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,
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
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.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.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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
fun RoomImagesScreen(
|
||||||
|
propertyId: String,
|
||||||
|
roomId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: RoomImageViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val tagsText = remember { mutableStateOf("") }
|
||||||
|
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 pickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.PickMultipleVisualMedia(20)
|
||||||
|
) { uris ->
|
||||||
|
selectedUris.value = uris
|
||||||
|
selectedNames.value = uris.mapNotNull { getDisplayName(context, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(propertyId, roomId) {
|
||||||
|
viewModel.load(propertyId, roomId)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tagsText.value,
|
||||||
|
onValueChange = { tagsText.value = it },
|
||||||
|
label = { Text("Tags (comma separated)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
onDone = {
|
||||||
|
selectedUris.value = emptyList()
|
||||||
|
selectedNames.value = emptyList()
|
||||||
|
tagsText.value = ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = selectedUris.value.isNotEmpty() && !state.isUploading
|
||||||
|
) {
|
||||||
|
if (state.isUploading) {
|
||||||
|
Text("Uploading...")
|
||||||
|
} else {
|
||||||
|
Text("Upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.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)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = image.thumbnailUrl ?: image.url,
|
||||||
|
contentDescription = "Room image",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(160.dp)
|
||||||
|
.clickable { previewUrl.value = image.url ?: image.thumbnailUrl }
|
||||||
|
)
|
||||||
|
if (!image.id.isNullOrBlank()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { deleteImageId.value = image.id },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 10.dp)
|
||||||
|
.offset(y = 2.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ googleServices = "4.4.4"
|
|||||||
lifecycleViewModelCompose = "2.10.0"
|
lifecycleViewModelCompose = "2.10.0"
|
||||||
firebaseAuthKtx = "24.0.1"
|
firebaseAuthKtx = "24.0.1"
|
||||||
vectordrawable = "1.2.0"
|
vectordrawable = "1.2.0"
|
||||||
|
coilCompose = "2.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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 = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
|
||||||
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", 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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user