Add NFC temporary card issue flow
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
app/src/main/assets/scan_nfc_animation.json
Normal file
1
app/src/main/assets/scan_nfc_animation.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user