Add room image management UI

This commit is contained in:
androidlover5842
2026-01-27 18:14:42 +05:30
parent 6e87eb76a1
commit 053b7c2544
10 changed files with 613 additions and 6 deletions

View File

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

View File

@@ -18,6 +18,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 {

View File

@@ -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<RequestBody>? = null
@Part tags: List<MultipartBody.Part>? = null
): Response<ImageDto>
@Streaming
@@ -39,4 +39,25 @@ interface RoomImageApi {
@Path("imageId") imageId: String,
@Query("size") size: String? = null
): Response<ResponseBody>
@DELETE("properties/{propertyId}/rooms/{roomId}/images/{imageId}")
suspend fun deleteRoomImage(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Path("imageId") imageId: String
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room")
suspend fun reorderRoomImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageReorderRequest
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room-type")
suspend fun reorderRoomTypeImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageReorderRequest
): Response<Unit>
}

View File

@@ -65,3 +65,7 @@ data class ImageDto(
val roomTypeSortOrder: Int? = null,
val createdAt: String? = null
)
data class RoomImageReorderRequest(
val imageIds: List<String>
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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") }
}
}
}
}

View File

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

View File

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