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 @Body body: IssueCardRequest
): Response<IssuedCardResponse> ): 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") @GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
suspend fun listCards( suspend fun listCards(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

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

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -35,6 +36,41 @@ class IssueTemporaryCardViewModel : ViewModel() {
_state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") } _state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") }
try { try {
val api = ApiClient.create() 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") Log.d("IssueTempCard", "Calling prepare-temp")
val prepareResponse = api.prepareTemporaryRoomCard( val prepareResponse = api.prepareTemporaryRoomCard(
propertyId = propertyId, propertyId = propertyId,
@@ -200,6 +236,29 @@ class IssueTemporaryCardViewModel : ViewModel() {
return null 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( private fun writeSector3Blocks(
mifare: MifareClassic, mifare: MifareClassic,
block0: String?, block0: String?,
@@ -234,6 +293,39 @@ class IssueTemporaryCardViewModel : ViewModel() {
return tagId.joinToString("") { byte -> "%02x".format(byte) } 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 { private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().replace(" ", "") val clean = hex.trim().replace(" ", "")
val len = clean.length val len = clean.length

View File

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

View File

@@ -22,6 +22,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier 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
@@ -35,6 +36,10 @@ fun AddImageTagScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.reset()
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(

View File

@@ -17,6 +17,10 @@ class ImageTagFormViewModel : ViewModel() {
_state.update { it.copy(name = tag.name.orEmpty(), error = null) } _state.update { it.copy(name = tag.name.orEmpty(), error = null) }
} }
fun reset() {
_state.value = ImageTagFormState()
}
fun onNameChange(value: String) { fun onNameChange(value: String) {
_state.update { it.copy(name = value, error = null) } _state.update { it.copy(name = value, error = null) }
} }
@@ -33,7 +37,7 @@ class ImageTagFormViewModel : ViewModel() {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.createImageTag(RoomImageTagDto(name = name)) val response = api.createImageTag(RoomImageTagDto(name = name))
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) } _state.update { ImageTagFormState(success = true) }
onDone() onDone()
} else { } else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } _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.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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
@@ -29,6 +33,8 @@ 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 com.android.trisolarispms.data.api.model.AmenityDto import com.android.trisolarispms.data.api.model.AmenityDto
import com.android.trisolarispms.data.api.ApiConstants
import coil.compose.AsyncImage
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -84,30 +90,47 @@ fun AmenitiesScreen(
if (state.items.isEmpty()) { if (state.items.isEmpty()) {
Text(text = "No amenities") Text(text = "No amenities")
} else { } else {
state.items.forEach { item -> LazyColumn(modifier = Modifier.fillMaxSize()) {
androidx.compose.foundation.layout.Row( items(state.items) { item ->
modifier = Modifier val iconKey = item.iconKey.orEmpty().removeSuffix(".png")
.fillMaxWidth() val meta = listOfNotNull(item.category, item.iconKey).joinToString("")
.padding(vertical = 8.dp), if (meta.isNotBlank()) {
horizontalArrangement = Arrangement.SpaceBetween Text(text = meta, style = MaterialTheme.typography.bodySmall)
) { }
Text( androidx.compose.foundation.layout.Row(
text = item.name ?: "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.clickable(enabled = canManageAmenities && item.id != null) { onEdit(item) } .padding(vertical = 8.dp)
) .clickable(enabled = canManageAmenities && item.id != null) { onEdit(item) },
if (canManageAmenities && item.id != null) { horizontalArrangement = Arrangement.SpaceBetween
IconButton(onClick = { viewModel.deleteAmenity(item.id) }) { ) {
Icon(Icons.Default.Delete, contentDescription = "Delete Amenity") 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
)
}
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)
}
} }
} }
} }