diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0862798..df8f07d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -56,6 +56,7 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
+ implementation(libs.lottie.compose)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 941efb4..2e3469c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
RoomTypesScreen(
@@ -197,6 +204,12 @@ class MainActivity : ComponentActivity() {
route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId)
}
)
+ is AppRoute.IssueTemporaryCard -> IssueTemporaryCardScreen(
+ propertyId = currentRoute.propertyId,
+ roomId = currentRoute.roomId,
+ roomNumber = selectedRoom.value?.roomNumber?.toString(),
+ onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
+ )
is AppRoute.RoomImages -> RoomImagesScreen(
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,
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 3121c89..00ee823 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
@@ -26,6 +26,20 @@ interface CardApi {
@Body body: IssueCardRequest
): Response
+ @POST("properties/{propertyId}/rooms/{roomId}/cards/prepare-temp")
+ suspend fun prepareTemporaryRoomCard(
+ @Path("propertyId") propertyId: String,
+ @Path("roomId") roomId: String,
+ @Body body: CardPrepareRequest
+ ): Response
+
+ @POST("properties/{propertyId}/rooms/{roomId}/cards/temp")
+ suspend fun issueTemporaryRoomCard(
+ @Path("propertyId") propertyId: String,
+ @Path("roomId") roomId: String,
+ @Body body: IssueCardRequest
+ ): 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/data/api/model/CardModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt
index 0a83e97..4ad888a 100644
--- a/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt
+++ b/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt
@@ -8,6 +8,9 @@ data class CardPrepareResponse(
val cardIndex: Int? = null,
val key: String? = null,
val timeData: String? = null,
+ val sector3Block0: String? = null,
+ val sector3Block1: String? = null,
+ val sector3Block2: String? = null,
val issuedAt: String? = null,
val expiresAt: String? = null
)
@@ -16,7 +19,7 @@ data class IssueCardRequest(
val cardId: String,
val cardIndex: Int,
val issuedAt: String? = null,
- val expiresAt: String
+ val expiresAt: String? = null
)
data class IssuedCardResponse(
diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt
index 2b26627..6c9a06f 100644
--- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt
+++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt
@@ -7,6 +7,7 @@ sealed interface AppRoute {
data class Rooms(val propertyId: String) : AppRoute
data class AddRoom(val propertyId: String) : AppRoute
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
+ data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute
data class RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
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
new file mode 100644
index 0000000..5bc73e4
--- /dev/null
+++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardScreen.kt
@@ -0,0 +1,146 @@
+package com.android.trisolarispms.ui.card
+
+import android.app.Activity
+import android.nfc.NfcAdapter
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.LottieConstants
+import com.airbnb.lottie.compose.animateLottieCompositionAsState
+import com.airbnb.lottie.compose.rememberLottieComposition
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun IssueTemporaryCardScreen(
+ propertyId: String,
+ roomId: String,
+ roomNumber: String?,
+ onBack: () -> Unit,
+ viewModel: IssueTemporaryCardViewModel = viewModel()
+) {
+ val state by viewModel.state.collectAsState()
+ val context = LocalContext.current
+ val activity = context as? Activity
+ val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
+ val composition = rememberLottieComposition(
+ LottieCompositionSpec.Asset("scan_nfc_animation.json")
+ )
+ val progress = animateLottieCompositionAsState(
+ composition = composition.value,
+ iterations = LottieConstants.IterateForever
+ )
+
+ DisposableEffect(activity, nfcAdapter, propertyId, roomId) {
+ if (activity != null && nfcAdapter != null) {
+ val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
+ val callback = NfcAdapter.ReaderCallback { tag ->
+ viewModel.onTagScanned(propertyId, roomId, tag)
+ }
+ nfcAdapter.enableReaderMode(activity, callback, flags, null)
+ onDispose {
+ nfcAdapter.disableReaderMode(activity)
+ }
+ } else {
+ onDispose { }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Issue Temporary Card") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors()
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = roomNumber?.let { "Room $it" } ?: "Room",
+ style = MaterialTheme.typography.titleLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "Hold the NFC card near the reader to issue a temporary card.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ if (nfcAdapter == null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "NFC not supported on this device.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ } else if (!nfcAdapter.isEnabled) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "NFC is off. Please enable NFC.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ state.status?.let {
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ state.error?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ LottieAnimation(
+ composition = composition.value,
+ progress = { progress.value },
+ modifier = Modifier.size(220.dp)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt
new file mode 100644
index 0000000..3786f8c
--- /dev/null
+++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardState.kt
@@ -0,0 +1,10 @@
+package com.android.trisolarispms.ui.card
+
+data class IssueTemporaryCardState(
+ val isLoading: Boolean = false,
+ val isProcessing: Boolean = false,
+ val error: String? = null,
+ val status: String? = null,
+ val lastCardId: String? = null,
+ val lastExpiresAt: String? = null
+)
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
new file mode 100644
index 0000000..84a8553
--- /dev/null
+++ b/app/src/main/java/com/android/trisolarispms/ui/card/IssueTemporaryCardViewModel.kt
@@ -0,0 +1,263 @@
+package com.android.trisolarispms.ui.card
+
+import android.nfc.Tag
+import android.nfc.tech.MifareClassic
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.android.trisolarispms.data.api.ApiClient
+import com.android.trisolarispms.data.api.model.CardPrepareRequest
+import com.android.trisolarispms.data.api.model.IssueCardRequest
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+import java.util.TimeZone
+
+class IssueTemporaryCardViewModel : ViewModel() {
+ private val _state = MutableStateFlow(IssueTemporaryCardState())
+ val state: StateFlow = _state
+
+ fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
+ if (propertyId.isBlank() || roomId.isBlank()) return
+ if (state.value.isProcessing) return
+ viewModelScope.launch(Dispatchers.IO) {
+ Log.d("IssueTempCard", "Tag detected. roomId=$roomId cardId=${tagIdToHex(tag.id)}")
+ _state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") }
+ try {
+ val api = ApiClient.create()
+ Log.d("IssueTempCard", "Calling prepare-temp")
+ val prepareResponse = api.prepareTemporaryRoomCard(
+ propertyId = propertyId,
+ roomId = roomId,
+ body = CardPrepareRequest(expiresAt = null)
+ )
+ Log.d("IssueTempCard", "Prepare-temp response=${prepareResponse.code()}")
+ val prepareBody = prepareResponse.body()
+ if (!prepareResponse.isSuccessful || prepareBody == null) {
+ _state.update {
+ it.copy(
+ isProcessing = false,
+ error = "Prepare failed: ${prepareResponse.code()}",
+ status = null
+ )
+ }
+ return@launch
+ }
+ val cardIndex = prepareBody.cardIndex
+ val keyHex = prepareBody.key
+ val timeHex = prepareBody.timeData
+ if (cardIndex == null || keyHex.isNullOrBlank() || timeHex.isNullOrBlank()) {
+ _state.update {
+ it.copy(
+ isProcessing = false,
+ error = "Prepare response missing data.",
+ status = null
+ )
+ }
+ return@launch
+ }
+
+ _state.update { it.copy(status = "Writing card...") }
+ val cardId = tagIdToHex(tag.id)
+ val writeResult = writeSector0(
+ tag = tag,
+ keyHex = keyHex,
+ timeHex = timeHex,
+ sector3Block0 = prepareBody.sector3Block0,
+ sector3Block1 = prepareBody.sector3Block1,
+ sector3Block2 = prepareBody.sector3Block2
+ )
+ if (writeResult != null) {
+ Log.d("IssueTempCard", "Write failed: $writeResult")
+ _state.update {
+ it.copy(
+ isProcessing = false,
+ error = writeResult,
+ status = null
+ )
+ }
+ return@launch
+ }
+
+ Log.d("IssueTempCard", "Calling issue-temp")
+ _state.update { it.copy(status = "Saving issued card...") }
+ val issueResponse = api.issueTemporaryRoomCard(
+ propertyId = propertyId,
+ roomId = roomId,
+ body = IssueCardRequest(
+ cardId = cardId,
+ cardIndex = cardIndex,
+ issuedAt = prepareBody.issuedAt
+ ?: nowAndExpiresIso(minutes = 7).first
+ )
+ )
+ Log.d("IssueTempCard", "Issue-temp response=${issueResponse.code()}")
+ if (issueResponse.isSuccessful) {
+ val body = issueResponse.body()
+ _state.update {
+ it.copy(
+ isProcessing = false,
+ error = null,
+ status = if (body != null) {
+ "Card issued. Expires in 7 minutes."
+ } else {
+ "Card issued (no response body)."
+ },
+ lastCardId = cardId,
+ lastExpiresAt = prepareBody.expiresAt
+ )
+ }
+ } else {
+ val errorText = issueResponse.errorBody()?.string()
+ Log.d("IssueTempCard", "Issue-temp error body=$errorText")
+ _state.update {
+ it.copy(
+ isProcessing = false,
+ error = when (issueResponse.code()) {
+ 409 -> "Active card already exists."
+ else -> "Issue failed: ${issueResponse.code()}"
+ },
+ status = null
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Log.d("IssueTempCard", "Issue failed: ${e.localizedMessage}", e)
+ _state.update {
+ it.copy(
+ isProcessing = false,
+ error = e.localizedMessage ?: "Issue failed",
+ status = null
+ )
+ }
+ }
+ }
+ }
+
+ private fun writeSector0(
+ tag: Tag,
+ keyHex: String,
+ timeHex: String,
+ sector3Block0: String?,
+ sector3Block1: String?,
+ sector3Block2: 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) ||
+ mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
+ val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) ||
+ mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
+ if (!authA && !authB) return "Card authentication failed (sector 0)."
+
+ val keyBytes = hexToBytes(keyHex)
+ val timeBytes = hexToBytes(timeHex)
+ val blockIndex = mifare.sectorToBlock(sectorIndex)
+ Log.d("IssueTempCard", "Writing blocks ${blockIndex + 1} and ${blockIndex + 2}")
+ mifare.writeBlock(blockIndex + 1, keyBytes)
+ mifare.writeBlock(blockIndex + 2, timeBytes)
+ val trailerWrite = writeTrailerBlocks(mifare)
+ if (trailerWrite != null) return trailerWrite
+ val sector3Write = writeSector3Blocks(
+ mifare = mifare,
+ block0 = sector3Block0,
+ block1 = sector3Block1,
+ block2 = sector3Block2
+ )
+ if (sector3Write != null) return sector3Write
+ null
+ } catch (e: Exception) {
+ e.localizedMessage ?: "Write failed."
+ } finally {
+ try {
+ mifare.close()
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ private fun writeTrailerBlocks(mifare: MifareClassic): String? {
+ val sectors = listOf(0, 1, 2, 14)
+ for (sector in sectors) {
+ val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
+ val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
+ if (!authA && !authB) return "Trailer auth failed (sector $sector)."
+ val blockIndex = mifare.sectorToBlock(sector) + 3
+ mifare.writeBlock(blockIndex, CARD_SECTOR_INFO)
+ }
+ return null
+ }
+
+ private fun writeSector3Blocks(
+ mifare: MifareClassic,
+ block0: String?,
+ block1: String?,
+ block2: String?
+ ): String? {
+ if (block0.isNullOrBlank() && block1.isNullOrBlank() && block2.isNullOrBlank()) return null
+ val sector = 3
+ val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
+ val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
+ if (!authA && !authB) return "Sector 3 authentication failed."
+ val blockIndex = mifare.sectorToBlock(sector)
+ block0?.let { mifare.writeBlock(blockIndex + 0, hexToBytes(it)) }
+ block1?.let { mifare.writeBlock(blockIndex + 1, hexToBytes(it)) }
+ block2?.let { mifare.writeBlock(blockIndex + 2, hexToBytes(it)) }
+ return null
+ }
+
+ private fun nowAndExpiresIso(minutes: Int): Pair {
+ val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
+ timeZone = TimeZone.getDefault()
+ }
+ val calendar = Calendar.getInstance()
+ val issuedAt = format.format(calendar.time)
+ calendar.add(Calendar.MINUTE, minutes)
+ val expiresAt = format.format(calendar.time)
+ return issuedAt to expiresAt
+ }
+
+ private fun tagIdToHex(tagId: ByteArray?): String {
+ if (tagId == null) return ""
+ return tagId.joinToString("") { byte -> "%02x".format(byte) }
+ }
+
+ private fun hexToBytes(hex: String): ByteArray {
+ val clean = hex.trim().replace(" ", "")
+ val len = clean.length
+ if (len % 2 != 0) return ByteArray(0)
+ val out = ByteArray(len / 2)
+ var i = 0
+ while (i < len) {
+ out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
+ i += 2
+ }
+ return out
+ }
+
+ companion object {
+ private val CARD_SECTOR_INFO = hexToBytesStatic("1AB23CD45EF6FF078069FFFFFFFFFFFF")
+
+ private fun hexToBytesStatic(hex: String): ByteArray {
+ val clean = hex.trim().replace(" ", "")
+ val len = clean.length
+ if (len % 2 != 0) return ByteArray(0)
+ val out = ByteArray(len / 2)
+ var i = 0
+ while (i < len) {
+ out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
+ i += 2
+ }
+ return out
+ }
+ }
+
+}
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 6e469fb..1c58026 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
@@ -1,6 +1,5 @@
package com.android.trisolarispms.ui.room
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -58,6 +57,7 @@ fun RoomsScreen(
onViewRoomTypes: () -> Unit,
canManageRooms: Boolean,
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
+ onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
viewModel: RoomListViewModel = viewModel(),
roomTypeListViewModel: RoomTypeListViewModel = viewModel()
) {
@@ -179,7 +179,7 @@ fun RoomsScreen(
.alpha(alpha)
.combinedClickable(
enabled = room.id != null,
- onClick = {},
+ onClick = { onIssueTemporaryCard(room) },
onLongClick = { onEditRoom(room) }
),
colors = CardDefaults.cardColors(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3ad08c6..1a259e5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -18,6 +18,7 @@ lifecycleViewModelCompose = "2.10.0"
firebaseAuthKtx = "24.0.1"
vectordrawable = "1.2.0"
coilCompose = "2.7.0"
+lottieCompose = "6.7.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -46,6 +47,7 @@ kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "ko
androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
+lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }