Improve temp card flow and amenity list

This commit is contained in:
androidlover5842
2026-01-28 20:49:10 +05:30
parent 1068e05c4a
commit 3fe0730f4c
7 changed files with 163 additions and 24 deletions

View File

@@ -40,6 +40,12 @@ interface CardApi {
@Body body: IssueCardRequest
): Response<IssuedCardResponse>
@GET("properties/{propertyId}/room-stays/cards/{cardIndex}")
suspend fun getCardByIndex(
@Path("propertyId") propertyId: String,
@Path("cardIndex") cardIndex: String
): Response<IssuedCardResponse>
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
suspend fun listCards(
@Path("propertyId") propertyId: String,

View File

@@ -54,6 +54,8 @@ fun IssueTemporaryCardScreen(
val activity = context as? Activity
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
val showSuccessDialog = remember { mutableStateOf(false) }
val lastShownCardId = remember { mutableStateOf<String?>(null) }
val resetVersion = remember { mutableStateOf(0) }
val composition = rememberLottieComposition(
LottieCompositionSpec.Asset("scan_nfc_animation.json")
)
@@ -65,10 +67,17 @@ fun IssueTemporaryCardScreen(
LaunchedEffect(propertyId, roomId) {
viewModel.reset()
showSuccessDialog.value = false
lastShownCardId.value = null
resetVersion.value = resetVersion.value + 1
}
LaunchedEffect(state.lastCardId) {
if (!state.lastCardId.isNullOrBlank()) {
LaunchedEffect(state.lastCardId, resetVersion.value) {
val currentId = state.lastCardId
if (resetVersion.value > 0 &&
!currentId.isNullOrBlank() &&
currentId != lastShownCardId.value
) {
lastShownCardId.value = currentId
showSuccessDialog.value = true
}
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
@@ -35,6 +36,41 @@ class IssueTemporaryCardViewModel : ViewModel() {
_state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") }
try {
val api = ApiClient.create()
val existingCardIndex = readCardIndex(tag)
if (existingCardIndex != null) {
val cardResponse = api.getCardByIndex(propertyId, existingCardIndex.toString())
val cardBody = cardResponse.body()
if (cardResponse.isSuccessful && cardBody != null) {
val isTemp = cardBody.roomStayId.isNullOrBlank()
val active = isTemp && isCardActive(cardBody.expiresAt, cardBody.revokedAt)
if (active) {
_state.update { it.copy(status = "Revoking old card...") }
val revokeResponse = api.revokeCard(propertyId, existingCardIndex.toString())
val revokeBody = revokeResponse.body()
if (!revokeResponse.isSuccessful || revokeBody?.timeData.isNullOrBlank()) {
_state.update {
it.copy(
isProcessing = false,
error = "Revoke failed: ${revokeResponse.code()}",
status = null
)
}
return@launch
}
val revokeWrite = writeRevokeTimeData(tag, revokeBody!!.timeData!!)
if (revokeWrite != null) {
_state.update {
it.copy(
isProcessing = false,
error = revokeWrite,
status = null
)
}
return@launch
}
}
}
}
Log.d("IssueTempCard", "Calling prepare-temp")
val prepareResponse = api.prepareTemporaryRoomCard(
propertyId = propertyId,
@@ -200,6 +236,29 @@ class IssueTemporaryCardViewModel : ViewModel() {
return null
}
private fun writeRevokeTimeData(tag: Tag, timeDataHex: 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)
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Card authentication failed (sector 0)."
val timeBytes = hexToBytes(timeDataHex)
if (timeBytes.size != 16) return "Invalid time data size."
val blockIndex = mifare.sectorToBlock(sectorIndex)
mifare.writeBlock(blockIndex + 2, timeBytes)
null
} catch (e: Exception) {
e.localizedMessage ?: "Write failed."
} finally {
try {
mifare.close()
} catch (_: Exception) {
}
}
}
private fun writeSector3Blocks(
mifare: MifareClassic,
block0: String?,
@@ -234,6 +293,39 @@ class IssueTemporaryCardViewModel : ViewModel() {
return tagId.joinToString("") { byte -> "%02x".format(byte) }
}
private fun readCardIndex(tag: Tag): Int? {
val mifare = MifareClassic.get(tag) ?: return null
return try {
mifare.connect()
val sectorIndex = 0
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return null
val blockIndex = mifare.sectorToBlock(sectorIndex) + 1
val data = mifare.readBlock(blockIndex)
val dataString = data.joinToString("") { String.format("%02X", it) }
if (dataString.length < 10) return null
dataString.substring(4, 10).toIntOrNull()
} catch (_: Exception) {
null
} finally {
try {
mifare.close()
} catch (_: Exception) {
}
}
}
private fun isCardActive(expiresAt: String?, revokedAt: String?): Boolean {
if (revokedAt != null) return false
if (expiresAt.isNullOrBlank()) return false
return try {
Instant.parse(expiresAt).isAfter(Instant.now())
} catch (_: Exception) {
false
}
}
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().replace(" ", "")
val len = clean.length

View File

@@ -185,7 +185,7 @@ fun RoomsScreen(
.combinedClickable(
enabled = room.id != null,
onClick = {
if (room.hasNfc != false) {
if (room.hasNfc != false && room.tempCardActive != true) {
onIssueTemporaryCard(room)
}
},

View File

@@ -22,6 +22,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -35,6 +36,10 @@ fun AddImageTagScreen(
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.reset()
}
Scaffold(
topBar = {
TopAppBar(

View File

@@ -17,6 +17,10 @@ class ImageTagFormViewModel : ViewModel() {
_state.update { it.copy(name = tag.name.orEmpty(), error = null) }
}
fun reset() {
_state.value = ImageTagFormState()
}
fun onNameChange(value: String) {
_state.update { it.copy(name = value, error = null) }
}
@@ -33,7 +37,7 @@ class ImageTagFormViewModel : ViewModel() {
val api = ApiClient.create()
val response = api.createImageTag(RoomImageTagDto(name = name))
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
_state.update { ImageTagFormState(success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }

View File

@@ -8,6 +8,10 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
@@ -29,6 +33,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.AmenityDto
import com.android.trisolarispms.data.api.ApiConstants
import coil.compose.AsyncImage
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -84,29 +90,46 @@ fun AmenitiesScreen(
if (state.items.isEmpty()) {
Text(text = "No amenities")
} else {
state.items.forEach { item ->
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.items) { item ->
val iconKey = item.iconKey.orEmpty().removeSuffix(".png")
val meta = listOfNotNull(item.category, item.iconKey).joinToString("")
if (meta.isNotBlank()) {
Text(text = meta, style = MaterialTheme.typography.bodySmall)
}
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
.padding(vertical = 8.dp)
.clickable(enabled = canManageAmenities && item.id != null) { onEdit(item) },
horizontalArrangement = Arrangement.SpaceBetween
) {
androidx.compose.foundation.layout.Row(
modifier = Modifier
.weight(1f),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
if (iconKey.isNotBlank()) {
AsyncImage(
model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png",
contentDescription = item.name ?: iconKey,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.width(8.dp))
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = item.name ?: "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.clickable(enabled = canManageAmenities && item.id != null) { onEdit(item) }
style = MaterialTheme.typography.titleMedium
)
}
if (canManageAmenities && item.id != null) {
IconButton(onClick = { viewModel.deleteAmenity(item.id) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Amenity")
}
}
}
val meta = listOfNotNull(item.category, item.iconKey).joinToString("")
if (meta.isNotBlank()) {
Text(text = meta, style = MaterialTheme.typography.bodySmall)
}
}
}