Compare commits

..

3 Commits

Author SHA1 Message Date
androidlover5842
be52b58165 Add NFC temporary card issue flow 2026-01-28 07:28:19 +05:30
androidlover5842
6d7edc3022 Room lists filters and room type availability 2026-01-28 05:42:23 +05:30
androidlover5842
d2d60b5074 Auto-refresh auth token on 401 2026-01-28 04:50:20 +05:30
26 changed files with 1118 additions and 454 deletions

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
implementation(libs.lottie.compose)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services)

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.data.api
import com.google.firebase.auth.FirebaseAuth
import okhttp3.Authenticator
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -18,7 +19,7 @@ object ApiClient {
val authInterceptor = Interceptor { chain ->
val original = chain.request()
val token = try {
kotlinx.coroutines.runBlocking { tokenProvider.token() }
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
} catch (e: Exception) {
null
}
@@ -32,12 +33,28 @@ object ApiClient {
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 {
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.authenticator(authenticator)
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)

View File

@@ -1,5 +1,5 @@
package com.android.trisolarispms.data.api
interface AuthTokenProvider {
suspend fun token(): String?
suspend fun token(forceRefresh: Boolean = false): String?
}

View File

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

View File

@@ -6,8 +6,8 @@ import kotlinx.coroutines.tasks.await
class FirebaseAuthTokenProvider(
private val auth: FirebaseAuth
) : AuthTokenProvider {
override suspend fun token(): String? {
override suspend fun token(forceRefresh: Boolean): String? {
val user = auth.currentUser ?: return null
return user.getIdToken(false).await().token
return user.getIdToken(forceRefresh).await().token
}
}

View File

@@ -56,4 +56,16 @@ interface RoomApi {
@Query("from") from: String,
@Query("to") to: String
): 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>>
}

View File

@@ -8,6 +8,9 @@ data class CardPrepareResponse(
val cardIndex: Int? = null,
val key: String? = null,
val timeData: String? = null,
val sector3Block0: String? = null,
val sector3Block1: String? = null,
val sector3Block2: String? = null,
val issuedAt: String? = null,
val expiresAt: String? = null
)
@@ -16,7 +19,7 @@ data class IssueCardRequest(
val cardId: String,
val cardIndex: Int,
val issuedAt: String? = null,
val expiresAt: String
val expiresAt: String? = null
)
data class IssuedCardResponse(

View File

@@ -25,6 +25,7 @@ data class RoomDto(
val roomNumber: Int? = null,
val roomTypeCode: String? = null,
val roomTypeName: String? = null,
val maxOccupancy: Int? = null,
val floor: Int? = null,
val hasNfc: Boolean? = null,
val active: Boolean? = null,

View File

@@ -7,6 +7,7 @@ sealed interface AppRoute {
data class Rooms(val propertyId: String) : AppRoute
data class AddRoom(val propertyId: String) : AppRoute
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute
data class RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute

View File

@@ -0,0 +1,146 @@
package com.android.trisolarispms.ui.card
import android.app.Activity
import android.nfc.NfcAdapter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun IssueTemporaryCardScreen(
propertyId: String,
roomId: String,
roomNumber: String?,
onBack: () -> Unit,
viewModel: IssueTemporaryCardViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val activity = context as? Activity
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
val composition = rememberLottieComposition(
LottieCompositionSpec.Asset("scan_nfc_animation.json")
)
val progress = animateLottieCompositionAsState(
composition = composition.value,
iterations = LottieConstants.IterateForever
)
DisposableEffect(activity, nfcAdapter, propertyId, roomId) {
if (activity != null && nfcAdapter != null) {
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
val callback = NfcAdapter.ReaderCallback { tag ->
viewModel.onTagScanned(propertyId, roomId, tag)
}
nfcAdapter.enableReaderMode(activity, callback, flags, null)
onDispose {
nfcAdapter.disableReaderMode(activity)
}
} else {
onDispose { }
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Issue Temporary Card") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = roomNumber?.let { "Room $it" } ?: "Room",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Hold the NFC card near the reader to issue a temporary card.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (nfcAdapter == null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "NFC not supported on this device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (!nfcAdapter.isEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "NFC is off. Please enable NFC.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
state.status?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
state.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(32.dp))
LottieAnimation(
composition = composition.value,
progress = { progress.value },
modifier = Modifier.size(220.dp)
)
}
}
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.card
data class IssueTemporaryCardState(
val isLoading: Boolean = false,
val isProcessing: Boolean = false,
val error: String? = null,
val status: String? = null,
val lastCardId: String? = null,
val lastExpiresAt: String? = null
)

View File

@@ -0,0 +1,263 @@
package com.android.trisolarispms.ui.card
import android.nfc.Tag
import android.nfc.tech.MifareClassic
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.CardPrepareRequest
import com.android.trisolarispms.data.api.model.IssueCardRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
class IssueTemporaryCardViewModel : ViewModel() {
private val _state = MutableStateFlow(IssueTemporaryCardState())
val state: StateFlow<IssueTemporaryCardState> = _state
fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
if (propertyId.isBlank() || roomId.isBlank()) return
if (state.value.isProcessing) return
viewModelScope.launch(Dispatchers.IO) {
Log.d("IssueTempCard", "Tag detected. roomId=$roomId cardId=${tagIdToHex(tag.id)}")
_state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") }
try {
val api = ApiClient.create()
Log.d("IssueTempCard", "Calling prepare-temp")
val prepareResponse = api.prepareTemporaryRoomCard(
propertyId = propertyId,
roomId = roomId,
body = CardPrepareRequest(expiresAt = null)
)
Log.d("IssueTempCard", "Prepare-temp response=${prepareResponse.code()}")
val prepareBody = prepareResponse.body()
if (!prepareResponse.isSuccessful || prepareBody == null) {
_state.update {
it.copy(
isProcessing = false,
error = "Prepare failed: ${prepareResponse.code()}",
status = null
)
}
return@launch
}
val cardIndex = prepareBody.cardIndex
val keyHex = prepareBody.key
val timeHex = prepareBody.timeData
if (cardIndex == null || keyHex.isNullOrBlank() || timeHex.isNullOrBlank()) {
_state.update {
it.copy(
isProcessing = false,
error = "Prepare response missing data.",
status = null
)
}
return@launch
}
_state.update { it.copy(status = "Writing card...") }
val cardId = tagIdToHex(tag.id)
val writeResult = writeSector0(
tag = tag,
keyHex = keyHex,
timeHex = timeHex,
sector3Block0 = prepareBody.sector3Block0,
sector3Block1 = prepareBody.sector3Block1,
sector3Block2 = prepareBody.sector3Block2
)
if (writeResult != null) {
Log.d("IssueTempCard", "Write failed: $writeResult")
_state.update {
it.copy(
isProcessing = false,
error = writeResult,
status = null
)
}
return@launch
}
Log.d("IssueTempCard", "Calling issue-temp")
_state.update { it.copy(status = "Saving issued card...") }
val issueResponse = api.issueTemporaryRoomCard(
propertyId = propertyId,
roomId = roomId,
body = IssueCardRequest(
cardId = cardId,
cardIndex = cardIndex,
issuedAt = prepareBody.issuedAt
?: nowAndExpiresIso(minutes = 7).first
)
)
Log.d("IssueTempCard", "Issue-temp response=${issueResponse.code()}")
if (issueResponse.isSuccessful) {
val body = issueResponse.body()
_state.update {
it.copy(
isProcessing = false,
error = null,
status = if (body != null) {
"Card issued. Expires in 7 minutes."
} else {
"Card issued (no response body)."
},
lastCardId = cardId,
lastExpiresAt = prepareBody.expiresAt
)
}
} else {
val errorText = issueResponse.errorBody()?.string()
Log.d("IssueTempCard", "Issue-temp error body=$errorText")
_state.update {
it.copy(
isProcessing = false,
error = when (issueResponse.code()) {
409 -> "Active card already exists."
else -> "Issue failed: ${issueResponse.code()}"
},
status = null
)
}
}
} catch (e: Exception) {
Log.d("IssueTempCard", "Issue failed: ${e.localizedMessage}", e)
_state.update {
it.copy(
isProcessing = false,
error = e.localizedMessage ?: "Issue failed",
status = null
)
}
}
}
}
private fun writeSector0(
tag: Tag,
keyHex: String,
timeHex: String,
sector3Block0: String?,
sector3Block1: String?,
sector3Block2: String?
): String? {
val mifare = MifareClassic.get(tag) ?: return "Unsupported card type (not Mifare Classic)."
return try {
mifare.connect()
val sectorIndex = 0
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT) ||
mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) ||
mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
if (!authA && !authB) return "Card authentication failed (sector 0)."
val keyBytes = hexToBytes(keyHex)
val timeBytes = hexToBytes(timeHex)
val blockIndex = mifare.sectorToBlock(sectorIndex)
Log.d("IssueTempCard", "Writing blocks ${blockIndex + 1} and ${blockIndex + 2}")
mifare.writeBlock(blockIndex + 1, keyBytes)
mifare.writeBlock(blockIndex + 2, timeBytes)
val trailerWrite = writeTrailerBlocks(mifare)
if (trailerWrite != null) return trailerWrite
val sector3Write = writeSector3Blocks(
mifare = mifare,
block0 = sector3Block0,
block1 = sector3Block1,
block2 = sector3Block2
)
if (sector3Write != null) return sector3Write
null
} catch (e: Exception) {
e.localizedMessage ?: "Write failed."
} finally {
try {
mifare.close()
} catch (_: Exception) {
}
}
}
private fun writeTrailerBlocks(mifare: MifareClassic): String? {
val sectors = listOf(0, 1, 2, 14)
for (sector in sectors) {
val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Trailer auth failed (sector $sector)."
val blockIndex = mifare.sectorToBlock(sector) + 3
mifare.writeBlock(blockIndex, CARD_SECTOR_INFO)
}
return null
}
private fun writeSector3Blocks(
mifare: MifareClassic,
block0: String?,
block1: String?,
block2: String?
): String? {
if (block0.isNullOrBlank() && block1.isNullOrBlank() && block2.isNullOrBlank()) return null
val sector = 3
val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Sector 3 authentication failed."
val blockIndex = mifare.sectorToBlock(sector)
block0?.let { mifare.writeBlock(blockIndex + 0, hexToBytes(it)) }
block1?.let { mifare.writeBlock(blockIndex + 1, hexToBytes(it)) }
block2?.let { mifare.writeBlock(blockIndex + 2, hexToBytes(it)) }
return null
}
private fun nowAndExpiresIso(minutes: Int): Pair<String, String> {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
timeZone = TimeZone.getDefault()
}
val calendar = Calendar.getInstance()
val issuedAt = format.format(calendar.time)
calendar.add(Calendar.MINUTE, minutes)
val expiresAt = format.format(calendar.time)
return issuedAt to expiresAt
}
private fun tagIdToHex(tagId: ByteArray?): String {
if (tagId == null) return ""
return tagId.joinToString("") { byte -> "%02x".format(byte) }
}
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().replace(" ", "")
val len = clean.length
if (len % 2 != 0) return ByteArray(0)
val out = ByteArray(len / 2)
var i = 0
while (i < len) {
out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
i += 2
}
return out
}
companion object {
private val CARD_SECTOR_INFO = hexToBytesStatic("1AB23CD45EF6FF078069FFFFFFFFFFFF")
private fun hexToBytesStatic(hex: String): ByteArray {
val clean = hex.trim().replace(" ", "")
val len = clean.length
if (len % 2 != 0) return ByteArray(0)
val out = ByteArray(len / 2)
var i = 0
while (i < len) {
out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
i += 2
}
return out
}
}
}

View File

@@ -5,5 +5,7 @@ import com.android.trisolarispms.data.api.model.RoomDto
data class RoomListState(
val isLoading: Boolean = false,
val error: String? = null,
val rooms: List<RoomDto> = emptyList()
val rooms: List<RoomDto> = emptyList(),
val showAll: Boolean = false,
val roomTypeCode: String? = null
)

View File

@@ -12,13 +12,24 @@ class RoomListViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomListState())
val state: StateFlow<RoomListState> = _state
fun load(propertyId: String) {
fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) {
if (propertyId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
_state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) }
try {
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) {
_state.update {
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)
}
}

