stay: improve checkin and checkout time editor

This commit is contained in:
androidlover5842
2026-02-04 16:28:50 +05:30
parent d69ed60a6e
commit e1250a0f32
7 changed files with 497 additions and 385 deletions

View File

@@ -11,15 +11,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
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.Switch
@@ -51,8 +47,10 @@ fun BookingCreateScreen(
viewModel: BookingCreateViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val showCheckInPicker = remember { mutableStateOf(false) }
val showCheckOutPicker = remember { mutableStateOf(false) }
val showCheckInDatePicker = remember { mutableStateOf(false) }
val showCheckInTimePicker = remember { mutableStateOf(false) }
val showCheckOutDatePicker = remember { mutableStateOf(false) }
val showCheckOutTimePicker = remember { mutableStateOf(false) }
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
@@ -76,6 +74,12 @@ fun BookingCreateScreen(
checkInTime.value = time
val checkInAt = formatBookingIso(date, time)
viewModel.onExpectedCheckInAtChange(checkInAt)
val currentCheckOutDate = checkOutDate.value
if (currentCheckOutDate != null && currentCheckOutDate.isBefore(date)) {
checkOutDate.value = date
val adjustedCheckOutAt = formatBookingIso(date, checkOutTime.value)
viewModel.onExpectedCheckOutAtChange(adjustedCheckOutAt)
}
viewModel.autoSetBillingFromCheckIn(checkInAt)
viewModel.refreshExpectedCheckoutPreview(propertyId)
}
@@ -124,43 +128,35 @@ fun BookingCreateScreen(
if (enabled) {
val now = OffsetDateTime.now()
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
} else {
viewModel.onExpectedCheckInAtChange("")
}
}
)
}
if (!checkInNow.value) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
trailingIcon = {
IconButton(onClick = { showCheckInPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-out") },
trailingIcon = {
IconButton(onClick = { showCheckOutPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val checkOutDisplay = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val cardDateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val cardTimeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
val totalTimeText = remember(state.expectedCheckInAt, state.expectedCheckOutAt) {
val start = runCatching { OffsetDateTime.parse(state.expectedCheckInAt) }.getOrNull()
val end = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull()
formatBookingDurationText(start, end)
}
BookingDateTimeQuickEditorCard(
checkInDateText = checkInDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkInTimeText = checkInDisplay?.format(cardTimeFormatter) ?: "--:--",
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
totalTimeText = totalTimeText,
checkInEditable = !checkInNow.value,
onCheckInDateClick = { showCheckInDatePicker.value = true },
onCheckInTimeClick = { showCheckInTimePicker.value = true },
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
onCheckOutTimeClick = { showCheckOutTimePicker.value = true }
)
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
@@ -485,33 +481,50 @@ fun BookingCreateScreen(
}
}
if (showCheckInPicker.value) {
BookingDateTimePickerDialog(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
if (showCheckInDatePicker.value) {
BookingDatePickerDialog(
initialDate = checkInDate.value ?: LocalDate.now(),
minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false },
onConfirm = { date, time ->
applyCheckInSelection(date, time)
showCheckInPicker.value = false
onDismiss = { showCheckInDatePicker.value = false },
onDateSelected = { selectedDate ->
applyCheckInSelection(selectedDate, checkInTime.value)
}
)
}
if (showCheckOutPicker.value) {
BookingDateTimePickerDialog(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
if (showCheckInTimePicker.value) {
BookingTimePickerDialog(
initialTime = checkInTime.value,
onDismiss = { showCheckInTimePicker.value = false },
onTimeSelected = { selectedTime ->
val selectedDate = checkInDate.value ?: LocalDate.now()
applyCheckInSelection(selectedDate, selectedTime)
}
)
}
if (showCheckOutDatePicker.value) {
BookingDatePickerDialog(
initialDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now()),
minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false },
onConfirm = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
val formatted = formatBookingIso(date, time)
onDismiss = { showCheckOutDatePicker.value = false },
onDateSelected = { selectedDate ->
checkOutDate.value = selectedDate
val formatted = formatBookingIso(selectedDate, checkOutTime.value)
viewModel.onExpectedCheckOutAtChange(formatted)
}
)
}
if (showCheckOutTimePicker.value) {
BookingTimePickerDialog(
initialTime = checkOutTime.value,
onDismiss = { showCheckOutTimePicker.value = false },
onTimeSelected = { selectedTime ->
checkOutTime.value = selectedTime
val selectedDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now())
val formatted = formatBookingIso(selectedDate, selectedTime)
viewModel.onExpectedCheckOutAtChange(formatted)
showCheckOutPicker.value = false
}
)
}

View File

@@ -0,0 +1,217 @@
package com.android.trisolarispms.ui.booking
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
@Composable
internal fun BookingDateTimeQuickEditorCard(
checkInDateText: String,
checkInTimeText: String,
checkOutDateText: String,
checkOutTimeText: String,
totalTimeText: String?,
checkInEditable: Boolean,
checkOutEditable: Boolean = true,
onCheckInDateClick: () -> Unit,
onCheckInTimeClick: () -> Unit,
onCheckOutDateClick: () -> Unit,
onCheckOutTimeClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
BookingDateTimeQuickEditorRow(
label = "Check In Time:",
dateText = checkInDateText,
timeText = checkInTimeText,
editable = checkInEditable,
onDateClick = onCheckInDateClick,
onTimeClick = onCheckInTimeClick
)
BookingDateTimeQuickEditorRow(
label = "Check Out Time:",
dateText = checkOutDateText,
timeText = checkOutTimeText,
editable = checkOutEditable,
onDateClick = onCheckOutDateClick,
onTimeClick = onCheckOutTimeClick
)
if (!totalTimeText.isNullOrBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total Time:",
style = MaterialTheme.typography.titleSmall
)
Text(
text = totalTimeText,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
}
@Composable
private fun BookingDateTimeQuickEditorRow(
label: String,
dateText: String,
timeText: String,
editable: Boolean,
onDateClick: () -> Unit,
onTimeClick: () -> Unit
) {
val valueColor = if (editable) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = label, style = MaterialTheme.typography.bodyLarge)
Text(
text = timeText,
color = valueColor,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable(enabled = editable, onClick = onTimeClick)
.padding(vertical = 2.dp)
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = dateText,
color = valueColor,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable(enabled = editable, onClick = onDateClick)
.padding(vertical = 2.dp)
)
}
}
@Composable
internal fun BookingDatePickerDialog(
initialDate: LocalDate,
minDate: LocalDate,
onDismiss: () -> Unit,
onDateSelected: (LocalDate) -> Unit
) {
val context = LocalContext.current
val dismissState = rememberUpdatedState(onDismiss)
val selectDateState = rememberUpdatedState(onDateSelected)
DisposableEffect(context, initialDate, minDate) {
val dialog = DatePickerDialog(
context,
{ _, year, monthOfYear, dayOfMonth ->
selectDateState.value(LocalDate.of(year, monthOfYear + 1, dayOfMonth))
},
initialDate.year,
initialDate.monthValue - 1,
initialDate.dayOfMonth
)
val minDateMillis = minDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
dialog.datePicker.minDate = minDateMillis
dialog.setOnDismissListener { dismissState.value() }
dialog.show()
onDispose {
dialog.setOnDismissListener(null)
dialog.dismiss()
}
}
}
@Composable
internal fun BookingTimePickerDialog(
initialTime: String,
onDismiss: () -> Unit,
onTimeSelected: (String) -> Unit
) {
val context = LocalContext.current
val dismissState = rememberUpdatedState(onDismiss)
val selectTimeState = rememberUpdatedState(onTimeSelected)
val initialHour = initialTime.split(":").getOrNull(0)?.toIntOrNull()?.coerceIn(0, 23) ?: 12
val initialMinute = initialTime.split(":").getOrNull(1)?.toIntOrNull()?.coerceIn(0, 59) ?: 0
DisposableEffect(context, initialHour, initialMinute) {
val dialog = TimePickerDialog(
context,
{ _, hourOfDay, minute ->
selectTimeState.value("%02d:%02d".format(hourOfDay, minute))
},
initialHour,
initialMinute,
true
)
dialog.setOnDismissListener { dismissState.value() }
dialog.show()
onDispose {
dialog.setOnDismissListener(null)
dialog.dismiss()
}
}
}
internal fun formatBookingDurationText(
start: OffsetDateTime?,
end: OffsetDateTime?
): String? {
if (start == null || end == null || !end.isAfter(start)) return null
val totalMinutes = Duration.between(start, end).toMinutes()
val totalHours = totalMinutes / 60
val minutes = totalMinutes % 60
return if (totalHours >= 24) {
val days = totalHours / 24
val hoursLeft = totalHours % 24
"%02dd:%02dh left".format(days, hoursLeft)
} else if (totalHours > 0) {
"${totalHours}h ${minutes}m"
} else {
"${minutes}m"
}
}

View File

@@ -1,266 +0,0 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun BookingExpectedDatesScreen(
propertyId: String,
bookingId: String,
status: String?,
expectedCheckInAt: String?,
expectedCheckOutAt: String?,
onBack: () -> Unit,
onDone: () -> Unit
) {
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val isLoading = remember { mutableStateOf(false) }
val error = remember { mutableStateOf<String?>(null) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val today = LocalDate.now(displayZone)
val bookingStatus = status?.uppercase()
val editableCheckIn = bookingStatus == "OPEN"
val billableNights = remember { mutableStateOf<Long?>(null) }
val isBillableNightsLoading = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(bookingId) {
val now = OffsetDateTime.now()
expectedCheckInAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
val zoned = parsed.atZoneSameInstant(displayZone)
checkInDate.value = zoned.toLocalDate()
checkInTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
}
} ?: run {
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
}
expectedCheckOutAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
val zoned = parsed.atZoneSameInstant(displayZone)
checkOutDate.value = zoned.toLocalDate()
checkOutTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
}
} ?: run {
checkOutDate.value = (checkInDate.value ?: now.toLocalDate()).plusDays(1)
checkOutTime.value = "11:00"
}
}
LaunchedEffect(
propertyId,
bookingId,
bookingStatus,
checkInDate.value,
checkInTime.value,
checkOutDate.value,
checkOutTime.value
) {
val inAt = if (editableCheckIn) {
checkInDate.value?.let { formatBookingIso(it, checkInTime.value) }
} else {
null
}
val outAt = checkOutDate.value?.let { formatBookingIso(it, checkOutTime.value) }
val previewBody = when (bookingStatus) {
"OPEN" -> {
if (inAt.isNullOrBlank() || outAt.isNullOrBlank()) null
else BookingBillableNightsRequest(expectedCheckInAt = inAt, expectedCheckOutAt = outAt)
}
"CHECKED_IN" -> {
if (outAt.isNullOrBlank()) null
else BookingBillableNightsRequest(expectedCheckOutAt = outAt)
}
else -> null
}
if (previewBody == null) {
billableNights.value = null
isBillableNightsLoading.value = false
return@LaunchedEffect
}
isBillableNightsLoading.value = true
try {
val api = ApiClient.create()
val response = api.previewBillableNights(
propertyId = propertyId,
bookingId = bookingId,
body = previewBody
)
billableNights.value = if (response.isSuccessful) response.body()?.billableNights else null
} catch (_: Exception) {
billableNights.value = null
} finally {
isBillableNightsLoading.value = false
}
}
SaveTopBarScaffold(
title = "Update Expected Dates",
onBack = onBack,
saveEnabled = !isLoading.value,
onSave = {
val inAt = if (editableCheckIn) {
checkInDate.value?.let { formatBookingIso(it, checkInTime.value) }
} else {
null
}
val outAt = checkOutDate.value?.let { formatBookingIso(it, checkOutTime.value) }
val hasCheckInChanged = editableCheckIn && !isSameBookingDateTime(inAt, expectedCheckInAt)
val hasCheckOutChanged = !isSameBookingDateTime(outAt, expectedCheckOutAt)
if (!hasCheckInChanged && !hasCheckOutChanged) {
onDone()
return@SaveTopBarScaffold
}
isLoading.value = true
error.value = null
scope.launch {
try {
val api = ApiClient.create()
val response = api.updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = BookingExpectedDatesRequest(
expectedCheckInAt = inAt,
expectedCheckOutAt = outAt
)
)
if (response.isSuccessful) {
onDone()
} else {
error.value = "Update failed: ${response.code()}"
}
} catch (e: Exception) {
error.value = e.localizedMessage ?: "Update failed"
} finally {
isLoading.value = false
}
}
}
) { padding ->
PaddedScreenColumn(padding = padding) {
if (editableCheckIn) {
OutlinedTextField(
value = checkInDate.value?.let {
formatBookingIso(it, checkInTime.value)
}?.let { iso ->
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
}.getOrDefault(iso)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
BookingDateTimePickerInline(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
minDate = today,
onValueChange = { date, time ->
checkInDate.value = date
checkInTime.value = time
if (checkOutDate.value?.isBefore(date) == true) {
checkOutDate.value = date
}
}
)
Spacer(modifier = Modifier.height(12.dp))
}
OutlinedTextField(
value = checkOutDate.value?.let {
formatBookingIso(it, checkOutTime.value)
}?.let { iso ->
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
}.getOrDefault(iso)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-out") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(6.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f),
shape = MaterialTheme.shapes.small
) {
Text(
text = if (isBillableNightsLoading.value) {
"Billable Nights: Calculating..."
} else {
"Billable Nights: ${billableNights.value?.toString() ?: "-"}"
},
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
)
}
val checkOutMinDate = maxOf(checkInDate.value ?: today, today)
Spacer(modifier = Modifier.height(8.dp))
BookingDateTimePickerInline(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
minDate = checkOutMinDate,
onValueChange = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
}
)
if (isLoading.value) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
error.value?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}
private fun isSameBookingDateTime(current: String?, original: String?): Boolean {
if (current.isNullOrBlank() && original.isNullOrBlank()) return true
if (current.isNullOrBlank() || original.isNullOrBlank()) return false
val currentInstant = runCatching { OffsetDateTime.parse(current).toInstant() }.getOrNull()
val originalInstant = runCatching { OffsetDateTime.parse(original).toInstant() }.getOrNull()
return if (currentInstant != null && originalInstant != null) {
currentInstant == originalInstant
} else {
current.trim() == original.trim()
}
}

View File

@@ -35,13 +35,6 @@ sealed interface AppRoute {
val propertyId: String,
val bookingId: String
) : AppRoute
data class BookingExpectedDates(
val propertyId: String,
val bookingId: String,
val status: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
) : AppRoute
data class BookingDetailsTabs(
val propertyId: String,
val bookingId: String,

View File

@@ -72,7 +72,6 @@ internal fun handleBackNavigation(
currentRoute.toAt
)
is AppRoute.BookingRoomStays -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingExpectedDates -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingDetailsTabs -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingPayments -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,

View File

@@ -3,7 +3,6 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.AuthzPolicy
import com.android.trisolarispms.ui.navigation.AppRoute
import com.android.trisolarispms.ui.booking.BookingExpectedDatesScreen
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
@@ -20,30 +19,11 @@ internal fun renderBookingRoutes(
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }
)
is AppRoute.BookingExpectedDates -> BookingExpectedDatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = currentRoute.status,
expectedCheckInAt = currentRoute.expectedCheckInAt,
expectedCheckOutAt = currentRoute.expectedCheckOutAt,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onDone = { refs.openActiveRoomStays(currentRoute.propertyId) }
)
is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onEditCheckout = { expectedCheckInAt, expectedCheckOutAt ->
refs.route.value = AppRoute.BookingExpectedDates(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = "CHECKED_IN",
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt
)
},
onEditSignature = { guestId ->
refs.route.value = AppRoute.GuestSignature(
currentRoute.propertyId,

View File

@@ -18,7 +18,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.Card
@@ -33,10 +32,11 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
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
@@ -49,10 +49,17 @@ import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants
import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard
import com.android.trisolarispms.ui.booking.BookingTimePickerDialog
import com.android.trisolarispms.ui.booking.formatBookingIso
import com.android.trisolarispms.ui.booking.formatBookingDurationText
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
import com.google.firebase.auth.FirebaseAuth
@@ -72,7 +79,6 @@ fun BookingDetailsTabsScreen(
bookingId: String,
guestId: String?,
onBack: () -> Unit,
onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit,
@@ -140,11 +146,11 @@ fun BookingDetailsTabsScreen(
when (page) {
0 -> GuestInfoTabContent(
propertyId = propertyId,
bookingId = bookingId,
details = detailsState.details,
guestId = guestId,
isLoading = detailsState.isLoading,
error = detailsState.error,
onEditCheckout = onEditCheckout,
onEditSignature = onEditSignature,
onOpenRazorpayQr = onOpenRazorpayQr,
onOpenPayments = onOpenPayments
@@ -178,11 +184,11 @@ fun BookingDetailsTabsScreen(
@Composable
private fun GuestInfoTabContent(
propertyId: String,
bookingId: String,
details: BookingDetailsResponse?,
guestId: String?,
isLoading: Boolean,
error: String?,
onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit
@@ -190,6 +196,67 @@ private fun GuestInfoTabContent(
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
val pickerTimeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
val scope = rememberCoroutineScope()
val showCheckInDatePicker = remember { mutableStateOf(false) }
val showCheckInTimePicker = remember { mutableStateOf(false) }
val showCheckOutDatePicker = remember { mutableStateOf(false) }
val showCheckOutTimePicker = remember { mutableStateOf(false) }
val isUpdatingDates = remember { mutableStateOf(false) }
val updateDatesError = remember { mutableStateOf<String?>(null) }
val draftCheckInAt = remember { mutableStateOf<OffsetDateTime?>(null) }
val draftCheckOutAt = remember { mutableStateOf<OffsetDateTime?>(null) }
val today = remember(displayZone) { LocalDate.now(displayZone) }
val checkInFromDetails = details?.checkInAt ?: details?.expectedCheckInAt
val checkOutFromDetails = details?.expectedCheckOutAt ?: details?.checkOutAt
val bookingStatus = details?.status?.uppercase()
val canEditCheckIn = bookingStatus == "OPEN"
val canEditCheckOut = bookingStatus == "OPEN" || bookingStatus == "CHECKED_IN"
LaunchedEffect(checkInFromDetails, checkOutFromDetails) {
draftCheckInAt.value = checkInFromDetails
?.takeIf { it.isNotBlank() }
?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() }
draftCheckOutAt.value = checkOutFromDetails
?.takeIf { it.isNotBlank() }
?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() }
}
fun submitExpectedDatesUpdate(updatedCheckInAt: OffsetDateTime?, updatedCheckOutAt: OffsetDateTime?) {
val bookingStatus = details?.status?.uppercase() ?: return
if (bookingStatus != "OPEN" && bookingStatus != "CHECKED_IN") return
scope.launch {
isUpdatingDates.value = true
updateDatesError.value = null
try {
val body = when (bookingStatus) {
"OPEN" -> BookingExpectedDatesRequest(
expectedCheckInAt = updatedCheckInAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
else -> BookingExpectedDatesRequest(
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
}
val response = ApiClient.create().updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = body
)
if (response.isSuccessful) {
draftCheckInAt.value = updatedCheckInAt
draftCheckOutAt.value = updatedCheckOutAt
} else {
updateDatesError.value = "Update failed: ${response.code()}"
}
} catch (e: Exception) {
updateDatesError.value = e.localizedMessage ?: "Update failed"
} finally {
isUpdatingDates.value = false
}
}
}
Column(
modifier = Modifier
@@ -221,37 +288,46 @@ private fun GuestInfoTabContent(
GuestDetailRow(label = "Mode of transport", value = details?.transportMode)
}
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)}" }
val parsedCheckIn = draftCheckInAt.value
val parsedCheckOut = draftCheckOutAt.value
val zonedCheckIn = parsedCheckIn?.atZoneSameInstant(displayZone)
val zonedCheckOut = parsedCheckOut?.atZoneSameInstant(displayZone)
if (zonedCheckIn != null || zonedCheckOut != null) {
BookingDateTimeQuickEditorCard(
checkInDateText = zonedCheckIn?.format(dateFormatter) ?: "--/--/----",
checkInTimeText = zonedCheckIn?.format(timeFormatter) ?: "--:--",
checkOutDateText = zonedCheckOut?.format(dateFormatter) ?: "--/--/----",
checkOutTimeText = zonedCheckOut?.format(timeFormatter) ?: "--:--",
totalTimeText = formatBookingDurationText(parsedCheckIn, parsedCheckOut),
checkInEditable = canEditCheckIn,
checkOutEditable = canEditCheckOut,
onCheckInDateClick = {
if (canEditCheckIn) showCheckInDatePicker.value = true
},
onCheckInTimeClick = {
if (canEditCheckIn) showCheckInTimePicker.value = true
},
onCheckOutDateClick = {
if (canEditCheckOut) showCheckOutDatePicker.value = true
},
onCheckOutTimeClick = {
if (canEditCheckOut) showCheckOutTimePicker.value = true
}
)
}
if (!checkOut.isNullOrBlank()) {
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
GuestDetailRow(
label = "Estimated Check Out Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }
)
}
IconButton(
onClick = {
onEditCheckout(details?.expectedCheckInAt, details?.expectedCheckOutAt)
}
) {
Icon(Icons.Default.Edit, contentDescription = "Edit checkout")
}
if (isUpdatingDates.value) {
Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator()
}
updateDatesError.value?.let { message ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
}
val billingMode = BookingBillingMode.from(details?.billingMode)
GuestDetailRow(label = "Billing Mode", value = details?.billingMode)
@@ -325,6 +401,106 @@ private fun GuestInfoTabContent(
onEditSignature = onEditSignature
)
}
if (showCheckInDatePicker.value && canEditCheckIn) {
BookingDatePickerDialog(
initialDate = draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate() ?: today,
minDate = today,
onDismiss = { showCheckInDatePicker.value = false },
onDateSelected = { selectedDate ->
val selectedTime = draftCheckInAt.value
?.atZoneSameInstant(displayZone)
?.format(pickerTimeFormatter)
?: "12:00"
val updatedCheckIn = runCatching {
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
}.getOrNull() ?: return@BookingDatePickerDialog
val currentCheckOut = draftCheckOutAt.value
val adjustedCheckOut = if (currentCheckOut != null && !currentCheckOut.isAfter(updatedCheckIn)) {
runCatching {
OffsetDateTime.parse(formatBookingIso(selectedDate.plusDays(1), selectedTime))
}.getOrNull()
} else {
currentCheckOut
}
submitExpectedDatesUpdate(updatedCheckIn, adjustedCheckOut)
}
)
}
if (showCheckInTimePicker.value && canEditCheckIn) {
BookingTimePickerDialog(
initialTime = draftCheckInAt.value
?.atZoneSameInstant(displayZone)
?.format(pickerTimeFormatter)
?: "12:00",
onDismiss = { showCheckInTimePicker.value = false },
onTimeSelected = { selectedTime ->
val selectedDate = draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate() ?: today
val updatedCheckIn = runCatching {
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
}.getOrNull() ?: return@BookingTimePickerDialog
val currentCheckOut = draftCheckOutAt.value
val adjustedCheckOut = if (currentCheckOut != null && !currentCheckOut.isAfter(updatedCheckIn)) {
runCatching {
OffsetDateTime.parse(formatBookingIso(selectedDate.plusDays(1), selectedTime))
}.getOrNull()
} else {
currentCheckOut
}
submitExpectedDatesUpdate(updatedCheckIn, adjustedCheckOut)
}
)
}
if (showCheckOutDatePicker.value && canEditCheckOut) {
val checkInDate = draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate()
val checkoutMinDate = maxOf(checkInDate ?: today, today)
BookingDatePickerDialog(
initialDate = draftCheckOutAt.value?.atZoneSameInstant(displayZone)?.toLocalDate() ?: checkoutMinDate,
minDate = checkoutMinDate,
onDismiss = { showCheckOutDatePicker.value = false },
onDateSelected = { selectedDate ->
val selectedTime = draftCheckOutAt.value
?.atZoneSameInstant(displayZone)
?.format(pickerTimeFormatter)
?: "11:00"
val updatedCheckOut = runCatching {
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
}.getOrNull() ?: return@BookingDatePickerDialog
val currentCheckIn = draftCheckInAt.value
if (currentCheckIn != null && !updatedCheckOut.isAfter(currentCheckIn)) {
updateDatesError.value = "Check-out must be after check-in"
return@BookingDatePickerDialog
}
submitExpectedDatesUpdate(currentCheckIn, updatedCheckOut)
}
)
}
if (showCheckOutTimePicker.value && canEditCheckOut) {
BookingTimePickerDialog(
initialTime = draftCheckOutAt.value
?.atZoneSameInstant(displayZone)
?.format(pickerTimeFormatter)
?: "11:00",
onDismiss = { showCheckOutTimePicker.value = false },
onTimeSelected = { selectedTime ->
val selectedDate = draftCheckOutAt.value?.atZoneSameInstant(displayZone)?.toLocalDate()
?: draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate()
?: today
val updatedCheckOut = runCatching {
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
}.getOrNull() ?: return@BookingTimePickerDialog
val currentCheckIn = draftCheckInAt.value
if (currentCheckIn != null && !updatedCheckOut.isAfter(currentCheckIn)) {
updateDatesError.value = "Check-out must be after check-in"
return@BookingTimePickerDialog
}
submitExpectedDatesUpdate(currentCheckIn, updatedCheckOut)
}
)
}
}
@Composable