From 3fe0730f4cf0ed70badc37f4298ec13873797b7b Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Wed, 28 Jan 2026 20:49:10 +0530 Subject: [PATCH] Improve temp card flow and amenity list --- .../android/trisolarispms/data/api/CardApi.kt | 6 ++ .../ui/card/IssueTemporaryCardScreen.kt | 13 ++- .../ui/card/IssueTemporaryCardViewModel.kt | 92 +++++++++++++++++++ .../trisolarispms/ui/room/RoomsScreen.kt | 2 +- .../ui/roomimage/AddImageTagScreen.kt | 5 + .../ui/roomimage/ImageTagFormViewModel.kt | 6 +- .../ui/roomtype/AmenitiesScreen.kt | 63 +++++++++---- 7 files changed, 163 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt index f2b4a9a..edc7891 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt @@ -40,6 +40,12 @@ interface CardApi { @Body body: IssueCardRequest ): Response + @GET("properties/{propertyId}/room-stays/cards/{cardIndex}") + suspend fun getCardByIndex( + @Path("propertyId") propertyId: String, + @Path("cardIndex") cardIndex: String + ): Response + @GET("properties/{propertyId}/room-stays/{roomStayId}/cards") suspend fun listCards( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt index 2a2e78e..6abdc52 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt @@ -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(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 } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt index 6216b26..650d215 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt index de708c0..006d3dc 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt @@ -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) } }, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt index ee79fb2..2f38073 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/AddImageTagScreen.kt @@ -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( diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt index f99b479..5d43d14 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagFormViewModel.kt @@ -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()}") } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt index 1e40615..87b8cf8 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt @@ -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) - } } } }