Compare commits

..

3 Commits

Author SHA1 Message Date
androidlover5842
be52b58165 Add NFC temporary card issue flow 2026-01-28 07:28:19 +05:30
androidlover5842
6d7edc3022 Room lists filters and room type availability 2026-01-28 05:42:23 +05:30
androidlover5842
d2d60b5074 Auto-refresh auth token on 401 2026-01-28 04:50:20 +05:30
26 changed files with 1118 additions and 454 deletions

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.lottie.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)

View File

@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"

File diff suppressed because one or more lines are too long

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.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.card.IssueTemporaryCardScreen
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
@@ -114,6 +115,12 @@ class MainActivity : ComponentActivity() {
selectedRoom.value = it selectedRoom.value = it
roomFormKey.value++ roomFormKey.value++
route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "") route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "")
},
onIssueTemporaryCard = {
if (it.id != null) {
selectedRoom.value = it
route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id)
}
} }
) )
is AppRoute.RoomTypes -> RoomTypesScreen( is AppRoute.RoomTypes -> RoomTypesScreen(
@@ -197,6 +204,12 @@ class MainActivity : ComponentActivity() {
route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId) route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId)
} }
) )
is AppRoute.IssueTemporaryCard -> IssueTemporaryCardScreen(
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,
roomNumber = selectedRoom.value?.roomNumber?.toString(),
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
)
is AppRoute.RoomImages -> RoomImagesScreen( is AppRoute.RoomImages -> RoomImagesScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId, roomId = currentRoute.roomId,

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import okhttp3.Authenticator
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -18,7 +19,7 @@ object ApiClient {
val authInterceptor = Interceptor { chain -> val authInterceptor = Interceptor { chain ->
val original = chain.request() val original = chain.request()
val token = try { val token = try {
kotlinx.coroutines.runBlocking { tokenProvider.token() } kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -32,12 +33,28 @@ object ApiClient {
chain.proceed(request) chain.proceed(request)
} }
val authenticator = Authenticator { _, response ->
if (response.code != 401) return@Authenticator null
if (response.request.header("X-Auth-Retry") == "true") return@Authenticator null
val newToken = try {
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = true) }
} catch (e: Exception) {
null
}
if (newToken.isNullOrBlank()) return@Authenticator null
response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.header("X-Auth-Retry", "true")
.build()
}
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
} }
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.authenticator(authenticator)
.addInterceptor(logging) .addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)

View File

@@ -1,5 +1,5 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api
interface AuthTokenProvider { interface AuthTokenProvider {
suspend fun token(): String? suspend fun token(forceRefresh: Boolean = false): String?
} }

View File

@@ -26,6 +26,20 @@ interface CardApi {
@Body body: IssueCardRequest @Body body: IssueCardRequest
): Response<IssuedCardResponse> ): Response<IssuedCardResponse>
@POST("properties/{propertyId}/rooms/{roomId}/cards/prepare-temp")
suspend fun prepareTemporaryRoomCard(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: CardPrepareRequest
): Response<CardPrepareResponse>
@POST("properties/{propertyId}/rooms/{roomId}/cards/temp")
suspend fun issueTemporaryRoomCard(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: IssueCardRequest
): Response<IssuedCardResponse>
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards") @GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
suspend fun listCards( suspend fun listCards(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -6,8 +6,8 @@ import kotlinx.coroutines.tasks.await
class FirebaseAuthTokenProvider( class FirebaseAuthTokenProvider(
private val auth: FirebaseAuth private val auth: FirebaseAuth
) : AuthTokenProvider { ) : AuthTokenProvider {
override suspend fun token(): String? { override suspend fun token(forceRefresh: Boolean): String? {
val user = auth.currentUser ?: return null val user = auth.currentUser ?: return null
return user.getIdToken(false).await().token return user.getIdToken(forceRefresh).await().token
} }
} }

View File

@@ -56,4 +56,16 @@ interface RoomApi {
@Query("from") from: String, @Query("from") from: String,
@Query("to") to: String @Query("to") to: String
): Response<List<RoomAvailabilityRangeResponse>> ): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available")
suspend fun listAvailableRooms(
@Path("propertyId") propertyId: String
): Response<List<RoomDto>>
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType(
@Path("propertyId") propertyId: String,
@Path("roomTypeCode") roomTypeCode: String,
@Query("availableOnly") availableOnly: Boolean? = null
): Response<List<RoomDto>>
} }

View File

