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

View File

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

View File

@@ -3,7 +3,6 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.AuthzPolicy import com.android.trisolarispms.core.auth.AuthzPolicy
import com.android.trisolarispms.ui.navigation.AppRoute 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.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
@@ -20,30 +19,11 @@ internal fun renderBookingRoutes(
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) } 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( is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }, 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 -> onEditSignature = { guestId ->
refs.route.value = AppRoute.GuestSignature( refs.route.value = AppRoute.GuestSignature(
currentRoute.propertyId, currentRoute.propertyId,

View File

@@ -18,7 +18,6 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.QrCode
import androidx.compose.material.icons.filled.ReceiptLong import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -33,10 +32,11 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -49,10 +49,17 @@ import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.request.ImageRequest 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.ApiConstants
import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingDetailsResponse 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.common.BackTopBarScaffold
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
@@ -72,7 +79,6 @@ fun BookingDetailsTabsScreen(
bookingId: String, bookingId: String,
guestId: String?, guestId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit, onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit, onOpenPayments: () -> Unit,
@@ -140,11 +146,11 @@ fun BookingDetailsTabsScreen(
when (page) { when (page) {
0 -> GuestInfoTabContent( 0 -> GuestInfoTabContent(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId,
details = detailsState.details, details = detailsState.details,
guestId = guestId, guestId = guestId,
isLoading = detailsState.isLoading, isLoading = detailsState.isLoading,
error = detailsState.error, error = detailsState.error,
onEditCheckout = onEditCheckout,
onEditSignature = onEditSignature, onEditSignature = onEditSignature,
onOpenRazorpayQr = onOpenRazorpayQr, onOpenRazorpayQr = onOpenRazorpayQr,
onOpenPayments = onOpenPayments onOpenPayments = onOpenPayments
@@ -178,11 +184,11 @@ fun BookingDetailsTabsScreen(
@Composable @Composable
private fun GuestInfoTabContent( private fun GuestInfoTabContent(
propertyId: String, propertyId: String,
bookingId: String,
details: BookingDetailsResponse?, details: BookingDetailsResponse?,
guestId: String?, guestId: String?,
isLoading: Boolean, isLoading: Boolean,
error: String?, error: String?,
onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit, onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit onOpenPayments: () -> Unit
@@ -190,6 +196,67 @@ private fun GuestInfoTabContent(
val displayZone = remember { ZoneId.of("Asia/Kolkata") } val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") } 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( Column(
modifier = Modifier modifier = Modifier
@@ -221,37 +288,46 @@ private fun GuestInfoTabContent(
GuestDetailRow(label = "Mode of transport", value = details?.transportMode) GuestDetailRow(label = "Mode of transport", value = details?.transportMode)
} }
val checkIn = details?.checkInAt ?: details?.expectedCheckInAt
val checkOut = details?.expectedCheckOutAt ?: details?.checkOutAt
SectionCard(title = "Stay") { SectionCard(title = "Stay") {
if (!checkIn.isNullOrBlank()) { val parsedCheckIn = draftCheckInAt.value
val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull() val parsedCheckOut = draftCheckOutAt.value
GuestDetailRow( val zonedCheckIn = parsedCheckIn?.atZoneSameInstant(displayZone)
label = "Check In Time", val zonedCheckOut = parsedCheckOut?.atZoneSameInstant(displayZone)
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" } 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 (isUpdatingDates.value) {
if (!checkOut.isNullOrBlank()) { Spacer(modifier = Modifier.height(8.dp))
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull() CircularProgressIndicator()
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")
}
} }
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) val billingMode = BookingBillingMode.from(details?.billingMode)
GuestDetailRow(label = "Billing Mode", value = details?.billingMode) GuestDetailRow(label = "Billing Mode", value = details?.billingMode)
@@ -325,6 +401,106 @@ private fun GuestInfoTabContent(
onEditSignature = onEditSignature 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 @Composable