Compare commits
3 Commits
d8a40e4c9a
...
be52b58165
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be52b58165 | ||
|
|
6d7edc3022 | ||
|
|
d2d60b5074 |
@@ -56,6 +56,7 @@ dependencies {
|
|||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.lottie.compose)
|
||||||
implementation(platform(libs.firebase.bom))
|
implementation(platform(libs.firebase.bom))
|
||||||
implementation(libs.firebase.auth.ktx)
|
implementation(libs.firebase.auth.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
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.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.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
|
||||||
@@ -114,6 +115,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
selectedRoom.value = it
|
selectedRoom.value = it
|
||||||
roomFormKey.value++
|
roomFormKey.value++
|
||||||
route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "")
|
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(
|
is AppRoute.RoomTypes -> RoomTypesScreen(
|
||||||
@@ -197,6 +204,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId)
|
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(
|
is AppRoute.RoomImages -> RoomImagesScreen(
|
||||||
propertyId = currentRoute.propertyId,
|
propertyId = currentRoute.propertyId,
|
||||||
roomId = currentRoute.roomId,
|
roomId = currentRoute.roomId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
|
import okhttp3.Authenticator
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -18,7 +19,7 @@ object ApiClient {
|
|||||||
val authInterceptor = Interceptor { chain ->
|
val authInterceptor = Interceptor { chain ->
|
||||||
val original = chain.request()
|
val original = chain.request()
|
||||||
val token = try {
|
val token = try {
|
||||||
kotlinx.coroutines.runBlocking { tokenProvider.token() }
|
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -32,12 +33,28 @@ object ApiClient {
|
|||||||
chain.proceed(request)
|
chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val authenticator = Authenticator { _, response ->
|
||||||
|
if (response.code != 401) return@Authenticator null
|
||||||
|
if (response.request.header("X-Auth-Retry") == "true") return@Authenticator null
|
||||||
|
val newToken = try {
|
||||||
|
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = true) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (newToken.isNullOrBlank()) return@Authenticator null
|
||||||
|
response.request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $newToken")
|
||||||
|
.header("X-Auth-Retry", "true")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
val logging = HttpLoggingInterceptor().apply {
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = OkHttpClient.Builder()
|
val client = OkHttpClient.Builder()
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
|
.authenticator(authenticator)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
interface AuthTokenProvider {
|
interface AuthTokenProvider {
|
||||||
suspend fun token(): String?
|
suspend fun token(forceRefresh: Boolean = false): String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ interface CardApi {
|
|||||||
@Body body: IssueCardRequest
|
@Body body: IssueCardRequest
|
||||||
): Response<IssuedCardResponse>
|
): 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")
|
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
||||||
suspend fun listCards(
|
suspend fun listCards(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import kotlinx.coroutines.tasks.await
|
|||||||
class FirebaseAuthTokenProvider(
|
class FirebaseAuthTokenProvider(
|
||||||
private val auth: FirebaseAuth
|
private val auth: FirebaseAuth
|
||||||
) : AuthTokenProvider {
|
) : AuthTokenProvider {
|
||||||
override suspend fun token(): String? {
|
override suspend fun token(forceRefresh: Boolean): String? {
|
||||||
val user = auth.currentUser ?: return null
|
val user = auth.currentUser ?: return null
|
||||||
return user.getIdToken(false).await().token
|
return user.getIdToken(forceRefresh).await().token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,16 @@ interface RoomApi {
|
|||||||
@Query("from") from: String,
|
@Query("from") from: String,
|
||||||
@Query("to") to: String
|
@Query("to") to: String
|
||||||
): Response<List<RoomAvailabilityRangeResponse>>
|
): Response<List<RoomAvailabilityRangeResponse>>
|
||||||
|
|
||||||
|
@GET("properties/{propertyId}/rooms/available")
|
||||||
|
suspend fun listAvailableRooms(
|
||||||
|
@Path("propertyId") propertyId: String
|
||||||
|
): Response<List<RoomDto>>
|
||||||
|
|
||||||
|
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
|
||||||
|
suspend fun listRoomsByType(
|
||||||
|
@Path("propertyId") propertyId: String,
|
||||||
|
@Path("roomTypeCode") roomTypeCode: String,
|
||||||
|
@Query("availableOnly") availableOnly: Boolean? = null
|
||||||
|
): Response<List<RoomDto>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ data class CardPrepareResponse(
|
|||||||
val cardIndex: Int? = null,
|
val cardIndex: Int? = null,
|
||||||
val key: String? = null,
|
val key: String? = null,
|
||||||
val timeData: String? = null,
|
val timeData: String? = null,
|
||||||
|
val sector3Block0: String? = null,
|
||||||
|
val sector3Block1: String? = null,
|
||||||
|
val sector3Block2: String? = null,
|
||||||
val issuedAt: String? = null,
|
val issuedAt: String? = null,
|
||||||
val expiresAt: String? = null
|
val expiresAt: String? = null
|
||||||
)
|
)
|
||||||
@@ -16,7 +19,7 @@ data class IssueCardRequest(
|
|||||||
val cardId: String,
|
val cardId: String,
|
||||||
val cardIndex: Int,
|
val cardIndex: Int,
|
||||||
val issuedAt: String? = null,
|
val issuedAt: String? = null,
|
||||||
val expiresAt: String
|
val expiresAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IssuedCardResponse(
|
data class IssuedCardResponse(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ data class RoomDto(
|
|||||||
val roomNumber: Int? = null,
|
val roomNumber: Int? = null,
|
||||||
val roomTypeCode: String? = null,
|
val roomTypeCode: String? = null,
|
||||||
val roomTypeName: String? = null,
|
val roomTypeName: String? = null,
|
||||||
|
val maxOccupancy: Int? = null,
|
||||||
val floor: Int? = null,
|
val floor: Int? = null,
|
||||||
val hasNfc: Boolean? = null,
|
val hasNfc: Boolean? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ sealed interface AppRoute {
|
|||||||
data class Rooms(val propertyId: String) : AppRoute
|
data class Rooms(val propertyId: String) : 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 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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@ import com.android.trisolarispms.data.api.model.RoomDto
|
|||||||
data class RoomListState(
|
data class RoomListState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val rooms: List<RoomDto> = emptyList()
|
val rooms: List<RoomDto> = emptyList(),
|
||||||
|
val showAll: Boolean = false,
|
||||||
|
val roomTypeCode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,13 +12,24 @@ class RoomListViewModel : ViewModel() {
|
|||||||
private val _state = MutableStateFlow(RoomListState())
|
private val _state = MutableStateFlow(RoomListState())
|
||||||
val state: StateFlow<RoomListState> = _state
|
val state: StateFlow<RoomListState> = _state
|
||||||
|
|
||||||
fun load(propertyId: String) {
|
fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) {
|
||||||
if (propertyId.isBlank()) return
|
if (propertyId.isBlank()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) }
|
||||||
try {
|
try {
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.listRooms(propertyId)
|
val trimmedCode = roomTypeCode?.trim().orEmpty()
|
||||||
|
val response = if (trimmedCode.isNotBlank()) {
|
||||||
|
api.listRoomsByType(
|
||||||
|
propertyId = propertyId,
|
||||||
|
roomTypeCode = trimmedCode,
|
||||||
|
availableOnly = if (showAll) false else true
|
||||||
|
)
|
||||||
|
} else if (showAll) {
|
||||||
|
api.listRooms(propertyId)
|
||||||
|
} else {
|
||||||
|
api.listAvailableRooms(propertyId)
|
||||||
|
}
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -35,4 +46,12 @@ class RoomListViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowAll(propertyId: String, showAll: Boolean) {
|
||||||
|
load(propertyId, showAll, _state.value.roomTypeCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRoomTypeFilter(propertyId: String, roomTypeCode: String?) {
|
||||||
|
load(propertyId, _state.value.showAll, roomTypeCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
package com.android.trisolarispms.ui.room
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
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.Hotel
|
||||||
|
import androidx.compose.material.icons.filled.Layers
|
||||||
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -21,6 +32,9 @@ import androidx.compose.material3.Scaffold
|
|||||||
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
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -29,6 +43,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -39,12 +57,17 @@ fun RoomsScreen(
|
|||||||
onViewRoomTypes: () -> Unit,
|
onViewRoomTypes: () -> Unit,
|
||||||
canManageRooms: Boolean,
|
canManageRooms: Boolean,
|
||||||
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||||
viewModel: RoomListViewModel = viewModel()
|
onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||||
|
viewModel: RoomListViewModel = viewModel(),
|
||||||
|
roomTypeListViewModel: RoomTypeListViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val roomTypeState by roomTypeListViewModel.state.collectAsState()
|
||||||
|
val showTypeMenu = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(propertyId) {
|
LaunchedEffect(propertyId) {
|
||||||
viewModel.load(propertyId)
|
viewModel.load(propertyId, showAll = false)
|
||||||
|
roomTypeListViewModel.load(propertyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -86,29 +109,153 @@ fun RoomsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
if (!state.isLoading && state.error == null) {
|
if (!state.isLoading && state.error == null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = state.showAll,
|
||||||
|
onClick = { viewModel.setShowAll(propertyId, !state.showAll) },
|
||||||
|
label = { Text(if (state.showAll) "Showing all" else "Showing available") }
|
||||||
|
)
|
||||||
|
Box {
|
||||||
|
val selectedType = state.roomTypeCode
|
||||||
|
FilterChip(
|
||||||
|
selected = !selectedType.isNullOrBlank(),
|
||||||
|
onClick = { showTypeMenu.value = true },
|
||||||
|
label = { Text("Type: ${selectedType ?: "All"}") }
|
||||||
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showTypeMenu.value,
|
||||||
|
onDismissRequest = { showTypeMenu.value = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("All types") },
|
||||||
|
onClick = {
|
||||||
|
showTypeMenu.value = false
|
||||||
|
viewModel.setRoomTypeFilter(propertyId, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
roomTypeState.items.forEach { type ->
|
||||||
|
val code = type.code ?: return@forEach
|
||||||
|
val label = type.name?.let { "$code • $it" } ?: code
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(label) },
|
||||||
|
onClick = {
|
||||||
|
showTypeMenu.value = false
|
||||||
|
viewModel.setRoomTypeFilter(propertyId, code)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val availableCount = state.rooms.size
|
||||||
|
Text(
|
||||||
|
text = "Available rooms: $availableCount",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
if (state.rooms.isEmpty()) {
|
if (state.rooms.isEmpty()) {
|
||||||
Text(text = "No rooms found")
|
Text(text = "No rooms found")
|
||||||
} else {
|
} else {
|
||||||
state.rooms.forEach { room ->
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
items(state.rooms) { room ->
|
||||||
val label = room.roomNumber?.toString() ?: "-"
|
val label = room.roomNumber?.toString() ?: "-"
|
||||||
val details = listOfNotNull(
|
val category = room.roomTypeName ?: room.roomTypeCode ?: "Unassigned"
|
||||||
room.floor?.let { "Floor $it" },
|
val floorLabel = room.floor?.let { "Floor $it" }.orEmpty()
|
||||||
room.roomTypeName ?: room.roomTypeCode
|
|
||||||
).joinToString(" • ")
|
|
||||||
val isDimmed = (room.active == false) || (room.maintenance == true)
|
val isDimmed = (room.active == false) || (room.maintenance == true)
|
||||||
val alpha = if (isDimmed) 0.5f else 1f
|
val alpha = if (isDimmed) 0.5f else 1f
|
||||||
Column(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.alpha(alpha)
|
.alpha(alpha)
|
||||||
.clickable(enabled = room.id != null) {
|
.combinedClickable(
|
||||||
onEditRoom(room)
|
enabled = room.id != null,
|
||||||
}
|
onClick = { onIssueTemporaryCard(room) },
|
||||||
.padding(vertical = 10.dp)
|
onLongClick = { onEditRoom(room) }
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Hotel,
|
||||||
|
contentDescription = "Room",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||||
if (details.isNotBlank()) {
|
}
|
||||||
Text(text = details, style = MaterialTheme.typography.bodySmall)
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Category,
|
||||||
|
contentDescription = "Category",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val maxOcc = room.maxOccupancy
|
||||||
|
if (maxOcc != null && maxOcc > 0) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Groups,
|
||||||
|
contentDescription = "Max occupancy",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Max $maxOcc",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floorLabel.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Layers,
|
||||||
|
contentDescription = "Floor",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = floorLabel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (room.maintenance == true) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Maintenance",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,7 @@
|
|||||||
package com.android.trisolarispms.ui.roomtype
|
package com.android.trisolarispms.ui.roomtype
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Done
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -41,122 +13,16 @@ fun AddRoomTypeScreen(
|
|||||||
viewModel: RoomTypeFormViewModel = viewModel(),
|
viewModel: RoomTypeFormViewModel = viewModel(),
|
||||||
amenityViewModel: AmenityListViewModel = viewModel()
|
amenityViewModel: AmenityListViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
val amenityState by amenityViewModel.state.collectAsState()
|
viewModel.resetForm()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
amenityViewModel.load()
|
|
||||||
}
|
}
|
||||||
|
RoomTypeFormScreen(
|
||||||
Scaffold(
|
title = "Add Room Type",
|
||||||
topBar = {
|
propertyId = propertyId,
|
||||||
TopAppBar(
|
onBack = onBack,
|
||||||
title = { Text("Add Room Type") },
|
onSave = { viewModel.submit(propertyId, onSave) },
|
||||||
navigationIcon = {
|
codeEditable = true,
|
||||||
IconButton(onClick = onBack) {
|
viewModel = viewModel,
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
amenityViewModel = amenityViewModel
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { viewModel.submit(propertyId, onSave) }) {
|
|
||||||
Icon(Icons.Default.Done, contentDescription = "Save")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors()
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.code,
|
|
||||||
onValueChange = viewModel::onCodeChange,
|
|
||||||
label = { Text("Code") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.name,
|
|
||||||
onValueChange = viewModel::onNameChange,
|
|
||||||
label = { Text("Name") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.baseOccupancy,
|
|
||||||
onValueChange = viewModel::onBaseOccupancyChange,
|
|
||||||
label = { Text("Base Occupancy") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.maxOccupancy,
|
|
||||||
onValueChange = viewModel::onMaxOccupancyChange,
|
|
||||||
label = { Text("Max Occupancy") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.sqFeet,
|
|
||||||
onValueChange = viewModel::onSqFeetChange,
|
|
||||||
label = { Text("Sq Ft") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.bathroomSqFeet,
|
|
||||||
onValueChange = viewModel::onBathroomSqFeetChange,
|
|
||||||
label = { Text("Bathroom Sq Ft") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
|
|
||||||
if (amenityState.items.isEmpty()) {
|
|
||||||
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
|
|
||||||
} else {
|
|
||||||
amenityState.items.forEach { amenity ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight()
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Checkbox(
|
|
||||||
checked = state.amenityIds.contains(amenity.id),
|
|
||||||
onCheckedChange = { amenity.id?.let { viewModel.onAmenityToggle(it) } }
|
|
||||||
)
|
|
||||||
Text(text = amenity.name ?: "", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.otaAliases,
|
|
||||||
onValueChange = viewModel::onAliasesChange,
|
|
||||||
label = { Text("OTA Aliases (comma separated)") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
state.error?.let {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,29 @@
|
|||||||
package com.android.trisolarispms.ui.roomtype
|
package com.android.trisolarispms.ui.roomtype
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.filled.DragIndicator
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Done
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.android.trisolarispms.data.api.ApiConstants
|
|
||||||
import com.android.trisolarispms.data.api.model.RoomTypeDto
|
import com.android.trisolarispms.data.api.model.RoomTypeDto
|
||||||
import com.android.trisolarispms.ui.roomimage.RoomImageGridItem
|
import com.android.trisolarispms.ui.roomimage.ImagePreviewDialog
|
||||||
import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid
|
import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid
|
||||||
|
import com.android.trisolarispms.ui.roomimage.RoomImageGridItem
|
||||||
import com.android.trisolarispms.ui.roomimage.RoomImageViewModel
|
import com.android.trisolarispms.ui.roomimage.RoomImageViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -66,16 +37,14 @@ fun EditRoomTypeScreen(
|
|||||||
amenityViewModel: AmenityListViewModel = viewModel(),
|
amenityViewModel: AmenityListViewModel = viewModel(),
|
||||||
roomImageViewModel: RoomImageViewModel = viewModel()
|
roomImageViewModel: RoomImageViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
|
||||||
val amenityState by amenityViewModel.state.collectAsState()
|
|
||||||
val roomImageState by roomImageViewModel.state.collectAsState()
|
val roomImageState by roomImageViewModel.state.collectAsState()
|
||||||
val showDeleteConfirm = remember { mutableStateOf(false) }
|
val showDeleteConfirm = remember { mutableStateOf(false) }
|
||||||
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
|
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
|
||||||
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
|
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
val previewUrl = remember { mutableStateOf<String?>(null) }
|
val previewUrl = remember { mutableStateOf<String?>(null) }
|
||||||
val showAmenityDialog = remember { mutableStateOf(false) }
|
|
||||||
val amenitySearch = remember { mutableStateOf("") }
|
val gridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) {
|
val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) {
|
||||||
{
|
{
|
||||||
val code = roomType.code.orEmpty()
|
val code = roomType.code.orEmpty()
|
||||||
@@ -101,9 +70,6 @@ fun EditRoomTypeScreen(
|
|||||||
LaunchedEffect(roomType.id) {
|
LaunchedEffect(roomType.id) {
|
||||||
viewModel.setRoomType(roomType)
|
viewModel.setRoomType(roomType)
|
||||||
}
|
}
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
amenityViewModel.load()
|
|
||||||
}
|
|
||||||
LaunchedEffect(roomType.code) {
|
LaunchedEffect(roomType.code) {
|
||||||
val code = roomType.code.orEmpty()
|
val code = roomType.code.orEmpty()
|
||||||
if (code.isNotBlank()) {
|
if (code.isNotBlank()) {
|
||||||
@@ -115,121 +81,22 @@ fun EditRoomTypeScreen(
|
|||||||
originalOrderIds.value = roomImageState.images.mapNotNull { it.id }
|
originalOrderIds.value = roomImageState.images.mapNotNull { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
RoomTypeFormScreen(
|
||||||
topBar = {
|
title = "Modify Room Type",
|
||||||
TopAppBar(
|
propertyId = propertyId,
|
||||||
title = { Text("Modify Room Type") },
|
onBack = onBack,
|
||||||
navigationIcon = {
|
onSave = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) },
|
||||||
IconButton(onClick = onBack) {
|
codeEditable = false,
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
onDelete = { if (!roomType.id.isNullOrBlank()) showDeleteConfirm.value = true },
|
||||||
}
|
viewModel = viewModel,
|
||||||
},
|
amenityViewModel = amenityViewModel
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) }) {
|
|
||||||
Icon(Icons.Default.Done, contentDescription = "Save")
|
|
||||||
}
|
|
||||||
if (!roomType.id.isNullOrBlank()) {
|
|
||||||
IconButton(onClick = { showDeleteConfirm.value = true }) {
|
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Delete Room Type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(24.dp)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
|
||||||
value = state.code,
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text("Code") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.name,
|
|
||||||
onValueChange = viewModel::onNameChange,
|
|
||||||
label = { Text("Name") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.baseOccupancy,
|
|
||||||
onValueChange = viewModel::onBaseOccupancyChange,
|
|
||||||
label = { Text("Base Occupancy") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.maxOccupancy,
|
|
||||||
onValueChange = viewModel::onMaxOccupancyChange,
|
|
||||||
label = { Text("Max Occupancy") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.sqFeet,
|
|
||||||
onValueChange = viewModel::onSqFeetChange,
|
|
||||||
label = { Text("Sq Ft") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.bathroomSqFeet,
|
|
||||||
onValueChange = viewModel::onBathroomSqFeetChange,
|
|
||||||
label = { Text("Bathroom Sq Ft") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.otaAliases,
|
|
||||||
onValueChange = viewModel::onAliasesChange,
|
|
||||||
label = { Text("OTA Aliases (comma separated)") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
|
Text(text = "Room Type Images", style = androidx.compose.material3.MaterialTheme.typography.titleSmall)
|
||||||
Button(onClick = { showAmenityDialog.value = true }) {
|
|
||||||
Text("Edit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val selectedCount = state.amenityIds.size
|
|
||||||
Text(
|
|
||||||
text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(text = "Room Type Images", style = MaterialTheme.typography.titleSmall)
|
|
||||||
val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value
|
val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value
|
||||||
if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
|
if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
|
||||||
Button(onClick = saveRoomTypeImageOrder) {
|
Button(onClick = saveRoomTypeImageOrder) {
|
||||||
@@ -241,7 +108,7 @@ fun EditRoomTypeScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
androidx.compose.material3.CircularProgressIndicator()
|
androidx.compose.material3.CircularProgressIndicator()
|
||||||
} else if (orderedImages.value.isEmpty()) {
|
} else if (orderedImages.value.isEmpty()) {
|
||||||
Text(text = "No images yet", style = MaterialTheme.typography.bodySmall)
|
Text(text = "No images yet", style = androidx.compose.material3.MaterialTheme.typography.bodySmall)
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
ReorderableImageGrid(
|
ReorderableImageGrid(
|
||||||
@@ -265,11 +132,6 @@ fun EditRoomTypeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.error?.let {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDeleteConfirm.value && !roomType.id.isNullOrBlank()) {
|
if (showDeleteConfirm.value && !roomType.id.isNullOrBlank()) {
|
||||||
@@ -293,90 +155,9 @@ fun EditRoomTypeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAmenityDialog.value) {
|
|
||||||
val query = amenitySearch.value.trim()
|
|
||||||
val filtered = if (query.isBlank()) {
|
|
||||||
amenityState.items
|
|
||||||
} else {
|
|
||||||
amenityState.items.filter {
|
|
||||||
val name = it.name.orEmpty()
|
|
||||||
val category = it.category.orEmpty()
|
|
||||||
name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showAmenityDialog.value = false },
|
|
||||||
title = { Text("Select Amenities") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = amenitySearch.value,
|
|
||||||
onValueChange = { amenitySearch.value = it },
|
|
||||||
label = { Text("Search") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
if (amenityState.items.isEmpty()) {
|
|
||||||
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
|
|
||||||
} else if (filtered.isEmpty()) {
|
|
||||||
Text(text = "No matches", style = MaterialTheme.typography.bodySmall)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(320.dp)
|
|
||||||
) {
|
|
||||||
items(filtered) { amenity ->
|
|
||||||
val id = amenity.id ?: return@items
|
|
||||||
val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png")
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Checkbox(
|
|
||||||
checked = state.amenityIds.contains(id),
|
|
||||||
onCheckedChange = { viewModel.onAmenityToggle(id) }
|
|
||||||
)
|
|
||||||
if (iconKey.isNotBlank()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png",
|
|
||||||
contentDescription = amenity.name ?: iconKey,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = amenity.name.orEmpty())
|
|
||||||
if (!amenity.category.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = amenity.category.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showAmenityDialog.value = false }) {
|
|
||||||
Text("Done")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val preview = previewUrl.value
|
val preview = previewUrl.value
|
||||||
if (!preview.isNullOrBlank()) {
|
if (!preview.isNullOrBlank()) {
|
||||||
com.android.trisolarispms.ui.roomimage.ImagePreviewDialog(
|
ImagePreviewDialog(
|
||||||
imageUrl = preview,
|
imageUrl = preview,
|
||||||
onDismiss = { previewUrl.value = null }
|
onDismiss = { previewUrl.value = null }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package com.android.trisolarispms.ui.roomtype
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Done
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.android.trisolarispms.data.api.ApiConstants
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
fun RoomTypeFormScreen(
|
||||||
|
title: String,
|
||||||
|
propertyId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
codeEditable: Boolean,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
viewModel: RoomTypeFormViewModel = viewModel(),
|
||||||
|
amenityViewModel: AmenityListViewModel = viewModel(),
|
||||||
|
extraContent: @Composable (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val amenityState by amenityViewModel.state.collectAsState()
|
||||||
|
val showAmenityDialog = remember { mutableStateOf(false) }
|
||||||
|
val amenitySearch = remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
amenityViewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSave) {
|
||||||
|
Icon(Icons.Default.Done, contentDescription = "Save")
|
||||||
|
}
|
||||||
|
if (onDelete != null) {
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Delete Room Type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.code,
|
||||||
|
onValueChange = { value ->
|
||||||
|
if (codeEditable) {
|
||||||
|
viewModel.onCodeChange(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readOnly = !codeEditable,
|
||||||
|
label = { Text("Code") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = viewModel::onNameChange,
|
||||||
|
label = { Text("Name") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.baseOccupancy,
|
||||||
|
onValueChange = viewModel::onBaseOccupancyChange,
|
||||||
|
label = { Text("Base Occupancy") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.maxOccupancy,
|
||||||
|
onValueChange = viewModel::onMaxOccupancyChange,
|
||||||
|
label = { Text("Max Occupancy") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.sqFeet,
|
||||||
|
onValueChange = viewModel::onSqFeetChange,
|
||||||
|
label = { Text("Sq Ft") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.bathroomSqFeet,
|
||||||
|
onValueChange = viewModel::onBathroomSqFeetChange,
|
||||||
|
label = { Text("Bathroom Sq Ft") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Button(onClick = { showAmenityDialog.value = true }) {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val selectedCount = state.amenityIds.size
|
||||||
|
Text(
|
||||||
|
text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.otaAliases,
|
||||||
|
onValueChange = viewModel::onAliasesChange,
|
||||||
|
label = { Text("OTA Aliases (comma separated)") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
extraContent?.let {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
it()
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAmenityDialog.value) {
|
||||||
|
val query = amenitySearch.value.trim()
|
||||||
|
val filtered = if (query.isBlank()) {
|
||||||
|
amenityState.items
|
||||||
|
} else {
|
||||||
|
amenityState.items.filter {
|
||||||
|
val name = it.name.orEmpty()
|
||||||
|
val category = it.category.orEmpty()
|
||||||
|
name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showAmenityDialog.value = false },
|
||||||
|
title = { Text("Select Amenities") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amenitySearch.value,
|
||||||
|
onValueChange = { amenitySearch.value = it },
|
||||||
|
label = { Text("Search") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
if (amenityState.items.isEmpty()) {
|
||||||
|
Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else if (filtered.isEmpty()) {
|
||||||
|
Text(text = "No matches", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(320.dp)
|
||||||
|
) {
|
||||||
|
items(filtered) { amenity ->
|
||||||
|
val id = amenity.id ?: return@items
|
||||||
|
val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png")
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.amenityIds.contains(id),
|
||||||
|
onCheckedChange = { viewModel.onAmenityToggle(id) }
|
||||||
|
)
|
||||||
|
if (iconKey.isNotBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png",
|
||||||
|
contentDescription = amenity.name ?: iconKey,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = amenity.name.orEmpty())
|
||||||
|
if (!amenity.category.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = amenity.category.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showAmenityDialog.value = false }) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ class RoomTypeFormViewModel : ViewModel() {
|
|||||||
private val _state = MutableStateFlow(RoomTypeFormState())
|
private val _state = MutableStateFlow(RoomTypeFormState())
|
||||||
val state: StateFlow<RoomTypeFormState> = _state
|
val state: StateFlow<RoomTypeFormState> = _state
|
||||||
|
|
||||||
|
fun resetForm() {
|
||||||
|
_state.update { RoomTypeFormState() }
|
||||||
|
}
|
||||||
|
|
||||||
fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) {
|
fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ data class RoomTypeListState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val items: List<RoomTypeDto> = emptyList(),
|
val items: List<RoomTypeDto> = emptyList(),
|
||||||
val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap()
|
val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap(),
|
||||||
|
val availableCountByTypeCode: Map<String, Int> = emptyMap()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class RoomTypeListViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadRoomTypeImages(propertyId, items)
|
loadRoomTypeImages(propertyId, items)
|
||||||
|
loadRoomTypeAvailableCounts(propertyId, items)
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
||||||
}
|
}
|
||||||
@@ -59,4 +60,30 @@ class RoomTypeListViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadRoomTypeAvailableCounts(propertyId: String, items: List<com.android.trisolarispms.data.api.model.RoomTypeDto>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val api = ApiClient.create()
|
||||||
|
val updates = mutableMapOf<String, Int>()
|
||||||
|
for (item in items) {
|
||||||
|
val code = item.code?.trim().orEmpty()
|
||||||
|
if (code.isBlank()) continue
|
||||||
|
try {
|
||||||
|
val response = api.listRoomsByType(
|
||||||
|
propertyId = propertyId,
|
||||||
|
roomTypeCode = code,
|
||||||
|
availableOnly = true
|
||||||
|
)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
updates[code] = response.body().orEmpty().size
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore per-item failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.isNotEmpty()) {
|
||||||
|
_state.update { it.copy(availableCountByTypeCode = it.availableCountByTypeCode + updates) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.AspectRatio
|
||||||
|
import androidx.compose.material.icons.filled.Bed
|
||||||
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -105,11 +108,61 @@ fun RoomTypesScreen(
|
|||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
text = "${item.code} • ${item.name}",
|
text = "${item.code} • ${item.name}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item.maxOccupancy?.let { max ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Groups,
|
||||||
|
contentDescription = "Max occupancy",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = max.toString(),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.availableCountByTypeCode[item.code.orEmpty()]?.let { count ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bed,
|
||||||
|
contentDescription = "Available rooms",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = count.toString(),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.sqFeet?.let { sq ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.AspectRatio,
|
||||||
|
contentDescription = "Room size",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${sq} sq ft",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ lifecycleViewModelCompose = "2.10.0"
|
|||||||
firebaseAuthKtx = "24.0.1"
|
firebaseAuthKtx = "24.0.1"
|
||||||
vectordrawable = "1.2.0"
|
vectordrawable = "1.2.0"
|
||||||
coilCompose = "2.7.0"
|
coilCompose = "2.7.0"
|
||||||
|
lottieCompose = "6.7.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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 = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
|
||||||
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", 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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user