@@ -8,6 +8,9 @@ data class CardPrepareResponse(
val cardIndex: Int? = null, val cardIndex: Int? = null,
val key: String? = null, val key: String? = null,
val timeData: String? = null, val timeData: String? = null,
val sector3Block0: String? = null,
val sector3Block1: String? = null,
val sector3Block2: String? = null,
val issuedAt: String? = null, val issuedAt: String? = null,
val expiresAt: String? = null val expiresAt: String? = null
) )
@@ -16,7 +19,7 @@ data class IssueCardRequest(
val cardId: String, val cardId: String,
val cardIndex: Int, val cardIndex: Int,
val issuedAt: String? = null, val issuedAt: String? = null,
val expiresAt: String val expiresAt: String? = null
) )
data class IssuedCardResponse( data class IssuedCardResponse(

View File

@@ -25,6 +25,7 @@ data class RoomDto(
val roomNumber: Int? = null, val roomNumber: Int? = null,
val roomTypeCode: String? = null, val roomTypeCode: String? = null,
val roomTypeName: String? = null, val roomTypeName: String? = null,
val maxOccupancy: Int? = null,
val floor: Int? = null, val floor: Int? = null,
val hasNfc: Boolean? = null, val hasNfc: Boolean? = null,
val active: Boolean? = null, val active: Boolean? = null,

View File

@@ -7,6 +7,7 @@ sealed interface AppRoute {
data class Rooms(val propertyId: String) : AppRoute data class Rooms(val propertyId: String) : AppRoute
data class AddRoom(val propertyId: String) : AppRoute data class AddRoom(val propertyId: String) : AppRoute
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute
data class RoomTypes(val propertyId: String) : AppRoute data class RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute

View File

@@ -0,0 +1,146 @@
package com.android.trisolarispms.ui.card
import android.app.Activity
import android.nfc.NfcAdapter
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun IssueTemporaryCardScreen(
propertyId: String,
roomId: String,
roomNumber: String?,
onBack: () -> Unit,
viewModel: IssueTemporaryCardViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val activity = context as? Activity
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
val composition = rememberLottieComposition(
LottieCompositionSpec.Asset("scan_nfc_animation.json")
)
val progress = animateLottieCompositionAsState(
composition = composition.value,
iterations = LottieConstants.IterateForever
)
DisposableEffect(activity, nfcAdapter, propertyId, roomId) {
if (activity != null && nfcAdapter != null) {
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val callback = NfcAdapter.ReaderCallback { tag ->
viewModel.onTagScanned(propertyId, roomId, tag)
}
nfcAdapter.enableReaderMode(activity, callback, flags, null)
onDispose {
nfcAdapter.disableReaderMode(activity)
}
} else {
onDispose { }
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Issue Temporary Card") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = roomNumber?.let { "Room $it" } ?: "Room",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Hold the NFC card near the reader to issue a temporary card.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (nfcAdapter == null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "NFC not supported on this device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (!nfcAdapter.isEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "NFC is off. Please enable NFC.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
state.status?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
state.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(32.dp))
LottieAnimation(
composition = composition.value,
progress = { progress.value },
modifier = Modifier.size(220.dp)
)
}
}
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.card
data class IssueTemporaryCardState(
val isLoading: Boolean = false,
val isProcessing: Boolean = false,
val error: String? = null,
val status: String? = null,
val lastCardId: String? = null,
val lastExpiresAt: String? = null
)

View File

@@ -0,0 +1,263 @@
package com.android.trisolarispms.ui.card
import android.nfc.Tag
import android.nfc.tech.MifareClassic
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.CardPrepareRequest
import com.android.trisolarispms.data.api.model.IssueCardRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
class IssueTemporaryCardViewModel : ViewModel() {
private val _state = MutableStateFlow(IssueTemporaryCardState())
val state: StateFlow<IssueTemporaryCardState> = _state
fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
if (propertyId.isBlank() || roomId.isBlank()) return
if (state.value.isProcessing) return
viewModelScope.launch(Dispatchers.IO) {
Log.d("IssueTempCard", "Tag detected. roomId=$roomId cardId=${tagIdToHex(tag.id)}")
_state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") }
try {
val api = ApiClient.create()
Log.d("IssueTempCard", "Calling prepare-temp")
val prepareResponse = api.prepareTemporaryRoomCard(
propertyId = propertyId,
roomId = roomId,
body = CardPrepareRequest(expiresAt = null)
)
Log.d("IssueTempCard", "Prepare-temp response=${prepareResponse.code()}")
val prepareBody = prepareResponse.body()
if (!prepareResponse.isSuccessful || prepareBody == null) {
_state.update {
it.copy(
isProcessing = false,
error = "Prepare failed: ${prepareResponse.code()}",
status = null
)
}
return@launch
}
val cardIndex = prepareBody.cardIndex
val keyHex = prepareBody.key
val timeHex = prepareBody.timeData
if (cardIndex == null || keyHex.isNullOrBlank() || timeHex.isNullOrBlank()) {
_state.update {
it.copy(
isProcessing = false,
error = "Prepare response missing data.",
status = null
)
}
return@launch
}
_state.update { it.copy(status = "Writing card...") }
val cardId = tagIdToHex(tag.id)
val writeResult = writeSector0(
tag = tag,
keyHex = keyHex,
timeHex = timeHex,
sector3Block0 = prepareBody.sector3Block0,
sector3Block1 = prepareBody.sector3Block1,
sector3Block2 = prepareBody.sector3Block2
)
if (writeResult != null) {
Log.d("IssueTempCard", "Write failed: $writeResult")
_state.update {
it.copy(
isProcessing = false,
error = writeResult,
status = null
)
}
return@launch
}
Log.d("IssueTempCard", "Calling issue-temp")
_state.update { it.copy(status = "Saving issued card...") }
val issueResponse = api.issueTemporaryRoomCard(
propertyId = propertyId,
roomId = roomId,
body = IssueCardRequest(
cardId = cardId,
cardIndex = cardIndex,
issuedAt = prepareBody.issuedAt
?: nowAndExpiresIso(minutes = 7).first
)
)
Log.d("IssueTempCard", "Issue-temp response=${issueResponse.code()}")
if (issueResponse.isSuccessful) {
val body = issueResponse.body()
_state.update {
it.copy(
isProcessing = false,
error = null,
status = if (body != null) {
"Card issued. Expires in 7 minutes."
} else {
"Card issued (no response body)."
},
lastCardId = cardId,
lastExpiresAt = prepareBody.expiresAt
)
}
} else {
val errorText = issueResponse.errorBody()?.string()
Log.d("IssueTempCard", "Issue-temp error body=$errorText")
_state.update {
it.copy(
isProcessing = false,
error = when (issueResponse.code()) {
409 -> "Active card already exists."
else -> "Issue failed: ${issueResponse.code()}"
},
status = null
)
}
}
} catch (e: Exception) {
Log.d("IssueTempCard", "Issue failed: ${e.localizedMessage}", e)
_state.update {
it.copy(
isProcessing = false,
error = e.localizedMessage ?: "Issue failed",
status = null
)
}
}
}
}
private fun writeSector0(
tag: Tag,
keyHex: String,
timeHex: String,
sector3Block0: String?,
sector3Block1: String?,
sector3Block2: String?
): String? {
val mifare = MifareClassic.get(tag) ?: return "Unsupported card type (not Mifare Classic)."
return try {
mifare.connect()
val sectorIndex = 0
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT) ||
mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) ||
mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
if (!authA && !authB) return "Card authentication failed (sector 0)."
val keyBytes = hexToBytes(keyHex)
val timeBytes = hexToBytes(timeHex)
val blockIndex = mifare.sectorToBlock(sectorIndex)
Log.d("IssueTempCard", "Writing blocks ${blockIndex + 1} and ${blockIndex + 2}")
mifare.writeBlock(blockIndex + 1, keyBytes)
mifare.writeBlock(blockIndex + 2, timeBytes)
val trailerWrite = writeTrailerBlocks(mifare)
if (trailerWrite != null) return trailerWrite
val sector3Write = writeSector3Blocks(
mifare = mifare,
block0 = sector3Block0,
block1 = sector3Block1,
block2 = sector3Block2
)
if (sector3Write != null) return sector3Write
null
} catch (e: Exception) {
e.localizedMessage ?: "Write failed."
} finally {
try {
mifare.close()
} catch (_: Exception) {
}
}
}
private fun writeTrailerBlocks(mifare: MifareClassic): String? {
val sectors = listOf(0, 1, 2, 14)
for (sector in sectors) {
val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Trailer auth failed (sector $sector)."
val blockIndex = mifare.sectorToBlock(sector) + 3
mifare.writeBlock(blockIndex, CARD_SECTOR_INFO)
}
return null
}
private fun writeSector3Blocks(
mifare: MifareClassic,
block0: String?,
block1: String?,
block2: String?
): String? {
if (block0.isNullOrBlank() && block1.isNullOrBlank() && block2.isNullOrBlank()) return null
val sector = 3
val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Sector 3 authentication failed."
val blockIndex = mifare.sectorToBlock(sector)
block0?.let { mifare.writeBlock(blockIndex + 0, hexToBytes(it)) }
block1?.let { mifare.writeBlock(blockIndex + 1, hexToBytes(it)) }
block2?.let { mifare.writeBlock(blockIndex + 2, hexToBytes(it)) }
return null
}
private fun nowAndExpiresIso(minutes: Int): Pair<String, String> {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
timeZone = TimeZone.getDefault()
}
val calendar = Calendar.getInstance()
val issuedAt = format.format(calendar.time)
calendar.add(Calendar.MINUTE, minutes)
val expiresAt = format.format(calendar.time)
return issuedAt to expiresAt
}
private fun tagIdToHex(tagId: ByteArray?): String {
if (tagId == null) return ""
return tagId.joinToString("") { byte -> "%02x".format(byte) }
}
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().replace(" ", "")
val len = clean.length
if (len % 2 != 0) return ByteArray(0)
val out = ByteArray(len / 2)
var i = 0
while (i < len) {
out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
i += 2
}
return out
}
companion object {
private val CARD_SECTOR_INFO = hexToBytesStatic("1AB23CD45EF6FF078069FFFFFFFFFFFF")
private fun hexToBytesStatic(hex: String): ByteArray {
val clean = hex.trim().replace(" ", "")
val len = clean.length
if (len % 2 != 0) return ByteArray(0)
val out = ByteArray(len / 2)
var i = 0
while (i < len) {
out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
i += 2
}
return out
}
}
}

