Improve temp card flow and amenity list
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()}") }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user