View File

@@ -1,18 +1,29 @@
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
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.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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -21,6 +32,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -29,6 +43,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
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
@OptIn(ExperimentalMaterial3Api::class)
@@ -39,12 +57,17 @@ fun RoomsScreen(
onViewRoomTypes: () -> Unit,
canManageRooms: Boolean,
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 roomTypeState by roomTypeListViewModel.state.collectAsState()
val showTypeMenu = remember { mutableStateOf(false) }
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
viewModel.load(propertyId, showAll = false)
roomTypeListViewModel.load(propertyId)
}
Scaffold(
@@ -86,29 +109,153 @@ fun RoomsScreen(
Spacer(modifier = Modifier.height(8.dp))
}
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()) {
Text(text = "No rooms found")
} else {
state.rooms.forEach { room ->
val label = room.roomNumber?.toString() ?: "-"
val details = listOfNotNull(
room.floor?.let { "Floor $it" },
room.roomTypeName ?: room.roomTypeCode
).joinToString("")
val isDimmed = (room.active == false) || (room.maintenance == true)
val alpha = if (isDimmed) 0.5f else 1f
Column(
modifier = Modifier
.fillMaxWidth()
.alpha(alpha)
.clickable(enabled = room.id != null) {
onEditRoom(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 category = room.roomTypeName ?: room.roomTypeCode ?: "Unassigned"
val floorLabel = room.floor?.let { "Floor $it" }.orEmpty()
val isDimmed = (room.active == false) || (room.maintenance == true)
val alpha = if (isDimmed) 0.5f else 1f
Card(
modifier = Modifier
.fillMaxWidth()
.alpha(alpha)
.combinedClickable(
enabled = room.id != null,
onClick = { onIssueTemporaryCard(room) },
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)
}
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
)
}
}
.padding(vertical = 10.dp)
) {
Text(text = label, style = MaterialTheme.typography.titleMedium)
if (details.isNotBlank()) {
Text(text = details, style = MaterialTheme.typography.bodySmall)
}
}
}

