diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d595b8..0862798 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.retrofit.converter.gson) implementation(libs.okhttp) implementation(libs.okhttp.logging) + implementation(libs.coil.compose) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth.ktx) implementation(libs.kotlinx.coroutines.play.services) diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index d5845e2..fe76118 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -18,6 +18,7 @@ import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomsScreen +import com.android.trisolarispms.ui.roomimage.RoomImagesScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen @@ -158,7 +159,8 @@ class MainActivity : ComponentActivity() { roomData = null, formKey = roomFormKey.value, onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, - onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) } + onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, + onViewImages = { } ) is AppRoute.EditRoom -> RoomFormScreen( title = "Modify Room", @@ -167,7 +169,15 @@ class MainActivity : ComponentActivity() { roomData = selectedRoom.value, formKey = roomFormKey.value, onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, - onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) } + onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, + onViewImages = { roomId -> + route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId) + } + ) + is AppRoute.RoomImages -> RoomImagesScreen( + propertyId = currentRoute.propertyId, + roomId = currentRoute.roomId, + onBack = { route.value = AppRoute.EditRoom(currentRoute.propertyId, currentRoute.roomId) } ) } } else { 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 702db38..20b951d 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 @@ -2,16 +2,18 @@ package com.android.trisolarispms.data.api import com.android.trisolarispms.data.api.model.ImageDto import okhttp3.MultipartBody -import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.PUT import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.Streaming +import retrofit2.http.DELETE interface RoomImageApi { @GET("properties/{propertyId}/rooms/{roomId}/images") @@ -26,9 +28,7 @@ interface RoomImageApi { @Path("propertyId") propertyId: String, @Path("roomId") roomId: String, @Part file: MultipartBody.Part, - @Part("roomSortOrder") roomSortOrder: RequestBody? = null, - @Part("roomTypeSortOrder") roomTypeSortOrder: RequestBody? = null, - @Part("tags") tags: List? = null + @Part tags: List? = null ): Response @Streaming @@ -39,4 +39,25 @@ interface RoomImageApi { @Path("imageId") imageId: String, @Query("size") size: String? = null ): Response + + @DELETE("properties/{propertyId}/rooms/{roomId}/images/{imageId}") + suspend fun deleteRoomImage( + @Path("propertyId") propertyId: String, + @Path("roomId") roomId: String, + @Path("imageId") imageId: String + ): Response + + @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 + + @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 } 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 c1dc112..1e83761 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 @@ -65,3 +65,7 @@ data class ImageDto( val roomTypeSortOrder: Int? = null, val createdAt: String? = null ) + +data class RoomImageReorderRequest( + val imageIds: 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 2c55b38..7398d7a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -13,4 +13,5 @@ sealed interface AppRoute { data object Amenities : AppRoute data object AddAmenity : AppRoute data class EditAmenity(val amenityId: String) : AppRoute + data class RoomImages(val propertyId: String, val roomId: String) : AppRoute } diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt index eeb5444..4d64dab 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -49,6 +50,7 @@ fun RoomFormScreen( formKey: Int, onBack: () -> Unit, onSave: () -> Unit, + onViewImages: (String) -> Unit, viewModel: RoomFormViewModel = viewModel(), roomTypesViewModel: RoomTypeListViewModel = viewModel() ) { @@ -90,6 +92,9 @@ fun RoomFormScreen( Icon(Icons.Default.Done, contentDescription = "Save") } if (roomId != null) { + IconButton(onClick = { onViewImages(roomId) }) { + Icon(Icons.Default.PhotoLibrary, contentDescription = "Images") + } IconButton(onClick = { showDeleteConfirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Delete Room") } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageState.kt new file mode 100644 index 0000000..9d9050b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageState.kt @@ -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 = emptyList() +) 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 new file mode 100644 index 0000000..8e4608a --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImageViewModel.kt @@ -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 = _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) { + _state.update { it.copy(images = images) } + } + + fun upload( + propertyId: String, + roomId: String, + filePart: MultipartBody.Part, + tags: List?, + 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, + tags: List?, + 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) { + 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) { + 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") } + } + } + } +} 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 new file mode 100644 index 0000000..17452a5 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/RoomImagesScreen.kt @@ -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>(emptyList()) } + val selectedNames = remember { mutableStateOf>(emptyList()) } + val deleteImageId = remember { mutableStateOf(null) } + val previewUrl = remember { mutableStateOf(null) } + val orderedImages = remember { mutableStateOf>(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 + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ab3f7f..3ad08c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ googleServices = "4.4.4" lifecycleViewModelCompose = "2.10.0" firebaseAuthKtx = "24.0.1" vectordrawable = "1.2.0" +coilCompose = "2.7.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -44,6 +45,7 @@ firebase-auth-ktx = { module = "com.google.firebase:firebase-auth" } kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" } androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" } androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }