Add card info screen and revoke flow

This commit is contained in:
androidlover5842
2026-01-28 18:33:48 +05:30
parent be52b58165
commit 65a41863e2
9 changed files with 460 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ 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.card.CardInfoScreen
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
@@ -110,6 +111,7 @@ class MainActivity : ComponentActivity() {
route.value = AppRoute.AddRoom(currentRoute.propertyId)
},
onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) },
canManageRooms = canManageProperty(currentRoute.propertyId),
onEditRoom = {
selectedRoom.value = it
@@ -210,6 +212,10 @@ class MainActivity : ComponentActivity() {
roomNumber = selectedRoom.value?.roomNumber?.toString(),
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(
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,

View File

@@ -1,10 +1,10 @@
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.CardPrepareResponse
import com.android.trisolarispms.data.api.model.IssueCardRequest
import com.android.trisolarispms.data.api.model.IssuedCardResponse
import com.android.trisolarispms.data.api.model.RevokeCardResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
@@ -50,5 +50,5 @@ interface CardApi {
suspend fun revokeCard(
@Path("propertyId") propertyId: String,
@Path("cardId") cardId: String
): Response<ActionResponse>
): Response<RevokeCardResponse>
}

View File

@@ -34,3 +34,7 @@ data class IssuedCardResponse(
val issuedByUserId: String? = null,
val revokedAt: String? = null
)
data class RevokeCardResponse(
val timeData: String? = null
)

View File

@@ -30,7 +30,9 @@ data class RoomDto(
val hasNfc: Boolean? = null,
val active: 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(

View File

@@ -8,6 +8,7 @@ sealed interface 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 CardInfo(val propertyId: 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,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
}

View File

@@ -16,6 +16,8 @@ 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
@@ -23,6 +25,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
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.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -48,6 +53,7 @@ fun IssueTemporaryCardScreen(
val context = LocalContext.current
val activity = context as? Activity
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
val showSuccessDialog = remember { mutableStateOf(false) }
val composition = rememberLottieComposition(
LottieCompositionSpec.Asset("scan_nfc_animation.json")
)
@@ -56,6 +62,17 @@ fun IssueTemporaryCardScreen(
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) {
if (activity != null && nfcAdapter != null) {
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")
}
}
)
}
}

View File

@@ -23,6 +23,10 @@ class IssueTemporaryCardViewModel : ViewModel() {
private val _state = MutableStateFlow(IssueTemporaryCardState())
val state: StateFlow<IssueTemporaryCardState> = _state
fun reset() {
_state.value = IssueTemporaryCardState()
}
fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
if (propertyId.isBlank() || roomId.isBlank()) return
if (state.value.isProcessing) return

View File

@@ -18,6 +18,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
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.Layers
import androidx.compose.material.icons.filled.Groups
@@ -55,6 +56,7 @@ fun RoomsScreen(
onBack: () -> Unit,
onAddRoom: () -> Unit,
onViewRoomTypes: () -> Unit,
onViewCardInfo: () -> Unit,
canManageRooms: Boolean,
onEditRoom: (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) {
Icon(Icons.Default.Category, contentDescription = "Room Types")
}
IconButton(onClick = onViewCardInfo) {
Icon(Icons.Default.CreditCard, contentDescription = "Card Info")
}
IconButton(onClick = onAddRoom) {
Icon(Icons.Default.Add, contentDescription = "Add Room")
}
@@ -179,7 +184,11 @@ fun RoomsScreen(
.alpha(alpha)
.combinedClickable(
enabled = room.id != null,
onClick = { onIssueTemporaryCard(room) },
onClick = {
if (room.hasNfc != false) {
onIssueTemporaryCard(room)
}
},
onLongClick = { onEditRoom(room) }
),
colors = CardDefaults.cardColors(
@@ -255,6 +264,14 @@ fun RoomsScreen(
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)
)
}
}
}
}