Add NFC temporary card issue flow

This commit is contained in:
androidlover5842
2026-01-28 07:28:19 +05:30
parent 6d7edc3022
commit be52b58165
12 changed files with 462 additions and 3 deletions

View File

@@ -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)

View File

@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<application
android:allowBackup="true"

File diff suppressed because one or more lines are too long

View File

@@ -18,6 +18,7 @@ import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen
import com.android.trisolarispms.ui.room.RoomsScreen
import com.android.trisolarispms.ui.card.IssueTemporaryCardScreen
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
@@ -114,6 +115,12 @@ class MainActivity : ComponentActivity() {
selectedRoom.value = it
roomFormKey.value++
route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "")
},
onIssueTemporaryCard = {
if (it.id != null) {
selectedRoom.value = it
route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id)
}
}
)
is AppRoute.RoomTypes -> 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,

View File

@@ -26,6 +26,20 @@ interface CardApi {
@Body body: IssueCardRequest
): Response<IssuedCardResponse>
@POST("properties/{propertyId}/rooms/{roomId}/cards/prepare-temp")
suspend fun prepareTemporaryRoomCard(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: CardPrepareRequest
): Response<CardPrepareResponse>
@POST("properties/{propertyId}/rooms/{roomId}/cards/temp")
suspend fun issueTemporaryRoomCard(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: IssueCardRequest
): Response<IssuedCardResponse>
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
suspend fun listCards(
@Path("propertyId") propertyId: String,

View File

@@ -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(

View File

@@ -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

View File

@@ -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)
)
}
}
}

View File

@@ -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
)

View File

@@ -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<IssueTemporaryCardState> = _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<String, String> {
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
}
}
}

View File

@@ -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(

View File

@@ -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" }