View File

@@ -1,35 +1,7 @@
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.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.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
@Composable
@@ -41,122 +13,16 @@ fun AddRoomTypeScreen(
viewModel: RoomTypeFormViewModel = viewModel(),
amenityViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
LaunchedEffect(Unit) {
amenityViewModel.load()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Room Type") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
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)
}
}
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.resetForm()
}
RoomTypeFormScreen(
title = "Add Room Type",
propertyId = propertyId,
onBack = onBack,
onSave = { viewModel.submit(propertyId, onSave) },
codeEditable = true,
viewModel = viewModel,
amenityViewModel = amenityViewModel
)
}

View File

@@ -1,58 +1,29 @@
package com.android.trisolarispms.ui.roomtype
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.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.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.ui.Modifier
import androidx.compose.runtime.remember
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.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.ui.roomimage.RoomImageGridItem
import com.android.trisolarispms.ui.roomimage.ImagePreviewDialog
import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid
import com.android.trisolarispms.ui.roomimage.RoomImageGridItem
import com.android.trisolarispms.ui.roomimage.RoomImageViewModel
@Composable
@@ -66,16 +37,14 @@ fun EditRoomTypeScreen(
amenityViewModel: AmenityListViewModel = viewModel(),
roomImageViewModel: RoomImageViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
val roomImageState by roomImageViewModel.state.collectAsState()
val showDeleteConfirm = remember { mutableStateOf(false) }
val orderedImages = remember { mutableStateOf<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
val previewUrl = remember { mutableStateOf<String?>(null) }
val showAmenityDialog = remember { mutableStateOf(false) }
val amenitySearch = remember { mutableStateOf("") }
val gridState = rememberLazyGridState()
val gridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) {
{
val code = roomType.code.orEmpty()
@@ -101,9 +70,6 @@ fun EditRoomTypeScreen(
LaunchedEffect(roomType.id) {
viewModel.setRoomType(roomType)
}
LaunchedEffect(Unit) {
amenityViewModel.load()
}
LaunchedEffect(roomType.code) {
val code = roomType.code.orEmpty()
if (code.isNotBlank()) {
@@ -115,159 +81,55 @@ fun EditRoomTypeScreen(
originalOrderIds.value = roomImageState.images.mapNotNull { it.id }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Modify Room Type") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
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
RoomTypeFormScreen(
title = "Modify Room Type",
propertyId = propertyId,
onBack = onBack,
onSave = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) },
codeEditable = false,
onDelete = { if (!roomType.id.isNullOrBlank()) showDeleteConfirm.value = true },
viewModel = viewModel,
amenityViewModel = amenityViewModel
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
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(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Amenities", style = MaterialTheme.typography.titleSmall)
Button(onClick = { showAmenityDialog.value = true }) {
Text("Edit")
Text(text = "Room Type Images", style = androidx.compose.material3.MaterialTheme.typography.titleSmall)
val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value
if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
Button(onClick = saveRoomTypeImageOrder) {
Text("Save order")
}
}
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
if (orderedImages.value.isNotEmpty() && hasOrderChanged) {
Button(onClick = saveRoomTypeImageOrder) {
Text("Save order")
}
}
}
if (roomImageState.isLoading) {
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.CircularProgressIndicator()
} else if (orderedImages.value.isEmpty()) {
Text(text = "No images yet", style = MaterialTheme.typography.bodySmall)
} else {
Spacer(modifier = Modifier.height(8.dp))
ReorderableImageGrid(
images = orderedImages.value,
gridState = gridState,
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
onOrderChange = { list ->
orderedImages.value = list
},
onDragEnd = {}
) { image, dragHandleModifier, _ ->
RoomImageGridItem(
image = image,
modifier = dragHandleModifier,
onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl },
showTags = true
)
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
if (roomImageState.isLoading) {
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.CircularProgressIndicator()
} else if (orderedImages.value.isEmpty()) {
Text(text = "No images yet", style = androidx.compose.material3.MaterialTheme.typography.bodySmall)
} else {
Spacer(modifier = Modifier.height(8.dp))
ReorderableImageGrid(
images = orderedImages.value,
gridState = gridState,
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
onOrderChange = { list ->
orderedImages.value = list
},
onDragEnd = {}
) { image, dragHandleModifier, _ ->
RoomImageGridItem(
image = image,
modifier = dragHandleModifier,
onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl },
showTags = true
)
}
}
}
@@ -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
if (!preview.isNullOrBlank()) {
com.android.trisolarispms.ui.roomimage.ImagePreviewDialog(
ImagePreviewDialog(
imageUrl = preview,
onDismiss = { previewUrl.value = null }
)

View File

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

View File

@@ -14,6 +14,10 @@ class RoomTypeFormViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomTypeFormState())
val state: StateFlow<RoomTypeFormState> = _state
fun resetForm() {
_state.update { RoomTypeFormState() }
}
fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) {
_state.update {
it.copy(

View File

@@ -6,5 +6,6 @@ data class RoomTypeListState(
val isLoading: Boolean = false,
val error: String? = null,
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()
)

View File

@@ -29,6 +29,7 @@ class RoomTypeListViewModel : ViewModel() {
)
}
loadRoomTypeImages(propertyId, items)
loadRoomTypeAvailableCounts(propertyId, items)
} else {
_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) }
}
}
}
}

View File

@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -105,11 +108,61 @@ fun RoomTypesScreen(
.clip(MaterialTheme.shapes.medium)
)
}
Text(
text = "${item.code}${item.name}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth()
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${item.code}${item.name}",
style = MaterialTheme.typography.titleMedium
)
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))
}

View File

@@ -18,6 +18,7 @@ lifecycleViewModelCompose = "2.10.0"
firebaseAuthKtx = "24.0.1"
vectordrawable = "1.2.0"
coilCompose = "2.7.0"
lottieCompose = "6.7.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -46,6 +47,7 @@ kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "ko
androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }