diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt index 4c79abb..d04c37a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt @@ -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(null) } val checkOutDate = remember { mutableStateOf(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 } ) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingDateTimeQuickEditor.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingDateTimeQuickEditor.kt new file mode 100644 index 0000000..e225139 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingDateTimeQuickEditor.kt @@ -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" + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt deleted file mode 100644 index 3e2c161..0000000 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt +++ /dev/null @@ -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(null) } - val checkOutDate = remember { mutableStateOf(null) } - val checkInTime = remember { mutableStateOf("12:00") } - val checkOutTime = remember { mutableStateOf("11:00") } - val isLoading = remember { mutableStateOf(false) } - val error = remember { mutableStateOf(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(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() - } -} diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt index 974c850..d6930e4 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt index 13f1429..fa69641 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt index cd4d66f..4765675 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index 177325b..6ece699 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -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(null) } + val draftCheckInAt = remember { mutableStateOf(null) } + val draftCheckOutAt = remember { mutableStateOf(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