View File

@@ -5,5 +5,7 @@ import com.android.trisolarispms.data.api.model.RoomDto
data class RoomListState( data class RoomListState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val rooms: List<RoomDto> = emptyList() val rooms: List<RoomDto> = emptyList(),
val showAll: Boolean = false,
val roomTypeCode: String? = null
) )

View File

@@ -12,13 +12,24 @@ class RoomListViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomListState()) private val _state = MutableStateFlow(RoomListState())
val state: StateFlow<RoomListState> = _state val state: StateFlow<RoomListState> = _state
fun load(propertyId: String) { fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) {
if (propertyId.isBlank()) return if (propertyId.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) }
try { try {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.listRooms(propertyId) val trimmedCode = roomTypeCode?.trim().orEmpty()
val response = if (trimmedCode.isNotBlank()) {
api.listRoomsByType(
propertyId = propertyId,
roomTypeCode = trimmedCode,
availableOnly = if (showAll) false else true
)
} else if (showAll) {
api.listRooms(propertyId)
} else {
api.listAvailableRooms(propertyId)
}
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { _state.update {
it.copy( it.copy(
@@ -35,4 +46,12 @@ class RoomListViewModel : ViewModel() {
} }
} }
} }
fun setShowAll(propertyId: String, showAll: Boolean) {
load(propertyId, showAll, _state.value.roomTypeCode)
}
fun setRoomTypeFilter(propertyId: String, roomTypeCode: String?) {
load(propertyId, _state.value.showAll, roomTypeCode)
}
} }

