Add card info screen and revoke flow
This commit is contained in:
@@ -19,6 +19,7 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen
|
|||||||
import com.android.trisolarispms.ui.room.RoomFormScreen
|
import com.android.trisolarispms.ui.room.RoomFormScreen
|
||||||
import com.android.trisolarispms.ui.room.RoomsScreen
|
import com.android.trisolarispms.ui.room.RoomsScreen
|
||||||
import com.android.trisolarispms.ui.card.IssueTemporaryCardScreen
|
import com.android.trisolarispms.ui.card.IssueTemporaryCardScreen
|
||||||
|
import com.android.trisolarispms.ui.card.CardInfoScreen
|
||||||
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
|
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
|
||||||
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
|
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
|
||||||
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
|
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
|
||||||
@@ -110,6 +111,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
route.value = AppRoute.AddRoom(currentRoute.propertyId)
|
route.value = AppRoute.AddRoom(currentRoute.propertyId)
|
||||||
},
|
},
|
||||||
onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
|
onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
|
||||||
|
onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) },
|
||||||
canManageRooms = canManageProperty(currentRoute.propertyId),
|
canManageRooms = canManageProperty(currentRoute.propertyId),
|
||||||
onEditRoom = {
|
onEditRoom = {
|
||||||
selectedRoom.value = it
|
selectedRoom.value = it
|
||||||
@@ -210,6 +212,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
roomNumber = selectedRoom.value?.roomNumber?.toString(),
|
roomNumber = selectedRoom.value?.roomNumber?.toString(),
|
||||||
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
|
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
|
||||||
)
|
)
|
||||||
|
is AppRoute.CardInfo -> CardInfoScreen(
|
||||||
|
propertyId = currentRoute.propertyId,
|
||||||
|
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
|
||||||
|
)
|
||||||
is AppRoute.RoomImages -> RoomImagesScreen(
|
is AppRoute.RoomImages -> RoomImagesScreen(
|
||||||
propertyId = currentRoute.propertyId,
|
propertyId = currentRoute.propertyId,
|
||||||
roomId = currentRoute.roomId,
|
roomId = currentRoute.roomId,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.ActionResponse
|
|
||||||
import com.android.trisolarispms.data.api.model.CardPrepareRequest
|
import com.android.trisolarispms.data.api.model.CardPrepareRequest
|
||||||
import com.android.trisolarispms.data.api.model.CardPrepareResponse
|
import com.android.trisolarispms.data.api.model.CardPrepareResponse
|
||||||
import com.android.trisolarispms.data.api.model.IssueCardRequest
|
import com.android.trisolarispms.data.api.model.IssueCardRequest
|
||||||
import com.android.trisolarispms.data.api.model.IssuedCardResponse
|
import com.android.trisolarispms.data.api.model.IssuedCardResponse
|
||||||
|
import com.android.trisolarispms.data.api.model.RevokeCardResponse
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
@@ -50,5 +50,5 @@ interface CardApi {
|
|||||||
suspend fun revokeCard(
|
suspend fun revokeCard(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("cardId") cardId: String
|
@Path("cardId") cardId: String
|
||||||
): Response<ActionResponse>
|
): Response<RevokeCardResponse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ data class IssuedCardResponse(
|
|||||||
val issuedByUserId: String? = null,
|
val issuedByUserId: String? = null,
|
||||||
val revokedAt: String? = null
|
val revokedAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RevokeCardResponse(
|
||||||
|
val timeData: String? = null
|
||||||
|
)
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ data class RoomDto(
|
|||||||
val hasNfc: Boolean? = null,
|
val hasNfc: Boolean? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val maintenance: Boolean? = null,
|
val maintenance: Boolean? = null,
|
||||||
val notes: String? = null
|
val notes: String? = null,
|
||||||
|
val tempCardActive: Boolean? = null,
|
||||||
|
val tempCardExpiresAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomBoardDto(
|
data class RoomBoardDto(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ sealed interface AppRoute {
|
|||||||
data class AddRoom(val propertyId: String) : AppRoute
|
data class AddRoom(val propertyId: String) : AppRoute
|
||||||
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
|
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
|
||||||
data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute
|
data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute
|
||||||
|
data class CardInfo(val propertyId: String) : AppRoute
|
||||||
data class RoomTypes(val propertyId: String) : AppRoute
|
data class RoomTypes(val propertyId: String) : AppRoute
|
||||||
data class AddRoomType(val propertyId: String) : AppRoute
|
data class AddRoomType(val propertyId: String) : AppRoute
|
||||||
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
|
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package com.android.trisolarispms.ui.card
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.MifareClassic
|
||||||
|
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.AlertDialog
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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 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
|
||||||
|
import com.android.trisolarispms.data.api.ApiClient
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
fun CardInfoScreen(
|
||||||
|
propertyId: String,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as? Activity
|
||||||
|
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val lastTag = remember { mutableStateOf<Tag?>(null) }
|
||||||
|
val cardId = remember { mutableStateOf<String?>(null) }
|
||||||
|
val roomNumber = remember { mutableStateOf<String?>(null) }
|
||||||
|
val cardIndex = remember { mutableStateOf<String?>(null) }
|
||||||
|
val issuedBy = remember { mutableStateOf<String?>(null) }
|
||||||
|
val issuedById = remember { mutableStateOf<String?>(null) }
|
||||||
|
val issuedAt = remember { mutableStateOf<String?>(null) }
|
||||||
|
val expiresAt = remember { mutableStateOf<String?>(null) }
|
||||||
|
val expired = remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
val error = remember { mutableStateOf<String?>(null) }
|
||||||
|
val revokeError = remember { mutableStateOf<String?>(null) }
|
||||||
|
val revokeStatus = remember { mutableStateOf<String?>(null) }
|
||||||
|
val showRevokeConfirm = remember { mutableStateOf(false) }
|
||||||
|
val showRevokeSuccess = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val composition = rememberLottieComposition(
|
||||||
|
LottieCompositionSpec.Asset("scan_nfc_animation.json")
|
||||||
|
)
|
||||||
|
val progress = animateLottieCompositionAsState(
|
||||||
|
composition = composition.value,
|
||||||
|
iterations = LottieConstants.IterateForever
|
||||||
|
)
|
||||||
|
|
||||||
|
DisposableEffect(activity, nfcAdapter) {
|
||||||
|
if (activity != null && nfcAdapter != null) {
|
||||||
|
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||||
|
val callback = NfcAdapter.ReaderCallback { tag ->
|
||||||
|
lastTag.value = tag
|
||||||
|
handleTag(
|
||||||
|
tag = tag,
|
||||||
|
cardId = cardId,
|
||||||
|
roomNumber = roomNumber,
|
||||||
|
cardIndex = cardIndex,
|
||||||
|
issuedBy = issuedBy,
|
||||||
|
issuedById = issuedById,
|
||||||
|
issuedAt = issuedAt,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
expired = expired,
|
||||||
|
error = error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nfcAdapter.enableReaderMode(activity, callback, flags, null)
|
||||||
|
onDispose { nfcAdapter.disableReaderMode(activity) }
|
||||||
|
} else {
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Card Details") },
|
||||||
|
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.Top
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Tap a card on the back of the phone",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
LottieAnimation(
|
||||||
|
composition = composition.value,
|
||||||
|
progress = { progress.value },
|
||||||
|
modifier = Modifier.size(180.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
error.value?.let {
|
||||||
|
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
revokeError.value?.let {
|
||||||
|
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
revokeStatus.value?.let {
|
||||||
|
Text(text = it, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
infoRow("Card ID", cardId.value)
|
||||||
|
infoRow("Room", roomNumber.value)
|
||||||
|
infoRow("Card Index", cardIndex.value)
|
||||||
|
infoRow("Issued By", issuedBy.value)
|
||||||
|
infoRow("Issuer ID", issuedById.value)
|
||||||
|
infoRow("Issued At", issuedAt.value)
|
||||||
|
infoRow("Expires At", expiresAt.value)
|
||||||
|
expired.value?.let {
|
||||||
|
infoRow("Expired", if (it) "Yes" else "No")
|
||||||
|
}
|
||||||
|
if (!cardId.value.isNullOrBlank() && expired.value != true) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
TextButton(onClick = { showRevokeConfirm.value = true }) {
|
||||||
|
Text("Revoke Card")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRevokeConfirm.value) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRevokeConfirm.value = false },
|
||||||
|
title = { Text("Revoke card?") },
|
||||||
|
text = { Text("This will revoke the current card and make it inactive.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showRevokeConfirm.value = false
|
||||||
|
val currentIndex = cardIndex.value
|
||||||
|
val currentTag = lastTag.value
|
||||||
|
if (currentIndex.isNullOrBlank() || currentTag == null) {
|
||||||
|
revokeError.value = "Place the card on the phone to revoke."
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
val indexValue = currentIndex.toIntOrNull()
|
||||||
|
if (indexValue == null) {
|
||||||
|
revokeError.value = "Invalid card index."
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
revokeError.value = null
|
||||||
|
revokeStatus.value = "Revoking..."
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val api = ApiClient.create()
|
||||||
|
val response = api.revokeCard(propertyId, indexValue.toString())
|
||||||
|
val body = response.body()
|
||||||
|
if (response.isSuccessful && body?.timeData != null) {
|
||||||
|
val writeResult = writeRevokeTimeData(currentTag, body.timeData)
|
||||||
|
if (writeResult == null) {
|
||||||
|
revokeStatus.value = "Card revoked."
|
||||||
|
showRevokeSuccess.value = true
|
||||||
|
expired.value = true
|
||||||
|
cardId.value = null
|
||||||
|
roomNumber.value = null
|
||||||
|
cardIndex.value = null
|
||||||
|
issuedBy.value = null
|
||||||
|
issuedById.value = null
|
||||||
|
issuedAt.value = null
|
||||||
|
expiresAt.value = null
|
||||||
|
error.value = null
|
||||||
|
} else {
|
||||||
|
revokeStatus.value = null
|
||||||
|
revokeError.value = writeResult
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
revokeStatus.value = null
|
||||||
|
revokeError.value = "Revoke failed: ${response.code()}"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
revokeStatus.value = null
|
||||||
|
revokeError.value = e.localizedMessage ?: "Revoke failed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Revoke")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRevokeConfirm.value = false }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRevokeSuccess.value) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRevokeSuccess.value = false },
|
||||||
|
title = { Text("Card revoked") },
|
||||||
|
text = { Text("Card revoked successfully.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showRevokeSuccess.value = false
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun infoRow(label: String, value: String?) {
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "$label: $value",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleTag(
|
||||||
|
tag: Tag,
|
||||||
|
cardId: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
roomNumber: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
cardIndex: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
issuedBy: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
issuedById: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
issuedAt: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
expiresAt: androidx.compose.runtime.MutableState<String?>,
|
||||||
|
expired: androidx.compose.runtime.MutableState<Boolean?>,
|
||||||
|
error: androidx.compose.runtime.MutableState<String?>
|
||||||
|
) {
|
||||||
|
val mifare = MifareClassic.get(tag) ?: run {
|
||||||
|
error.value = "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) {
|
||||||
|
error.value = "Authentication failed for sector 0."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val key = getBlockHex(1, mifare)
|
||||||
|
val timeData = getBlockHex(2, mifare)
|
||||||
|
cardId.value = tag.id.joinToString("") { String.format("%02X", it) }
|
||||||
|
if (key.length >= 32) {
|
||||||
|
roomNumber.value = key.substring(26, 28)
|
||||||
|
cardIndex.value = key.substring(4, 10)
|
||||||
|
}
|
||||||
|
if (timeData.length >= 32) {
|
||||||
|
val startMin = timeData.substring(10, 12)
|
||||||
|
val startHour = timeData.substring(12, 14)
|
||||||
|
val startDate = timeData.substring(14, 16)
|
||||||
|
val startMonth = timeData.substring(16, 18)
|
||||||
|
val startYear = timeData.substring(18, 20)
|
||||||
|
issuedAt.value = "$startDate/$startMonth/$startYear $startHour:$startMin"
|
||||||
|
|
||||||
|
val endMin = timeData.substring(20, 22)
|
||||||
|
val endHour = timeData.substring(22, 24)
|
||||||
|
val endDate = timeData.substring(24, 26)
|
||||||
|
val endMonth = timeData.substring(26, 28)
|
||||||
|
val endYear = timeData.substring(28, 30)
|
||||||
|
expiresAt.value = "$endDate/$endMonth/$endYear $endHour:$endMin"
|
||||||
|
|
||||||
|
val checkoutTimeLong = Calendar.getInstance().apply {
|
||||||
|
set(Calendar.YEAR, 2000 + endYear.toInt())
|
||||||
|
set(Calendar.MONTH, endMonth.toInt() - 1)
|
||||||
|
set(Calendar.DAY_OF_MONTH, endDate.toInt())
|
||||||
|
set(Calendar.HOUR_OF_DAY, endHour.toInt())
|
||||||
|
set(Calendar.MINUTE, endMin.toInt())
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.time.time
|
||||||
|
expired.value = Date().time > checkoutTimeLong
|
||||||
|
}
|
||||||
|
val authC = mifare.authenticateSectorWithKeyA(3, MifareClassic.KEY_DEFAULT)
|
||||||
|
val authD = mifare.authenticateSectorWithKeyB(3, MifareClassic.KEY_DEFAULT)
|
||||||
|
if (authC || authD) {
|
||||||
|
val issuerBytes = mifare.readBlock(mifare.sectorToBlock(3) + 0)
|
||||||
|
val issuerIdBytes = mifare.readBlock(mifare.sectorToBlock(3) + 1)
|
||||||
|
issuedBy.value = issuerBytes.toString(Charsets.UTF_8).trim()
|
||||||
|
issuedById.value = issuerIdBytes.toString(Charsets.UTF_8).trim()
|
||||||
|
}
|
||||||
|
error.value = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error.value = e.localizedMessage ?: "Read failed."
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
mifare.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBlockHex(blockIndex: Int, mifareClassic: MifareClassic): String {
|
||||||
|
val data = mifareClassic.readBlock(blockIndex)
|
||||||
|
return data.joinToString("") { String.format("%02X", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -23,6 +25,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
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.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -48,6 +53,7 @@ fun IssueTemporaryCardScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
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 composition = rememberLottieComposition(
|
val composition = rememberLottieComposition(
|
||||||
LottieCompositionSpec.Asset("scan_nfc_animation.json")
|
LottieCompositionSpec.Asset("scan_nfc_animation.json")
|
||||||
)
|
)
|
||||||
@@ -56,6 +62,17 @@ fun IssueTemporaryCardScreen(
|
|||||||
iterations = LottieConstants.IterateForever
|
iterations = LottieConstants.IterateForever
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(propertyId, roomId) {
|
||||||
|
viewModel.reset()
|
||||||
|
showSuccessDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.lastCardId) {
|
||||||
|
if (!state.lastCardId.isNullOrBlank()) {
|
||||||
|
showSuccessDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(activity, nfcAdapter, propertyId, roomId) {
|
DisposableEffect(activity, nfcAdapter, propertyId, roomId) {
|
||||||
if (activity != null && nfcAdapter != null) {
|
if (activity != null && nfcAdapter != null) {
|
||||||
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||||
@@ -143,4 +160,27 @@ fun IssueTemporaryCardScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSuccessDialog.value) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showSuccessDialog.value = false
|
||||||
|
onBack()
|
||||||
|
},
|
||||||
|
title = { Text("Card issued") },
|
||||||
|
text = {
|
||||||
|
Text("Temporary card issued successfully.")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showSuccessDialog.value = false
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class IssueTemporaryCardViewModel : ViewModel() {
|
|||||||
private val _state = MutableStateFlow(IssueTemporaryCardState())
|
private val _state = MutableStateFlow(IssueTemporaryCardState())
|
||||||
val state: StateFlow<IssueTemporaryCardState> = _state
|
val state: StateFlow<IssueTemporaryCardState> = _state
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_state.value = IssueTemporaryCardState()
|
||||||
|
}
|
||||||
|
|
||||||
fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
|
fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
|
||||||
if (propertyId.isBlank() || roomId.isBlank()) return
|
if (propertyId.isBlank() || roomId.isBlank()) return
|
||||||
if (state.value.isProcessing) return
|
if (state.value.isProcessing) return
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Category
|
import androidx.compose.material.icons.filled.Category
|
||||||
|
import androidx.compose.material.icons.filled.CreditCard
|
||||||
import androidx.compose.material.icons.filled.Hotel
|
import androidx.compose.material.icons.filled.Hotel
|
||||||
import androidx.compose.material.icons.filled.Layers
|
import androidx.compose.material.icons.filled.Layers
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
@@ -55,6 +56,7 @@ fun RoomsScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAddRoom: () -> Unit,
|
onAddRoom: () -> Unit,
|
||||||
onViewRoomTypes: () -> Unit,
|
onViewRoomTypes: () -> Unit,
|
||||||
|
onViewCardInfo: () -> Unit,
|
||||||
canManageRooms: Boolean,
|
canManageRooms: Boolean,
|
||||||
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||||
onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||||
@@ -84,6 +86,9 @@ fun RoomsScreen(
|
|||||||
IconButton(onClick = onViewRoomTypes) {
|
IconButton(onClick = onViewRoomTypes) {
|
||||||
Icon(Icons.Default.Category, contentDescription = "Room Types")
|
Icon(Icons.Default.Category, contentDescription = "Room Types")
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onViewCardInfo) {
|
||||||
|
Icon(Icons.Default.CreditCard, contentDescription = "Card Info")
|
||||||
|
}
|
||||||
IconButton(onClick = onAddRoom) {
|
IconButton(onClick = onAddRoom) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add Room")
|
Icon(Icons.Default.Add, contentDescription = "Add Room")
|
||||||
}
|
}
|
||||||
@@ -179,7 +184,11 @@ fun RoomsScreen(
|
|||||||
.alpha(alpha)
|
.alpha(alpha)
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
enabled = room.id != null,
|
enabled = room.id != null,
|
||||||
onClick = { onIssueTemporaryCard(room) },
|
onClick = {
|
||||||
|
if (room.hasNfc != false) {
|
||||||
|
onIssueTemporaryCard(room)
|
||||||
|
}
|
||||||
|
},
|
||||||
onLongClick = { onEditRoom(room) }
|
onLongClick = { onEditRoom(room) }
|
||||||
),
|
),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
@@ -255,6 +264,14 @@ fun RoomsScreen(
|
|||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (room.tempCardActive == true) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Temp card active",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = androidx.compose.ui.graphics.Color(0xFF2E7D32)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user