booking details: add ability to see details of guest

This commit is contained in:
androidlover5842
2026-01-29 22:03:06 +05:30
parent 9d3ade3d03
commit be820391bc
13 changed files with 514 additions and 1 deletions

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.lottie.compose)
implementation(libs.calendar.compose)
implementation(libs.libphonenumber)

View File

@@ -23,6 +23,7 @@ import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen
@@ -152,6 +153,10 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.BookingDetailsTabs -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
}
@@ -276,6 +281,13 @@ class MainActivity : ComponentActivity() {
expectedCheckInAt = booking.expectedCheckInAt,
expectedCheckOutAt = booking.expectedCheckOutAt
)
},
onOpenBookingDetails = { booking ->
route.value = AppRoute.BookingDetailsTabs(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
guestId = booking.guestId
)
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
@@ -392,6 +404,17 @@ class MainActivity : ComponentActivity() {
)
}
)
is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId,
onBack = {

View File

@@ -12,6 +12,7 @@ import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.RoomStayDto
import retrofit2.Response
import retrofit2.http.Body
@@ -47,6 +48,12 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<BookingDetailsResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest(
@Path("propertyId") propertyId: String,

View File

@@ -41,6 +41,10 @@ data class BookingListItem(
val guestPhone: String? = null,
val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val expectedCheckInAt: String? = null,
@@ -73,6 +77,38 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null
)
data class BookingDetailsResponse(
val id: String? = null,
val status: String? = null,
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val guestNationality: String? = null,
val guestAddressText: String? = null,
val guestSignatureUrl: String? = null,
val roomNumbers: List<Int> = emptyList(),
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val adultCount: Int? = null,
val maleCount: Int? = null,
val femaleCount: Int? = null,
val childCount: Int? = null,
val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null,
val totalNightlyRate: Long? = null,
val registeredByName: String? = null,
val registeredByPhone: String? = null,
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null
)
data class BookingLinkGuestRequest(
val guestId: String
)

View File

@@ -42,6 +42,11 @@ sealed interface AppRoute {
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
) : AppRoute
data class BookingDetailsTabs(
val propertyId: String,
val bookingId: String,
val guestId: String?
) : AppRoute
data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: String) : AppRoute

View File

@@ -5,6 +5,7 @@ data class GuestInfoState(
val name: String = "",
val nationality: String = "",
val addressText: String = "",
val vehicleNumbers: List<String> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -41,6 +41,7 @@ class GuestInfoViewModel : ViewModel() {
name = guest?.name.orEmpty(),
nationality = guest?.nationality.orEmpty(),
addressText = guest?.addressText.orEmpty(),
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
error = null
)
}
@@ -61,6 +62,7 @@ class GuestInfoViewModel : ViewModel() {
name = guest.name.orEmpty(),
nationality = guest.nationality.orEmpty(),
addressText = guest.addressText.orEmpty(),
vehicleNumbers = guest.vehicleNumbers ?: emptyList(),
isLoading = false,
error = null
)

View File

@@ -54,6 +54,7 @@ fun ActiveRoomStaysScreen(
onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit,
onExtendBooking: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit,
viewModel: ActiveRoomStaysViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
@@ -119,7 +120,7 @@ fun ActiveRoomStaysScreen(
CheckedInBookingCard(
booking = booking,
onClick = { selectedBooking.value = booking },
onLongClick = { onViewBookingStays(booking) }
onLongClick = { onOpenBookingDetails(booking) }
)
}
}
@@ -167,6 +168,7 @@ fun ActiveRoomStaysScreen(
dismissButton = {}
)
}
}
@Composable

View File

@@ -0,0 +1,9 @@
package com.android.trisolarispms.ui.roomstay
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
data class BookingDetailsState(
val isLoading: Boolean = false,
val error: String? = null,
val details: BookingDetailsResponse? = null
)

View File