View File

@@ -1,18 +1,29 @@
package com.android.trisolarispms.ui.room package com.android.trisolarispms.ui.room
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Category import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Hotel
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -21,6 +32,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -29,6 +43,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.layout.Box
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,12 +57,17 @@ fun RoomsScreen(
onViewRoomTypes: () -> Unit, onViewRoomTypes: () -> Unit,
canManageRooms: Boolean, canManageRooms: Boolean,
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
viewModel: RoomListViewModel = viewModel() onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
viewModel: RoomListViewModel = viewModel(),
roomTypeListViewModel: RoomTypeListViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val roomTypeState by roomTypeListViewModel.state.collectAsState()
val showTypeMenu = remember { mutableStateOf(false) }
LaunchedEffect(propertyId) { LaunchedEffect(propertyId) {
viewModel.load(propertyId) viewModel.load(propertyId, showAll = false)
roomTypeListViewModel.load(propertyId)
} }
Scaffold( Scaffold(
@@ -86,29 +109,153 @@ fun RoomsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
if (!state.isLoading && state.error == null) { if (!state.isLoading && state.error == null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
FilterChip(
selected = state.showAll,
onClick = { viewModel.setShowAll(propertyId, !state.showAll) },
label = { Text(if (state.showAll) "Showing all" else "Showing available") }
)
Box {
val selectedType = state.roomTypeCode
FilterChip(
selected = !selectedType.isNullOrBlank(),
onClick = { showTypeMenu.value = true },
label = { Text("Type: ${selectedType ?: "All"}") }
)
DropdownMenu(
expanded = showTypeMenu.value,
onDismissRequest = { showTypeMenu.value = false }
) {
DropdownMenuItem(
text = { Text("All types") },
onClick = {
showTypeMenu.value = false
viewModel.setRoomTypeFilter(propertyId, null)
}
)
roomTypeState.items.forEach { type ->
val code = type.code ?: return@forEach
val label = type.name?.let { "$code$it" } ?: code
DropdownMenuItem(
text = { Text(label) },
onClick = {
showTypeMenu.value = false
viewModel.setRoomTypeFilter(propertyId, code)
}
)
}
}
}
}
val availableCount = state.rooms.size
Text(
text = "Available rooms: $availableCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
if (state.rooms.isEmpty()) { if (state.rooms.isEmpty()) {
Text(text = "No rooms found") Text(text = "No rooms found")
} else { } else {
state.rooms.forEach { room -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
items(state.rooms) { room ->
val label = room.roomNumber?.toString() ?: "-" val label = room.roomNumber?.toString() ?: "-"
val details = listOfNotNull( val category = room.roomTypeName ?: room.roomTypeCode ?: "Unassigned"
room.floor?.let { "Floor $it" }, val floorLabel = room.floor?.let { "Floor $it" }.orEmpty()
room.roomTypeName ?: room.roomTypeCode
).joinToString("")
val isDimmed = (room.active == false) || (room.maintenance == true) val isDimmed = (room.active == false) || (room.maintenance == true)
val alpha = if (isDimmed) 0.5f else 1f val alpha = if (isDimmed) 0.5f else 1f
Column( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.alpha(alpha) .alpha(alpha)
.clickable(enabled = room.id != null) { .combinedClickable(
onEditRoom(room) enabled = room.id != null,
} onClick = { onIssueTemporaryCard(room) },
.padding(vertical = 10.dp) onLongClick = { onEditRoom(room) }
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Hotel,
contentDescription = "Room",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = label, style = MaterialTheme.typography.titleMedium) Text(text = label, style = MaterialTheme.typography.titleMedium)
if (details.isNotBlank()) { }
Text(text = details, style = MaterialTheme.typography.bodySmall) Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Category,
contentDescription = "Category",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = category,
style = MaterialTheme.typography.bodySmall
)
}
val maxOcc = room.maxOccupancy
if (maxOcc != null && maxOcc > 0) {
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Groups,
contentDescription = "Max occupancy",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Max $maxOcc",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (floorLabel.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Layers,
contentDescription = "Floor",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = floorLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (room.maintenance == true) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Maintenance",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
} }
} }
} }

View File

@@ -1,35 +1,7 @@
package com.android.trisolarispms.ui.roomtype package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api 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.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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@Composable @Composable
@@ -41,122 +13,16 @@ fun AddRoomTypeScreen(
viewModel: RoomTypeFormViewModel = viewModel(), viewModel: RoomTypeFormViewModel = viewModel(),
amenityViewModel: AmenityListViewModel = viewModel() amenityViewModel: AmenityListViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() androidx.compose.runtime.LaunchedEffect(Unit) {
val amenityState by amenityViewModel.state.collectAsState() viewModel.resetForm()
LaunchedEffect(Unit) {
amenityViewModel.load()
} }
RoomTypeFormScreen(
Scaffold( title = "Add Room Type",
topBar = { propertyId = propertyId,
TopAppBar( onBack = onBack,
title = { Text("Add Room Type") }, onSave = { viewModel.submit(propertyId, onSave) },
navigationIcon = { codeEditable = true,
IconButton(onClick = onBack) { viewModel = viewModel,
Icon(Icons.Default.ArrowBack, contentDescription = "Back") amenityViewModel = amenityViewModel
}
},
actions = {
IconButton(onClick = { viewModel.submit(propertyId, 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.code,
onValueChange = viewModel::onCodeChange,
label = { Text("Code") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.baseOccupancy,
onValueChange = viewModel::onBaseOccupancyChange,
label = { Text("Base Occupancy") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.maxOccupancy,
onValueChange = viewModel::onMaxOccupancyChange,
label = { Text("Max Occupancy") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.sqFeet,
onValueChange = viewModel::onSqFeetChange,
label = { Text("Sq Ft") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.bathroomSqFeet,
onValueChange = viewModel::onBathroomSqFeetChange,
label = { Text("Bathroom Sq Ft") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
if (amenityState.items.isEmpty()) {
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
} else {
amenityState.items.forEach { amenity ->
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(vertical = 4.dp)
) {
Checkbox(
checked = state.amenityIds.contains(amenity.id),
onCheckedChange = { amenity.id?.let { viewModel.onAmenityToggle(it) } }
)
Text(text = amenity.name ?: "", modifier = Modifier.padding(start = 8.dp))
}
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.otaAliases,
onValueChange = viewModel::onAliasesChange,
label = { Text("OTA Aliases (comma separated)") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
} }

View File

@@ -1,58 +1,29 @@
package com.android.trisolarispms.ui.roomtype package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.DragIndicator
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.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.android.trisolarispms.data.api.ApiConstants
import com.android.trisolarispms.data.api.model.RoomTypeDto import com.android.trisolarispms.data.api.model.RoomTypeDto
import com.android.trisolarispms.ui.roomimage.RoomImageGridItem import com.android.trisolarispms.ui.roomimage.ImagePreviewDialog
import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid
import com.android.trisolarispms.ui.roomimage.RoomImageGridItem
import com.android.trisolarispms.ui.roomimage.RoomImageViewModel import com.android.trisolarispms.ui.roomimage.RoomImageViewModel
@Composable @Composable
@@ -66,16 +37,14 @@ fun EditRoomTypeScreen(
amenityViewModel: AmenityListViewModel = viewModel(), amenityViewModel: AmenityListViewModel = viewModel(),
roomImageViewModel: RoomImageViewModel = viewModel() roomImageViewModel: RoomImageViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
val roomImageState by roomImageViewModel.state.collectAsState() val roomImageState by roomImageViewModel.state.collectAsState()
val showDeleteConfirm = remember { mutableStateOf(false) } val showDeleteConfirm = remember { mutableStateOf(false) }
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) } val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) } val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
val previewUrl = remember { mutableStateOf<String?>(null) } val previewUrl = remember { mutableStateOf<String?>(null) }
val showAmenityDialog = remember { mutableStateOf(false) }
val amenitySearch = remember { mutableStateOf("") } val gridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
val gridState = rememberLazyGridState()
val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) { val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) {
{ {
val code = roomType.code.orEmpty() val code = roomType.code.orEmpty()
@@ -101,9 +70,6 @@ fun EditRoomTypeScreen(
LaunchedEffect(roomType.id) { LaunchedEffect(roomType.id) {
viewModel.setRoomType(roomType) viewModel.setRoomType(roomType)
} }
LaunchedEffect(Unit) {
amenityViewModel.load()
}
LaunchedEffect(roomType.code) { LaunchedEffect(roomType.code) {
val code = roomType.code.orEmpty() val code = roomType.code.orEmpty()
if (code.isNotBlank()) { if (code.isNotBlank()) {
@@ -115,121 +81,22 @@ fun EditRoomTypeScreen(
originalOrderIds.value = roomImageState.images.mapNotNull { it.id } originalOrderIds.value = roomImageState.images.mapNotNull { it.id }
} }
Scaffold( RoomTypeFormScreen(
topBar = { title = "Modify Room Type",
TopAppBar( propertyId = propertyId,
title = { Text("Modify Room Type") }, onBack = onBack,
navigationIcon = { onSave = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) },
IconButton(onClick = onBack) { codeEditable = false,
Icon(Icons.Default.ArrowBack, contentDescription = "Back") onDelete = { if (!roomType.id.isNullOrBlank()) showDeleteConfirm.value = true },
} viewModel = viewModel,
}, amenityViewModel = amenityViewModel
actions = {
IconButton(onClick = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
if (!roomType.id.isNullOrBlank()) {
IconButton(onClick = { showDeleteConfirm.value = true }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Room Type")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) { ) {
OutlinedTextField(
value = state.code,
onValueChange = {},
readOnly = true,
label = { Text("Code") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.baseOccupancy,
onValueChange = viewModel::onBaseOccupancyChange,
label = { Text("Base Occupancy") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.maxOccupancy,
onValueChange = viewModel::onMaxOccupancyChange,
label = { Text("Max Occupancy") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.sqFeet,
onValueChange = viewModel::onSqFeetChange,
label = { Text("Sq Ft") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.bathroomSqFeet,
onValueChange = viewModel::onBathroomSqFeetChange,
label = { Text("Bathroom Sq Ft") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.otaAliases,
onValueChange = viewModel::onAliasesChange,
label = { Text("OTA Aliases (comma separated)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall) Text(text = "Room Type Images", style = androidx.compose.material3.MaterialTheme.typography.titleSmall)
Button(onClick = { showAmenityDialog.value = true }) {
Text("Edit")
}
}
val selectedCount = state.amenityIds.size
Text(
text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Room Type Images", style = MaterialTheme.typography.titleSmall)
val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value
if (orderedImages.value.isNotEmpty() && hasOrderChanged) { if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
Button(onClick = saveRoomTypeImageOrder) { Button(onClick = saveRoomTypeImageOrder) {
@@ -241,7 +108,7 @@ fun EditRoomTypeScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.CircularProgressIndicator() androidx.compose.material3.CircularProgressIndicator()
} else if (orderedImages.value.isEmpty()) { } else if (orderedImages.value.isEmpty()) {
Text(text = "No images yet", style = MaterialTheme.typography.bodySmall) Text(text = "No images yet", style = androidx.compose.material3.MaterialTheme.typography.bodySmall)
} else { } else {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ReorderableImageGrid( ReorderableImageGrid(
@@ -265,11 +132,6 @@ fun EditRoomTypeScreen(
) )
} }
} }
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
} }
if (showDeleteConfirm.value && !roomType.id.isNullOrBlank()) { if (showDeleteConfirm.value && !roomType.id.isNullOrBlank()) {
@@ -293,90 +155,9 @@ fun EditRoomTypeScreen(
) )
} }
if (showAmenityDialog.value) {
val query = amenitySearch.value.trim()
val filtered = if (query.isBlank()) {
amenityState.items
} else {
amenityState.items.filter {
val name = it.name.orEmpty()
val category = it.category.orEmpty()
name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true)
}
}
AlertDialog(
onDismissRequest = { showAmenityDialog.value = false },
title = { Text("Select Amenities") },
text = {
Column {
OutlinedTextField(
value = amenitySearch.value,
onValueChange = { amenitySearch.value = it },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
if (amenityState.items.isEmpty()) {
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
} else if (filtered.isEmpty()) {
Text(text = "No matches", style = MaterialTheme.typography.bodySmall)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
) {
items(filtered) { amenity ->
val id = amenity.id ?: return@items
val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = state.amenityIds.contains(id),
onCheckedChange = { viewModel.onAmenityToggle(id) }
)
if (iconKey.isNotBlank()) {
AsyncImage(
model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png",
contentDescription = amenity.name ?: iconKey,
modifier = Modifier
.size(24.dp)
.padding(end = 8.dp)
)
} else {
Spacer(modifier = Modifier.width(8.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(text = amenity.name.orEmpty())
if (!amenity.category.isNullOrBlank()) {
Text(
text = amenity.category.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showAmenityDialog.value = false }) {
Text("Done")
}
}
)
}
val preview = previewUrl.value val preview = previewUrl.value
if (!preview.isNullOrBlank()) { if (!preview.isNullOrBlank()) {
com.android.trisolarispms.ui.roomimage.ImagePreviewDialog( ImagePreviewDialog(
imageUrl = preview, imageUrl = preview,
onDismiss = { previewUrl.value = null } onDismiss = { previewUrl.value = null }
) )

View File

@@ -0,0 +1,275 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.android.trisolarispms.data.api.ApiConstants
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RoomTypeFormScreen(
title: String,
propertyId: String,
onBack: () -> Unit,
onSave: () -> Unit,
codeEditable: Boolean,
onDelete: (() -> Unit)? = null,
viewModel: RoomTypeFormViewModel = viewModel(),
amenityViewModel: AmenityListViewModel = viewModel(),
extraContent: @Composable (() -> Unit)? = null
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
val showAmenityDialog = remember { mutableStateOf(false) }
val amenitySearch = remember { mutableStateOf("") }
LaunchedEffect(Unit) {
amenityViewModel.load()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onSave) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete Room Type")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.code,
onValueChange = { value ->
if (codeEditable) {
viewModel.onCodeChange(value)
}
},
readOnly = !codeEditable,
label = { Text("Code") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.baseOccupancy,
onValueChange = viewModel::onBaseOccupancyChange,
label = { Text("Base Occupancy") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.maxOccupancy,
onValueChange = viewModel::onMaxOccupancyChange,
label = { Text("Max Occupancy") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.sqFeet,
onValueChange = viewModel::onSqFeetChange,
label = { Text("Sq Ft") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.bathroomSqFeet,
onValueChange = viewModel::onBathroomSqFeetChange,
label = { Text("Bathroom Sq Ft") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
Button(onClick = { showAmenityDialog.value = true }) {
Text("Edit")
}
}
val selectedCount = state.amenityIds.size
Text(
text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.otaAliases,
onValueChange = viewModel::onAliasesChange,
label = { Text("OTA Aliases (comma separated)") },
modifier = Modifier.fillMaxWidth()
)
extraContent?.let {
Spacer(modifier = Modifier.height(16.dp))
it()
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
if (showAmenityDialog.value) {
val query = amenitySearch.value.trim()
val filtered = if (query.isBlank()) {
amenityState.items
} else {
amenityState.items.filter {
val name = it.name.orEmpty()
val category = it.category.orEmpty()
name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true)
}
}
AlertDialog(
onDismissRequest = { showAmenityDialog.value = false },
title = { Text("Select Amenities") },
text = {
Column {
OutlinedTextField(
value = amenitySearch.value,
onValueChange = { amenitySearch.value = it },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
if (amenityState.items.isEmpty()) {
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
} else if (filtered.isEmpty()) {
Text(text = "No matches", style = MaterialTheme.typography.bodySmall)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
) {
items(filtered) { amenity ->
val id = amenity.id ?: return@items
val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = state.amenityIds.contains(id),
onCheckedChange = { viewModel.onAmenityToggle(id) }
)
if (iconKey.isNotBlank()) {
AsyncImage(
model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png",
contentDescription = amenity.name ?: iconKey,
modifier = Modifier
.size(24.dp)
.padding(end = 8.dp)
)
} else {
Spacer(modifier = Modifier.width(8.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(text = amenity.name.orEmpty())
if (!amenity.category.isNullOrBlank()) {
Text(
text = amenity.category.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showAmenityDialog.value = false }) {
Text("Done")
}
}
)
}
}

View File

@@ -14,6 +14,10 @@ class RoomTypeFormViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomTypeFormState()) private val _state = MutableStateFlow(RoomTypeFormState())
val state: StateFlow<RoomTypeFormState> = _state val state: StateFlow<RoomTypeFormState> = _state
fun resetForm() {
_state.update { RoomTypeFormState() }
}
fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) { fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) {
_state.update { _state.update {
it.copy( it.copy(

View File

@@ -6,5 +6,6 @@ data class RoomTypeListState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val items: List<RoomTypeDto> = emptyList(), val items: List<RoomTypeDto> = emptyList(),
val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap() val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap(),
val availableCountByTypeCode: Map<String, Int> = emptyMap()
) )

View File

@@ -29,6 +29,7 @@ class RoomTypeListViewModel : ViewModel() {
) )
} }
loadRoomTypeImages(propertyId, items) loadRoomTypeImages(propertyId, items)
loadRoomTypeAvailableCounts(propertyId, items)
} else { } else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
} }
@@ -59,4 +60,30 @@ class RoomTypeListViewModel : ViewModel() {
} }
} }
} }
private fun loadRoomTypeAvailableCounts(propertyId: String, items: List<com.android.trisolarispms.data.api.model.RoomTypeDto>) {
viewModelScope.launch {
val api = ApiClient.create()
val updates = mutableMapOf<String, Int>()
for (item in items) {
val code = item.code?.trim().orEmpty()
if (code.isBlank()) continue
try {
val response = api.listRoomsByType(
propertyId = propertyId,
roomTypeCode = code,
availableOnly = true
)
if (response.isSuccessful) {
updates[code] = response.body().orEmpty().size
}
} catch (_: Exception) {
// Ignore per-item failures.
}
}
if (updates.isNotEmpty()) {
_state.update { it.copy(availableCountByTypeCode = it.availableCountByTypeCode + updates) }
}
}
}
} }

View File

@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons 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.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AspectRatio
import androidx.compose.material.icons.filled.Bed
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -105,11 +108,61 @@ fun RoomTypesScreen(
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
) )
} }
Column(modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = "${item.code}${item.name}", text = "${item.code}${item.name}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.fillMaxWidth()
) )
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
item.maxOccupancy?.let { max ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Default.Groups,
contentDescription = "Max occupancy",
modifier = Modifier.size(16.dp)
)
Text(
text = max.toString(),
style = MaterialTheme.typography.bodySmall
)
}
}
state.availableCountByTypeCode[item.code.orEmpty()]?.let { count ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Default.Bed,
contentDescription = "Available rooms",
modifier = Modifier.size(16.dp)
)
Text(
text = count.toString(),
style = MaterialTheme.typography.bodySmall
)
}
}
item.sqFeet?.let { sq ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Default.AspectRatio,
contentDescription = "Room size",
modifier = Modifier.size(16.dp)
)
Text(
text = "${sq} sq ft",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} }

View File

@@ -18,6 +18,7 @@ lifecycleViewModelCompose = "2.10.0"
firebaseAuthKtx = "24.0.1" firebaseAuthKtx = "24.0.1"
vectordrawable = "1.2.0" vectordrawable = "1.2.0"
coilCompose = "2.7.0" coilCompose = "2.7.0"
lottieCompose = "6.7.1"
[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" }
@@ -46,6 +47,7 @@ kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "ko
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" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }