Improve temp card flow and amenity list
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}") }
|
||||
|
||||
@@ -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,30 +90,47 @@ fun AmenitiesScreen(
|
||||
if (state.items.isEmpty()) {
|
||||
Text(text = "No amenities")
|
||||
} else {
|
||||
state.items.forEach { item ->
|
||||
androidx.compose.foundation.layout.Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = item.name ?: "",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
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
|
||||
.weight(1f)
|
||||
.clickable(enabled = canManageAmenities && item.id != null) { onEdit(item) }
|
||||
)
|
||||
if (canManageAmenities && item.id != null) {
|
||||
IconButton(onClick = { viewModel.deleteAmenity(item.id) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete Amenity")
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user