@@ -0,0 +1,370 @@
package com.android.trisolarispms.ui.roomstay
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.ColumnScope
import kotlinx.coroutines.launch
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.ApiConstants
import com.google.firebase.auth.FirebaseAuth
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import androidx.compose.ui.platform.LocalContext
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingDetailsTabsScreen(
propertyId: String,
bookingId: String,
guestId: String?,
onBack: () -> Unit,
staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel()
) {
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
val scope = rememberCoroutineScope()
val staysState by staysViewModel.state.collectAsState()
val detailsState by detailsViewModel.state.collectAsState()
LaunchedEffect(propertyId, bookingId, guestId) {
staysViewModel.load(propertyId, bookingId)
detailsViewModel.load(propertyId, bookingId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Details") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(
selected = pagerState.currentPage == 0,
onClick = {
scope.launch { pagerState.animateScrollToPage(0) }
},
text = { Text("Guest Info") }
)
Tab(
selected = pagerState.currentPage == 1,
onClick = {
scope.launch { pagerState.animateScrollToPage(1) }
},
text = { Text("Room Stays") }
)
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
when (page) {
0 -> GuestInfoTabContent(
propertyId = propertyId,
details = detailsState.details,
guestId = guestId,
isLoading = detailsState.isLoading,
error = detailsState.error
)
1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
}
}
}
}
}
@Composable
private fun GuestInfoTabContent(
propertyId: String,
details: BookingDetailsResponse?,
guestId: String?,
isLoading: Boolean,
error: String?
) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
if (isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
SectionCard(title = "Details") {
GuestDetailRow(label = "Name", value = details?.guestName.orEmpty())
GuestDetailRow(label = "Address", value = details?.guestAddressText.orEmpty())
GuestDetailRow(label = "Phone number", value = details?.guestPhone.orEmpty())
GuestDetailRow(label = "Coming From", value = details?.fromCity.orEmpty())
GuestDetailRow(label = "Going To", value = details?.toCity.orEmpty())
GuestDetailRow(label = "Relation", value = details?.memberRelation.orEmpty())
GuestDetailRow(label = "Mode of transport", value = details?.transportMode.orEmpty())
GuestDetailRow(label = "Vehicle numbers", value = "")
}
val checkIn = details?.checkInAt ?: details?.expectedCheckInAt
val checkOut = details?.expectedCheckOutAt ?: details?.checkOutAt
SectionCard(title = "Stay") {
if (!checkIn.isNullOrBlank()) {
val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull()
GuestDetailRow(
label = "Check In Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }.orEmpty()
)
} else {
GuestDetailRow(label = "Check In Time", value = "")
}
if (!checkOut.isNullOrBlank()) {
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull()
GuestDetailRow(
label = "Estimated Check Out Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }.orEmpty()
)
} else {
GuestDetailRow(label = "Estimated Check Out Time", value = "")
}
GuestDetailRow(
label = "Rooms Booked",
value = details?.roomNumbers?.takeIf { it.isNotEmpty() }?.joinToString(", ").orEmpty()
)
GuestDetailRow(label = "Total Adults", value = details?.adultCount?.toString().orEmpty())
GuestDetailRow(label = "Total Males", value = details?.maleCount?.toString().orEmpty())
GuestDetailRow(label = "Total Females", value = details?.femaleCount?.toString().orEmpty())
GuestDetailRow(label = "Total Children", value = details?.childCount?.toString().orEmpty())
GuestDetailRow(label = "Total Guests", value = details?.totalGuestCount?.toString().orEmpty())
GuestDetailRow(label = "Expected Guests", value = details?.expectedGuestCount?.toString().orEmpty())
}
SectionCard(title = "Calculations") {
GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString().orEmpty())
GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString().orEmpty())
GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString().orEmpty())
GuestDetailRow(label = "Pending", value = details?.pending?.toString().orEmpty())
}
SectionCard(title = "Registered By") {
GuestDetailRow(label = "Name", value = details?.registeredByName.orEmpty())
GuestDetailRow(label = "Phone number", value = details?.registeredByPhone.orEmpty())
}
Spacer(modifier = Modifier.height(16.dp))
SignaturePreview(
propertyId = propertyId,
guestId = details?.guestId ?: guestId,
signatureUrl = details?.guestSignatureUrl
)
}
}
@Composable
private fun GuestDetailRow(label: String, value: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value.ifBlank { "-" },
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
Spacer(modifier = Modifier.height(12.dp))
}
@Composable
private fun SignaturePreview(propertyId: String, guestId: String?, signatureUrl: String?) {
val resolvedGuestId = guestId
if (resolvedGuestId.isNullOrBlank() && signatureUrl.isNullOrBlank()) {
Text(text = "Signature", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Not available", style = MaterialTheme.typography.bodySmall)
return
}
val auth = remember { FirebaseAuth.getInstance() }
val tokenProvider = remember { FirebaseAuthTokenProvider(auth) }
val context = LocalContext.current
val imageLoader = remember {
ImageLoader.Builder(context)
.components { add(SvgDecoder.Factory()) }
.okHttpClient(
OkHttpClient.Builder()
.addInterceptor(Interceptor { chain ->
val original = chain.request()
val token = runCatching {
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
}.getOrNull()
if (token.isNullOrBlank()) {
chain.proceed(original)
} else {
val request = original.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
})
.build()
)
.build()
}
val url = signatureUrl?.let { if (it.startsWith("http")) it else "${ApiConstants.BASE_URL.trimEnd('/')}$it" }
?: "${ApiConstants.BASE_URL}properties/$propertyId/guests/$resolvedGuestId/signature/file"
Column {
Text(text = "Signature", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.build(),
imageLoader = imageLoader,
contentDescription = "Signature",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.padding(8.dp)
)
}
}
}
@Composable
private fun BookingRoomStaysTabContent(
state: BookingRoomStaysState,
viewModel: BookingRoomStaysViewModel
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(text = "Show all (including checkout)")
androidx.compose.material3.Switch(
checked = state.showAll,
onCheckedChange = viewModel::toggleShowAll
)
}
Spacer(modifier = Modifier.height(12.dp))
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!state.isLoading && state.error == null) {
if (state.stays.isEmpty()) {
Text(text = "No stays found")
} else {
state.stays.forEach { stay ->
val roomLine = "Room ${stay.roomNumber ?: "-"}${stay.roomTypeName ?: ""}".trim()
Text(text = roomLine, style = MaterialTheme.typography.titleMedium)
val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString("")
if (guestLine.isNotBlank()) {
Text(text = guestLine, style = MaterialTheme.typography.bodyMedium)
}
val timeLine = listOfNotNull(stay.fromAt, stay.expectedCheckoutAt).joinToString("")
if (timeLine.isNotBlank()) {
Text(text = timeLine, style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingDetailsViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingDetailsState())
val state: StateFlow<BookingDetailsState> = _state
fun load(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.getBookingDetails(propertyId, bookingId)
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
details = response.body(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
}