booking: ability to edit more guest info
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package com.android.trisolarispms.core.booking
|
||||
|
||||
object BookingProfileOptions {
|
||||
val memberRelations: List<String> = listOf(
|
||||
"FRIENDS",
|
||||
"FAMILY",
|
||||
"GROUP",
|
||||
"ALONE"
|
||||
)
|
||||
|
||||
val transportModes: List<String> = listOf(
|
||||
"",
|
||||
"CAR",
|
||||
"BIKE",
|
||||
"TRAIN",
|
||||
"PLANE",
|
||||
"BUS",
|
||||
"FOOT",
|
||||
"CYCLE",
|
||||
"OTHER"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.android.trisolarispms.core.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CitySearchController(
|
||||
private val scope: CoroutineScope,
|
||||
private val onUpdate: (isLoading: Boolean, suggestions: List<String>) -> Unit,
|
||||
private val search: suspend (query: String, limit: Int) -> List<String>,
|
||||
private val minQueryLength: Int = 2,
|
||||
private val defaultLimit: Int = 20,
|
||||
private val debounceMs: Long = 300L
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
fun onQueryChanged(rawQuery: String) {
|
||||
val query = rawQuery.trim()
|
||||
job?.cancel()
|
||||
if (query.length < minQueryLength) {
|
||||
onUpdate(false, emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
job = scope.launch {
|
||||
delay(debounceMs)
|
||||
if (!isActive) return@launch
|
||||
onUpdate(true, emptyList())
|
||||
try {
|
||||
val suggestions = search(query, defaultLimit)
|
||||
if (isActive) {
|
||||
onUpdate(false, suggestions)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
if (isActive) {
|
||||
onUpdate(false, emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.android.trisolarispms.data.api.service.CancellationPolicyApi
|
||||
import com.android.trisolarispms.data.api.service.CardApi
|
||||
import com.android.trisolarispms.data.api.service.GuestApi
|
||||
import com.android.trisolarispms.data.api.service.GuestDocumentApi
|
||||
import com.android.trisolarispms.data.api.service.GeoApi
|
||||
import com.android.trisolarispms.data.api.service.ImageTagApi
|
||||
import com.android.trisolarispms.data.api.service.InboundEmailApi
|
||||
import com.android.trisolarispms.data.api.service.PropertyApi
|
||||
@@ -33,6 +34,7 @@ interface ApiService :
|
||||
CardApi,
|
||||
GuestApi,
|
||||
GuestDocumentApi,
|
||||
GeoApi,
|
||||
TransportApi,
|
||||
InboundEmailApi,
|
||||
AmenityApi,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.api.core
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
|
||||
object GeoSearchRepository {
|
||||
suspend fun searchCityDisplayValues(
|
||||
query: String,
|
||||
limit: Int = 20
|
||||
): List<String> {
|
||||
val response = ApiClient.create().searchCities(query = query, limit = limit)
|
||||
if (!response.isSuccessful) return emptyList()
|
||||
return response.body()
|
||||
.orEmpty()
|
||||
.mapNotNull(::extractCityDisplayValue)
|
||||
.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractCityDisplayValue(element: JsonElement): String? {
|
||||
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
|
||||
return element.asString.trim().ifBlank { null }
|
||||
}
|
||||
if (!element.isJsonObject) return null
|
||||
|
||||
val obj = element.asJsonObject
|
||||
val city = obj.get("city")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
|
||||
?: return null
|
||||
val state = obj.get("state")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
|
||||
return if (state == null) city else "$city, $state"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class CitySearchItemDto(
|
||||
val city: String? = null,
|
||||
val state: String? = null
|
||||
)
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GuestDto(
|
||||
val id: String? = null,
|
||||
val name: String? = null,
|
||||
val phoneE164: String? = null,
|
||||
@SerializedName(value = "dob", alternate = ["age"])
|
||||
val dob: String? = null,
|
||||
val nationality: String? = null,
|
||||
val age: String? = null,
|
||||
val addressText: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val averageScore: Double? = null
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
|
||||
import com.google.gson.JsonObject
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
|
||||
@@ -65,6 +66,13 @@ interface BookingApi {
|
||||
@Body body: BookingExpectedDatesRequest
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/profile")
|
||||
suspend fun updateBookingProfile(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("bookingId") bookingId: String,
|
||||
@Body body: JsonObject
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
|
||||
suspend fun createRoomRequest(
|
||||
@Path("propertyId") propertyId: String,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.android.trisolarispms.data.api.service
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface GeoApi {
|
||||
@GET("geo/cities/search")
|
||||
suspend fun searchCities(
|
||||
@Query("q") query: String,
|
||||
@Query("limit") limit: Int = 20
|
||||
): Response<List<JsonElement>>
|
||||
|
||||
@GET("geo/countries/search")
|
||||
suspend fun searchCountries(
|
||||
@Query("q") query: String,
|
||||
@Query("limit") limit: Int = 20
|
||||
): Response<List<String>>
|
||||
}
|
||||
@@ -30,26 +30,16 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
|
||||
import com.android.trisolarispms.ui.booking.phoneCountryOptions
|
||||
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
|
||||
val phoneCountries = remember { phoneCountryOptions() }
|
||||
val phoneCountrySearch = remember { mutableStateOf("") }
|
||||
val now = remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
val hasNetwork = remember(context, state.error) { hasInternetConnection(context) }
|
||||
val noNetworkError = state.error?.contains("No internet connection", ignoreCase = true) == true
|
||||
@@ -97,69 +87,12 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
||||
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
|
||||
modifier = Modifier.weight(0.35f)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Country") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onDismissRequest = { phoneCountryMenuExpanded.value = false }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = phoneCountrySearch.value,
|
||||
onValueChange = { phoneCountrySearch.value = it },
|
||||
label = { Text("Search") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
val filtered = phoneCountries.filter { option ->
|
||||
val query = phoneCountrySearch.value.trim()
|
||||
if (query.isBlank()) true
|
||||
else option.name.contains(query, ignoreCase = true) ||
|
||||
option.code.contains(query, ignoreCase = true) ||
|
||||
option.dialCode.contains(query)
|
||||
}
|
||||
filtered.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.name} (+${option.dialCode})") },
|
||||
onClick = {
|
||||
phoneCountryMenuExpanded.value = false
|
||||
phoneCountrySearch.value = ""
|
||||
viewModel.onPhoneCountryChange(option.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.phoneNationalNumber,
|
||||
onValueChange = viewModel::onPhoneNationalNumberChange,
|
||||
label = { Text("Number") },
|
||||
prefix = { Text("+${selectedCountry.dialCode}") },
|
||||
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.weight(0.65f)
|
||||
)
|
||||
}
|
||||
PhoneNumberCountryField(
|
||||
phoneCountryCode = state.phoneCountryCode,
|
||||
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
|
||||
phoneNationalNumber = state.phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true
|
||||
|
||||
@@ -31,7 +31,10 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.core.booking.BookingProfileOptions
|
||||
import com.android.trisolarispms.ui.common.CityAutocompleteField
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
@@ -55,15 +58,10 @@ fun BookingCreateScreen(
|
||||
val checkInTime = remember { mutableStateOf("12:00") }
|
||||
val checkOutTime = remember { mutableStateOf("11:00") }
|
||||
val relationMenuExpanded = remember { mutableStateOf(false) }
|
||||
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
|
||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||
val transportOptions = listOf("", "CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
|
||||
val billingModeMenuExpanded = remember { mutableStateOf(false) }
|
||||
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
|
||||
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
|
||||
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
|
||||
val phoneCountries = remember { phoneCountryOptions() }
|
||||
val phoneCountrySearch = remember { mutableStateOf("") }
|
||||
|
||||
val applyCheckInSelection: (LocalDate, String) -> Unit = { date, time ->
|
||||
checkInDate.value = date
|
||||
@@ -226,85 +224,31 @@ fun BookingCreateScreen(
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
|
||||
modifier = Modifier.weight(0.3f)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Country") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onDismissRequest = { phoneCountryMenuExpanded.value = false }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = phoneCountrySearch.value,
|
||||
onValueChange = { phoneCountrySearch.value = it },
|
||||
label = { Text("Search") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
val filteredCountries = phoneCountries.filter { option ->
|
||||
val query = phoneCountrySearch.value.trim()
|
||||
if (query.isBlank()) {
|
||||
true
|
||||
} else {
|
||||
option.name.contains(query, ignoreCase = true) ||
|
||||
option.code.contains(query, ignoreCase = true) ||
|
||||
option.dialCode.contains(query)
|
||||
}
|
||||
}
|
||||
filteredCountries.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.name} (+${option.dialCode})") },
|
||||
onClick = {
|
||||
phoneCountryMenuExpanded.value = false
|
||||
phoneCountrySearch.value = ""
|
||||
viewModel.onPhoneCountryChange(option.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.phoneNationalNumber,
|
||||
onValueChange = viewModel::onPhoneNationalNumberChange,
|
||||
label = { Text("Number") },
|
||||
prefix = { Text("+${selectedCountry.dialCode}") },
|
||||
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.weight(0.7f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.fromCity,
|
||||
onValueChange = viewModel::onFromCityChange,
|
||||
label = { Text("From City (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
PhoneNumberCountryField(
|
||||
phoneCountryCode = state.phoneCountryCode,
|
||||
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
|
||||
phoneNationalNumber = state.phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
|
||||
countryWeight = 0.3f,
|
||||
numberWeight = 0.7f
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
CityAutocompleteField(
|
||||
value = state.fromCity,
|
||||
onValueChange = viewModel::onFromCityChange,
|
||||
label = "From City (optional)",
|
||||
suggestions = state.fromCitySuggestions,
|
||||
isLoading = state.isFromCitySearchLoading,
|
||||
onSuggestionSelected = viewModel::onFromCitySuggestionSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CityAutocompleteField(
|
||||
value = state.toCity,
|
||||
onValueChange = viewModel::onToCityChange,
|
||||
label = { Text("To City (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
label = "To City (optional)",
|
||||
suggestions = state.toCitySuggestions,
|
||||
isLoading = state.isToCitySearchLoading,
|
||||
onSuggestionSelected = viewModel::onToCitySuggestionSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
@@ -327,7 +271,7 @@ fun BookingCreateScreen(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onDismissRequest = { relationMenuExpanded.value = false }
|
||||
) {
|
||||
relationOptions.forEach { option ->
|
||||
BookingProfileOptions.memberRelations.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
@@ -359,7 +303,7 @@ fun BookingCreateScreen(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onDismissRequest = { transportMenuExpanded.value = false }
|
||||
) {
|
||||
transportOptions.forEach { option ->
|
||||
BookingProfileOptions.transportModes.forEach { option ->
|
||||
val optionLabel = option.ifBlank { "Not set" }
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel) },
|
||||
|
||||
@@ -14,7 +14,11 @@ data class BookingCreateState(
|
||||
val billingCheckoutTime: String = "",
|
||||
val source: String = "",
|
||||
val fromCity: String = "",
|
||||
val fromCitySuggestions: List<String> = emptyList(),
|
||||
val isFromCitySearchLoading: Boolean = false,
|
||||
val toCity: String = "",
|
||||
val toCitySuggestions: List<String> = emptyList(),
|
||||
val isToCitySearchLoading: Boolean = false,
|
||||
val memberRelation: String = "",
|
||||
val transportMode: String = "CAR",
|
||||
val isTransportModeAuto: Boolean = true,
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.android.trisolarispms.ui.booking
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
|
||||
import com.android.trisolarispms.core.viewmodel.CitySearchController
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.GeoSearchRepository
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||
import com.android.trisolarispms.data.api.model.BookingCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingCreateResponse
|
||||
@@ -23,9 +25,39 @@ class BookingCreateViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(BookingCreateState())
|
||||
val state: StateFlow<BookingCreateState> = _state
|
||||
private var expectedCheckoutPreviewRequestId: Long = 0
|
||||
private val fromCitySearch = CitySearchController(
|
||||
scope = viewModelScope,
|
||||
onUpdate = { isLoading, suggestions ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isFromCitySearchLoading = isLoading,
|
||||
fromCitySuggestions = suggestions
|
||||
)
|
||||
}
|
||||
},
|
||||
search = { query, limit ->
|
||||
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
|
||||
}
|
||||
)
|
||||
private val toCitySearch = CitySearchController(
|
||||
scope = viewModelScope,
|
||||
onUpdate = { isLoading, suggestions ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isToCitySearchLoading = isLoading,
|
||||
toCitySuggestions = suggestions
|
||||
)
|
||||
}
|
||||
},
|
||||
search = { query, limit ->
|
||||
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
|
||||
}
|
||||
)
|
||||
|
||||
fun reset() {
|
||||
expectedCheckoutPreviewRequestId = 0
|
||||
fromCitySearch.cancel()
|
||||
toCitySearch.cancel()
|
||||
_state.value = BookingCreateState()
|
||||
}
|
||||
|
||||
@@ -194,10 +226,36 @@ class BookingCreateViewModel : ViewModel() {
|
||||
|
||||
fun onFromCityChange(value: String) {
|
||||
_state.update { it.copy(fromCity = value, error = null) }
|
||||
fromCitySearch.onQueryChanged(value)
|
||||
}
|
||||
|
||||
fun onToCityChange(value: String) {
|
||||
_state.update { it.copy(toCity = value, error = null) }
|
||||
toCitySearch.onQueryChanged(value)
|
||||
}
|
||||
|
||||
fun onFromCitySuggestionSelected(value: String) {
|
||||
fromCitySearch.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
fromCity = value,
|
||||
fromCitySuggestions = emptyList(),
|
||||
isFromCitySearchLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToCitySuggestionSelected(value: String) {
|
||||
toCitySearch.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
toCity = value,
|
||||
toCitySuggestions = emptyList(),
|
||||
isToCitySearchLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onMemberRelationChange(value: String) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CityAutocompleteField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
suggestions: List<String>,
|
||||
isLoading: Boolean,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
val query = value.trim()
|
||||
val canShowMenu = expanded.value && (isLoading || suggestions.isNotEmpty() || query.length >= 2)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = canShowMenu,
|
||||
onExpandedChange = { expanded.value = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { input ->
|
||||
onValueChange(input)
|
||||
expanded.value = input.trim().length >= 2
|
||||
},
|
||||
label = { Text(label) },
|
||||
supportingText = {
|
||||
if (query.length < 2) {
|
||||
Text("Type at least 2 letters")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = expanded.value && (isLoading || suggestions.isNotEmpty())
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = canShowMenu,
|
||||
onDismissRequest = { expanded.value = false }
|
||||
) {
|
||||
if (isLoading) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Searching...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else if (query.length >= 2 && suggestions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No cities found") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
suggestions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
expanded.value = false
|
||||
onSuggestionSelected(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
|
||||
import com.android.trisolarispms.ui.booking.phoneCountryOptions
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PhoneNumberCountryField(
|
||||
phoneCountryCode: String,
|
||||
onPhoneCountryCodeChange: (String) -> Unit,
|
||||
phoneNationalNumber: String,
|
||||
onPhoneNationalNumberChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
countryLabel: String = "Country",
|
||||
numberLabel: String = "Number",
|
||||
countryWeight: Float = 0.35f,
|
||||
numberWeight: Float = 0.65f
|
||||
) {
|
||||
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
|
||||
val phoneCountrySearch = remember { mutableStateOf("") }
|
||||
val phoneCountries = remember { phoneCountryOptions() }
|
||||
val selectedCountry = findPhoneCountryOption(phoneCountryCode)
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
|
||||
modifier = Modifier.weight(countryWeight)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(countryLabel) },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onDismissRequest = { phoneCountryMenuExpanded.value = false }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = phoneCountrySearch.value,
|
||||
onValueChange = { phoneCountrySearch.value = it },
|
||||
label = { Text("Search") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val filtered = phoneCountries.filter { option ->
|
||||
val query = phoneCountrySearch.value.trim()
|
||||
if (query.isBlank()) {
|
||||
true
|
||||
} else {
|
||||
option.name.contains(query, ignoreCase = true) ||
|
||||
option.code.contains(query, ignoreCase = true) ||
|
||||
option.dialCode.contains(query)
|
||||
}
|
||||
}
|
||||
filtered.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.name} (+${option.dialCode})") },
|
||||
onClick = {
|
||||
phoneCountryMenuExpanded.value = false
|
||||
phoneCountrySearch.value = ""
|
||||
onPhoneCountryCodeChange(option.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = phoneNationalNumber,
|
||||
onValueChange = onPhoneNationalNumberChange,
|
||||
label = { Text(numberLabel) },
|
||||
prefix = { Text("+${selectedCountry.dialCode}") },
|
||||
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.weight(numberWeight),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
@@ -89,13 +91,20 @@ fun SaveTopBarScaffold(
|
||||
fun PaddedScreenColumn(
|
||||
padding: PaddingValues,
|
||||
contentPadding: Dp = 24.dp,
|
||||
scrollable: Boolean = false,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val baseModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(contentPadding)
|
||||
val scrollModifier = if (scrollable) {
|
||||
baseModifier.verticalScroll(rememberScrollState())
|
||||
} else {
|
||||
baseModifier
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(contentPadding),
|
||||
modifier = scrollModifier,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
content = content
|
||||
)
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.core.booking.BookingProfileOptions
|
||||
import com.android.trisolarispms.ui.common.CityAutocompleteField
|
||||
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GuestInfoFormFields(
|
||||
phoneCountryCode: String,
|
||||
onPhoneCountryCodeChange: (String) -> Unit,
|
||||
phoneNationalNumber: String,
|
||||
onPhoneNationalNumberChange: (String) -> Unit,
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
nationality: String,
|
||||
onNationalityChange: (String) -> Unit,
|
||||
nationalitySuggestions: List<String>,
|
||||
isNationalitySearchLoading: Boolean,
|
||||
onNationalitySuggestionSelected: (String) -> Unit,
|
||||
age: String,
|
||||
onAgeChange: (String) -> Unit,
|
||||
addressText: String,
|
||||
onAddressChange: (String) -> Unit,
|
||||
fromCity: String,
|
||||
onFromCityChange: (String) -> Unit,
|
||||
fromCitySuggestions: List<String>,
|
||||
isFromCitySearchLoading: Boolean,
|
||||
onFromCitySuggestionSelected: (String) -> Unit,
|
||||
toCity: String,
|
||||
onToCityChange: (String) -> Unit,
|
||||
toCitySuggestions: List<String>,
|
||||
isToCitySearchLoading: Boolean,
|
||||
onToCitySuggestionSelected: (String) -> Unit,
|
||||
memberRelation: String,
|
||||
onMemberRelationChange: (String) -> Unit,
|
||||
transportMode: String,
|
||||
onTransportModeChange: (String) -> Unit,
|
||||
childCount: String,
|
||||
onChildCountChange: (String) -> Unit,
|
||||
maleCount: String,
|
||||
onMaleCountChange: (String) -> Unit,
|
||||
femaleCount: String,
|
||||
onFemaleCountChange: (String) -> Unit,
|
||||
vehicleNumbers: List<String>
|
||||
) {
|
||||
val showDobPicker = remember { mutableStateOf(false) }
|
||||
val nationalityMenuExpanded = remember { mutableStateOf(false) }
|
||||
val relationMenuExpanded = remember { mutableStateOf(false) }
|
||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||
val dobFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
val transportOptions = remember(vehicleNumbers) {
|
||||
if (vehicleNumbers.isNotEmpty()) {
|
||||
listOf("", "CAR", "BIKE")
|
||||
} else {
|
||||
BookingProfileOptions.transportModes
|
||||
}
|
||||
}
|
||||
var dobFieldValue by remember {
|
||||
mutableStateOf(TextFieldValue(text = age, selection = TextRange(age.length)))
|
||||
}
|
||||
|
||||
LaunchedEffect(age) {
|
||||
if (age != dobFieldValue.text) {
|
||||
dobFieldValue = TextFieldValue(text = age, selection = TextRange(age.length))
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PhoneNumberCountryField(
|
||||
phoneCountryCode = phoneCountryCode,
|
||||
onPhoneCountryCodeChange = onPhoneCountryCodeChange,
|
||||
phoneNationalNumber = phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = onPhoneNationalNumberChange,
|
||||
numberLabel = "Phone (optional)"
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
|
||||
onExpandedChange = { nationalityMenuExpanded.value = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = nationality,
|
||||
onValueChange = { value ->
|
||||
onNationalityChange(value)
|
||||
nationalityMenuExpanded.value = value.trim().length >= 3
|
||||
},
|
||||
label = { Text("Nationality (optional)") },
|
||||
supportingText = {
|
||||
if (nationality.trim().length < 3) {
|
||||
Text("Type at least 3 letters")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty())
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
|
||||
onDismissRequest = { nationalityMenuExpanded.value = false }
|
||||
) {
|
||||
if (isNationalitySearchLoading) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Searching...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else if (nationality.trim().length >= 3 && nationalitySuggestions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No countries found") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
nationalitySuggestions.forEach { suggestion ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(suggestion)
|
||||
},
|
||||
onClick = {
|
||||
nationalityMenuExpanded.value = false
|
||||
onNationalitySuggestionSelected(suggestion)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = dobFieldValue,
|
||||
onValueChange = { input ->
|
||||
val formatted = formatDobInput(input.text)
|
||||
dobFieldValue = TextFieldValue(
|
||||
text = formatted,
|
||||
selection = TextRange(formatted.length)
|
||||
)
|
||||
if (formatted != age) onAgeChange(formatted)
|
||||
},
|
||||
label = { Text("DOB (dd/MM/yyyy)") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CalendarMonth,
|
||||
contentDescription = "Pick DOB",
|
||||
modifier = Modifier.clickable { showDobPicker.value = true }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = addressText,
|
||||
onValueChange = onAddressChange,
|
||||
label = { Text("Address (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
CityAutocompleteField(
|
||||
value = fromCity,
|
||||
onValueChange = onFromCityChange,
|
||||
label = "From City (optional)",
|
||||
suggestions = fromCitySuggestions,
|
||||
isLoading = isFromCitySearchLoading,
|
||||
onSuggestionSelected = onFromCitySuggestionSelected
|
||||
)
|
||||
CityAutocompleteField(
|
||||
value = toCity,
|
||||
onValueChange = onToCityChange,
|
||||
label = "To City (optional)",
|
||||
suggestions = toCitySuggestions,
|
||||
isLoading = isToCitySearchLoading,
|
||||
onSuggestionSelected = onToCitySuggestionSelected
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = memberRelation.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Member Relation") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onDismissRequest = { relationMenuExpanded.value = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Not set") },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
onMemberRelationChange("")
|
||||
}
|
||||
)
|
||||
BookingProfileOptions.memberRelations.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
onMemberRelationChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = transportMode.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transport Mode") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = transportMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onDismissRequest = { transportMenuExpanded.value = false }
|
||||
) {
|
||||
transportOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.ifBlank { "Not set" }) },
|
||||
onClick = {
|
||||
transportMenuExpanded.value = false
|
||||
onTransportModeChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = childCount,
|
||||
onValueChange = onChildCountChange,
|
||||
label = { Text("Child Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = maleCount,
|
||||
onValueChange = onMaleCountChange,
|
||||
label = { Text("Male Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = femaleCount,
|
||||
onValueChange = onFemaleCountChange,
|
||||
label = { Text("Female Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDobPicker.value) {
|
||||
val initialDate = age.toLocalDateOrNull(dobFormatter) ?: LocalDate.now().minusYears(18)
|
||||
GuestDobDatePickerDialog(
|
||||
initialDate = initialDate,
|
||||
onDismiss = { showDobPicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
onAgeChange(selectedDate.format(dobFormatter))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuestDobDatePickerDialog(
|
||||
initialDate: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onDateSelected: (LocalDate) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState by rememberUpdatedState(onDismiss)
|
||||
val selectState by rememberUpdatedState(onDateSelected)
|
||||
|
||||
DisposableEffect(context, initialDate) {
|
||||
val dialog = DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, dayOfMonth ->
|
||||
selectState(LocalDate.of(year, month + 1, dayOfMonth))
|
||||
},
|
||||
initialDate.year,
|
||||
initialDate.monthValue - 1,
|
||||
initialDate.dayOfMonth
|
||||
)
|
||||
val todayMillis = LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
val minDateMillis = LocalDate.of(1900, 1, 1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
dialog.datePicker.maxDate = todayMillis
|
||||
dialog.datePicker.minDate = minDateMillis
|
||||
dialog.setOnDismissListener { dismissState() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLocalDateOrNull(formatter: DateTimeFormatter): LocalDate? =
|
||||
runCatching { LocalDate.parse(this, formatter) }.getOrNull()
|
||||
|
||||
private fun formatDobInput(raw: String): String {
|
||||
val digits = raw.filter { it.isDigit() }.take(8)
|
||||
if (digits.isEmpty()) return ""
|
||||
val builder = StringBuilder(digits.length + 2)
|
||||
digits.forEachIndexed { index, char ->
|
||||
builder.append(char)
|
||||
if ((index == 1 || index == 3) && index != digits.lastIndex) {
|
||||
builder.append('/')
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -22,6 +18,7 @@ import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
||||
@Composable
|
||||
fun GuestInfoScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
guestId: String,
|
||||
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
|
||||
initialPhone: String?,
|
||||
@@ -31,51 +28,75 @@ fun GuestInfoScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(guestId) {
|
||||
LaunchedEffect(propertyId, bookingId, guestId) {
|
||||
viewModel.reset()
|
||||
viewModel.setInitial(initialGuest, initialPhone)
|
||||
viewModel.loadGuest(propertyId, guestId, initialPhone)
|
||||
viewModel.loadGuest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fallbackPhone = initialPhone
|
||||
)
|
||||
}
|
||||
|
||||
SaveTopBarScaffold(
|
||||
title = "Guest Info",
|
||||
onBack = onBack,
|
||||
onSave = { viewModel.submit(propertyId, guestId, onSave) }
|
||||
onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
|
||||
) { padding ->
|
||||
PaddedScreenColumn(padding = padding) {
|
||||
OutlinedTextField(
|
||||
value = state.phoneE164,
|
||||
onValueChange = viewModel::onPhoneChange,
|
||||
label = { Text("Phone E164 (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = viewModel::onNameChange,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.nationality,
|
||||
onValueChange = viewModel::onNationalityChange,
|
||||
label = { Text("Nationality (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.age,
|
||||
onValueChange = viewModel::onAgeChange,
|
||||
label = { Text("DOB (dd/MM/yyyy)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.addressText,
|
||||
onValueChange = viewModel::onAddressChange,
|
||||
label = { Text("Address (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
PaddedScreenColumn(
|
||||
padding = padding,
|
||||
scrollable = true
|
||||
) {
|
||||
GuestInfoFormFields(
|
||||
phoneCountryCode = state.phoneCountryCode,
|
||||
onPhoneCountryCodeChange = { code ->
|
||||
viewModel.onPhoneCountryChange(
|
||||
value = code,
|
||||
propertyId = propertyId,
|
||||
guestId = guestId
|
||||
)
|
||||
},
|
||||
phoneNationalNumber = state.phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = { number ->
|
||||
viewModel.onPhoneNationalNumberChange(
|
||||
value = number,
|
||||
propertyId = propertyId,
|
||||
guestId = guestId
|
||||
)
|
||||
},
|
||||
name = state.name,
|
||||
onNameChange = viewModel::onNameChange,
|
||||
nationality = state.nationality,
|
||||
onNationalityChange = viewModel::onNationalityChange,
|
||||
nationalitySuggestions = state.nationalitySuggestions,
|
||||
isNationalitySearchLoading = state.isNationalitySearchLoading,
|
||||
onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
|
||||
age = state.age,
|
||||
onAgeChange = viewModel::onAgeChange,
|
||||
addressText = state.addressText,
|
||||
onAddressChange = viewModel::onAddressChange,
|
||||
fromCity = state.fromCity,
|
||||
onFromCityChange = viewModel::onFromCityChange,
|
||||
fromCitySuggestions = state.fromCitySuggestions,
|
||||
isFromCitySearchLoading = state.isFromCitySearchLoading,
|
||||
onFromCitySuggestionSelected = viewModel::onFromCitySuggestionSelected,
|
||||
toCity = state.toCity,
|
||||
onToCityChange = viewModel::onToCityChange,
|
||||
toCitySuggestions = state.toCitySuggestions,
|
||||
isToCitySearchLoading = state.isToCitySearchLoading,
|
||||
onToCitySuggestionSelected = viewModel::onToCitySuggestionSelected,
|
||||
memberRelation = state.memberRelation,
|
||||
onMemberRelationChange = viewModel::onMemberRelationChange,
|
||||
transportMode = state.transportMode,
|
||||
onTransportModeChange = viewModel::onTransportModeChange,
|
||||
childCount = state.childCount,
|
||||
onChildCountChange = viewModel::onChildCountChange,
|
||||
maleCount = state.maleCount,
|
||||
onMaleCountChange = viewModel::onMaleCountChange,
|
||||
femaleCount = state.femaleCount,
|
||||
onFemaleCountChange = viewModel::onFemaleCountChange,
|
||||
vehicleNumbers = state.vehicleNumbers
|
||||
)
|
||||
if (state.isLoading) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
data class GuestInfoState(
|
||||
val phoneE164: String = "",
|
||||
val phoneCountryCode: String = "IN",
|
||||
val phoneNationalNumber: String = "",
|
||||
val name: String = "",
|
||||
val nationality: String = "",
|
||||
val nationalitySuggestions: List<String> = emptyList(),
|
||||
val isNationalitySearchLoading: Boolean = false,
|
||||
val age: String = "",
|
||||
val addressText: String = "",
|
||||
val fromCity: String = "",
|
||||
val fromCitySuggestions: List<String> = emptyList(),
|
||||
val isFromCitySearchLoading: Boolean = false,
|
||||
val toCity: String = "",
|
||||
val toCitySuggestions: List<String> = emptyList(),
|
||||
val isToCitySearchLoading: Boolean = false,
|
||||
val memberRelation: String = "",
|
||||
val transportMode: String = "",
|
||||
val childCount: String = "",
|
||||
val maleCount: String = "",
|
||||
val femaleCount: String = "",
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
|
||||
@@ -2,9 +2,19 @@ package com.android.trisolarispms.ui.guest
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.core.viewmodel.CitySearchController
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.GeoSearchRepository
|
||||
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
|
||||
import com.android.trisolarispms.data.api.model.GuestDto
|
||||
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
|
||||
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
|
||||
import com.google.gson.JsonNull
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -13,13 +23,69 @@ import kotlinx.coroutines.launch
|
||||
class GuestInfoViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(GuestInfoState())
|
||||
val state: StateFlow<GuestInfoState> = _state
|
||||
private var nationalitySearchJob: Job? = null
|
||||
private var phoneAutofillJob: Job? = null
|
||||
private var lastAutofilledPhoneE164: String? = null
|
||||
private var initialBookingProfile: BookingProfileSnapshot? = null
|
||||
private val fromCitySearch = CitySearchController(
|
||||
scope = viewModelScope,
|
||||
onUpdate = { isLoading, suggestions ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isFromCitySearchLoading = isLoading,
|
||||
fromCitySuggestions = suggestions
|
||||
)
|
||||
}
|
||||
},
|
||||
search = { query, limit ->
|
||||
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
|
||||
}
|
||||
)
|
||||
private val toCitySearch = CitySearchController(
|
||||
scope = viewModelScope,
|
||||
onUpdate = { isLoading, suggestions ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isToCitySearchLoading = isLoading,
|
||||
toCitySuggestions = suggestions
|
||||
)
|
||||
}
|
||||
},
|
||||
search = { query, limit ->
|
||||
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
|
||||
}
|
||||
)
|
||||
|
||||
fun reset() {
|
||||
nationalitySearchJob?.cancel()
|
||||
nationalitySearchJob = null
|
||||
phoneAutofillJob?.cancel()
|
||||
phoneAutofillJob = null
|
||||
lastAutofilledPhoneE164 = null
|
||||
fromCitySearch.cancel()
|
||||
toCitySearch.cancel()
|
||||
initialBookingProfile = null
|
||||
_state.value = GuestInfoState()
|
||||
}
|
||||
|
||||
fun onPhoneChange(value: String) {
|
||||
_state.update { it.copy(phoneE164 = value, error = null) }
|
||||
fun onPhoneCountryChange(value: String, propertyId: String, guestId: String) {
|
||||
val option = findPhoneCountryOption(value)
|
||||
_state.update { current ->
|
||||
val trimmed = current.phoneNationalNumber.filter { it.isDigit() }.take(option.maxLength)
|
||||
current.copy(
|
||||
phoneCountryCode = option.code,
|
||||
phoneNationalNumber = trimmed,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
autoFillByPhoneIfExists(propertyId = propertyId, currentGuestId = guestId)
|
||||
}
|
||||
|
||||
fun onPhoneNationalNumberChange(value: String, propertyId: String, guestId: String) {
|
||||
val option = findPhoneCountryOption(_state.value.phoneCountryCode)
|
||||
val trimmed = value.filter { it.isDigit() }.take(option.maxLength)
|
||||
_state.update { it.copy(phoneNationalNumber = trimmed, error = null) }
|
||||
autoFillByPhoneIfExists(propertyId = propertyId, currentGuestId = guestId)
|
||||
}
|
||||
|
||||
fun onNameChange(value: String) {
|
||||
@@ -28,6 +94,19 @@ class GuestInfoViewModel : ViewModel() {
|
||||
|
||||
fun onNationalityChange(value: String) {
|
||||
_state.update { it.copy(nationality = value, error = null) }
|
||||
searchCountrySuggestions(value)
|
||||
}
|
||||
|
||||
fun onNationalitySuggestionSelected(suggestion: String) {
|
||||
nationalitySearchJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
nationality = suggestion,
|
||||
nationalitySuggestions = emptyList(),
|
||||
isNationalitySearchLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAgeChange(value: String) {
|
||||
@@ -38,13 +117,71 @@ class GuestInfoViewModel : ViewModel() {
|
||||
_state.update { it.copy(addressText = value, error = null) }
|
||||
}
|
||||
|
||||
fun setInitial(guest: GuestDto?, phone: String?) {
|
||||
fun onFromCityChange(value: String) {
|
||||
_state.update { it.copy(fromCity = value, error = null) }
|
||||
fromCitySearch.onQueryChanged(value)
|
||||
}
|
||||
|
||||
fun onToCityChange(value: String) {
|
||||
_state.update { it.copy(toCity = value, error = null) }
|
||||
toCitySearch.onQueryChanged(value)
|
||||
}
|
||||
|
||||
fun onFromCitySuggestionSelected(value: String) {
|
||||
fromCitySearch.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneE164 = guest?.phoneE164 ?: phone.orEmpty(),
|
||||
fromCity = value,
|
||||
fromCitySuggestions = emptyList(),
|
||||
isFromCitySearchLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToCitySuggestionSelected(value: String) {
|
||||
toCitySearch.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
toCity = value,
|
||||
toCitySuggestions = emptyList(),
|
||||
isToCitySearchLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onMemberRelationChange(value: String) {
|
||||
_state.update { it.copy(memberRelation = value, error = null) }
|
||||
}
|
||||
|
||||
fun onTransportModeChange(value: String) {
|
||||
_state.update { it.copy(transportMode = value, error = null) }
|
||||
}
|
||||
|
||||
fun onChildCountChange(value: String) {
|
||||
_state.update { it.copy(childCount = value.filter { char -> char.isDigit() }, error = null) }
|
||||
}
|
||||
|
||||
fun onMaleCountChange(value: String) {
|
||||
_state.update { it.copy(maleCount = value.filter { char -> char.isDigit() }, error = null) }
|
||||
}
|
||||
|
||||
fun onFemaleCountChange(value: String) {
|
||||
_state.update { it.copy(femaleCount = value.filter { char -> char.isDigit() }, error = null) }
|
||||
}
|
||||
|
||||
fun setInitial(guest: GuestDto?, phone: String?) {
|
||||
val parsedPhone = parsePhoneE164(guest?.phoneE164 ?: phone)
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneCountryCode = parsedPhone.countryCode,
|
||||
phoneNationalNumber = parsedPhone.nationalNumber,
|
||||
name = guest?.name.orEmpty(),
|
||||
nationality = guest?.nationality.orEmpty(),
|
||||
age = guest?.age.orEmpty(),
|
||||
nationalitySuggestions = emptyList(),
|
||||
isNationalitySearchLoading = false,
|
||||
age = guest?.dob.orEmpty(),
|
||||
addressText = guest?.addressText.orEmpty(),
|
||||
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
|
||||
error = null
|
||||
@@ -52,75 +189,416 @@ class GuestInfoViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGuest(propertyId: String, guestId: String, fallbackPhone: String?) {
|
||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
||||
fun loadGuest(propertyId: String, bookingId: String, guestId: String, fallbackPhone: String?) {
|
||||
if (propertyId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
var loadError: String? = null
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.getGuest(propertyId = propertyId, guestId = guestId)
|
||||
val guest = response.body()
|
||||
if (response.isSuccessful && guest != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneE164 = guest.phoneE164 ?: fallbackPhone.orEmpty(),
|
||||
name = guest.name.orEmpty(),
|
||||
nationality = guest.nationality.orEmpty(),
|
||||
age = guest.age.orEmpty(),
|
||||
addressText = guest.addressText.orEmpty(),
|
||||
vehicleNumbers = guest.vehicleNumbers ?: emptyList(),
|
||||
isLoading = false,
|
||||
error = null
|
||||
)
|
||||
if (guestId.isNotBlank()) {
|
||||
val guestResponse = api.getGuest(propertyId = propertyId, guestId = guestId)
|
||||
val guest = guestResponse.body()
|
||||
if (guestResponse.isSuccessful && guest != null) {
|
||||
val parsedPhone = parsePhoneE164(guest.phoneE164 ?: fallbackPhone)
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneCountryCode = parsedPhone.countryCode,
|
||||
phoneNationalNumber = parsedPhone.nationalNumber,
|
||||
name = guest.name.orEmpty(),
|
||||
nationality = guest.nationality.orEmpty(),
|
||||
nationalitySuggestions = emptyList(),
|
||||
isNationalitySearchLoading = false,
|
||||
age = guest.dob.orEmpty(),
|
||||
addressText = guest.addressText.orEmpty(),
|
||||
vehicleNumbers = guest.vehicleNumbers,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val parsedPhone = parsePhoneE164(fallbackPhone)
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
|
||||
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
|
||||
isNationalitySearchLoading = false
|
||||
)
|
||||
}
|
||||
loadError = "Load failed: ${guestResponse.code()}"
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
|
||||
isLoading = false,
|
||||
error = "Load failed: ${response.code()}"
|
||||
}
|
||||
|
||||
if (bookingId.isNotBlank()) {
|
||||
val detailsResponse = api.getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
val details = detailsResponse.body()
|
||||
if (detailsResponse.isSuccessful && details != null) {
|
||||
val snapshot = BookingProfileSnapshot(
|
||||
transportMode = details.transportMode?.trim()?.ifBlank { null },
|
||||
childCount = details.childCount,
|
||||
maleCount = details.maleCount,
|
||||
femaleCount = details.femaleCount,
|
||||
fromCity = details.fromCity?.trim()?.ifBlank { null },
|
||||
toCity = details.toCity?.trim()?.ifBlank { null },
|
||||
memberRelation = details.memberRelation?.trim()?.ifBlank { null }
|
||||
)
|
||||
initialBookingProfile = snapshot
|
||||
_state.update {
|
||||
it.copy(
|
||||
fromCity = snapshot.fromCity.orEmpty(),
|
||||
fromCitySuggestions = emptyList(),
|
||||
isFromCitySearchLoading = false,
|
||||
toCity = snapshot.toCity.orEmpty(),
|
||||
toCitySuggestions = emptyList(),
|
||||
isToCitySearchLoading = false,
|
||||
memberRelation = snapshot.memberRelation.orEmpty(),
|
||||
transportMode = snapshot.transportMode.orEmpty(),
|
||||
childCount = snapshot.childCount?.toString().orEmpty(),
|
||||
maleCount = snapshot.maleCount?.toString().orEmpty(),
|
||||
femaleCount = snapshot.femaleCount?.toString().orEmpty()
|
||||
)
|
||||
}
|
||||
} else if (loadError == null) {
|
||||
loadError = "Load failed: ${detailsResponse.code()}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
|
||||
isLoading = false,
|
||||
error = e.localizedMessage ?: "Load failed"
|
||||
)
|
||||
}
|
||||
loadError = e.localizedMessage ?: "Load failed"
|
||||
}
|
||||
val parsedPhone = parsePhoneE164(fallbackPhone)
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
|
||||
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
|
||||
isNationalitySearchLoading = false,
|
||||
isLoading = false,
|
||||
error = loadError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submit(propertyId: String, guestId: String, onDone: () -> Unit) {
|
||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
||||
fun submit(propertyId: String, bookingId: String, guestId: String, onDone: () -> Unit) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank() || guestId.isBlank()) return
|
||||
val current = state.value
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.updateGuest(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
body = GuestUpdateRequest(
|
||||
phoneE164 = current.phoneE164.trim().ifBlank { null },
|
||||
name = current.name.trim().ifBlank { null },
|
||||
nationality = current.nationality.trim().ifBlank { null },
|
||||
age = current.age.trim().ifBlank { null },
|
||||
addressText = current.addressText.trim().ifBlank { null }
|
||||
var submitError: String? = null
|
||||
val fullPhoneE164 = composePhoneE164(current)
|
||||
var matchedGuestToLinkId: String? = null
|
||||
if (!fullPhoneE164.isNullOrBlank() && submitError == null) {
|
||||
val searchResponse = api.searchGuests(
|
||||
propertyId = propertyId,
|
||||
phone = fullPhoneE164
|
||||
)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
if (searchResponse.isSuccessful) {
|
||||
matchedGuestToLinkId = searchResponse.body()
|
||||
.orEmpty()
|
||||
.firstOrNull { it.id != guestId }
|
||||
?.id
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedGuestToLinkId.isNullOrBlank() && submitError == null) {
|
||||
val linkResponse = api.linkGuest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingLinkGuestRequest(guestId = matchedGuestToLinkId)
|
||||
)
|
||||
if (!linkResponse.isSuccessful) {
|
||||
submitError = "Link failed: ${linkResponse.code()}"
|
||||
}
|
||||
} else if (submitError == null) {
|
||||
val countryOption = findPhoneCountryOption(current.phoneCountryCode)
|
||||
val nationalNumber = current.phoneNationalNumber.trim()
|
||||
val phoneE164 = if (nationalNumber.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
"+${countryOption.dialCode}$nationalNumber"
|
||||
}
|
||||
val response = api.updateGuest(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
body = GuestUpdateRequest(
|
||||
phoneE164 = phoneE164,
|
||||
name = current.name.trim().ifBlank { null },
|
||||
nationality = current.nationality.trim().ifBlank { null },
|
||||
age = current.age.trim().ifBlank { null },
|
||||
addressText = current.addressText.trim().ifBlank { null }
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
submitError = "Update failed: ${response.code()}"
|
||||
}
|
||||
}
|
||||
|
||||
if (submitError == null) {
|
||||
val profilePayload = buildBookingProfilePayload(current)
|
||||
if (profilePayload != null) {
|
||||
val profileResponse = api.updateBookingProfile(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = profilePayload
|
||||
)
|
||||
if (!profileResponse.isSuccessful) {
|
||||
submitError = "Profile update failed: ${profileResponse.code()}"
|
||||
} else {
|
||||
initialBookingProfile = profileSnapshotFromState(_state.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (submitError == null) {
|
||||
_state.update { it.copy(isLoading = false, error = null) }
|
||||
onDone()
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
|
||||
_state.update { it.copy(isLoading = false, error = submitError) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchCountrySuggestions(value: String) {
|
||||
nationalitySearchJob?.cancel()
|
||||
nationalitySearchJob = null
|
||||
|
||||
val query = value.trim()
|
||||
if (query.length < 3) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
nationalitySuggestions = emptyList(),
|
||||
isNationalitySearchLoading = false
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
nationalitySearchJob = viewModelScope.launch {
|
||||
delay(300)
|
||||
_state.update { current ->
|
||||
if (current.nationality.trim() != query) {
|
||||
current
|
||||
} else {
|
||||
current.copy(isNationalitySearchLoading = true)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val response = ApiClient.create().searchCountries(query = query, limit = 20)
|
||||
val suggestions = if (response.isSuccessful) response.body().orEmpty() else emptyList()
|
||||
_state.update { current ->
|
||||
if (current.nationality.trim() != query) {
|
||||
current
|
||||
} else {
|
||||
current.copy(
|
||||
nationalitySuggestions = suggestions,
|
||||
isNationalitySearchLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
_state.update { current ->
|
||||
if (current.nationality.trim() != query) {
|
||||
current
|
||||
} else {
|
||||
current.copy(
|
||||
nationalitySuggestions = emptyList(),
|
||||
isNationalitySearchLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun autoFillByPhoneIfExists(propertyId: String, currentGuestId: String) {
|
||||
if (propertyId.isBlank()) return
|
||||
val currentPhone = composePhoneE164(_state.value)
|
||||
if (currentPhone.isNullOrBlank()) {
|
||||
phoneAutofillJob?.cancel()
|
||||
phoneAutofillJob = null
|
||||
lastAutofilledPhoneE164 = null
|
||||
return
|
||||
}
|
||||
if (lastAutofilledPhoneE164 == currentPhone) return
|
||||
|
||||
phoneAutofillJob?.cancel()
|
||||
phoneAutofillJob = viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.create().searchGuests(
|
||||
propertyId = propertyId,
|
||||
phone = currentPhone
|
||||
)
|
||||
if (!response.isSuccessful) return@launch
|
||||
val guests = response.body().orEmpty()
|
||||
val matchedGuest = guests.firstOrNull { it.id != currentGuestId } ?: guests.firstOrNull()
|
||||
if (matchedGuest == null) {
|
||||
lastAutofilledPhoneE164 = currentPhone
|
||||
return@launch
|
||||
}
|
||||
_state.update { current ->
|
||||
if (composePhoneE164(current) != currentPhone) {
|
||||
current
|
||||
} else {
|
||||
current.copy(
|
||||
name = matchedGuest.name.orEmpty(),
|
||||
nationality = matchedGuest.nationality.orEmpty(),
|
||||
age = matchedGuest.dob.orEmpty(),
|
||||
addressText = matchedGuest.addressText.orEmpty(),
|
||||
vehicleNumbers = matchedGuest.vehicleNumbers,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
lastAutofilledPhoneE164 = currentPhone
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Ignore lookup failures; manual entry should continue uninterrupted.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBookingProfilePayload(current: GuestInfoState): JsonObject? {
|
||||
val currentSnapshot = profileSnapshotFromState(current)
|
||||
val initialSnapshot = initialBookingProfile
|
||||
val body = JsonObject()
|
||||
|
||||
fun putNullableStringIfChanged(
|
||||
key: String,
|
||||
currentValue: String?,
|
||||
initialValue: String?
|
||||
) {
|
||||
if (currentValue == initialValue) return
|
||||
if (currentValue == null) {
|
||||
body.add(key, JsonNull.INSTANCE)
|
||||
} else {
|
||||
body.addProperty(key, currentValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun putNullableIntIfChanged(
|
||||
key: String,
|
||||
currentValue: Int?,
|
||||
initialValue: Int?
|
||||
) {
|
||||
if (currentValue == initialValue) return
|
||||
if (currentValue == null) {
|
||||
body.add(key, JsonNull.INSTANCE)
|
||||
} else {
|
||||
body.addProperty(key, currentValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (initialSnapshot == null) {
|
||||
currentSnapshot.transportMode?.let { body.addProperty("transportMode", it) }
|
||||
currentSnapshot.childCount?.let { body.addProperty("childCount", it) }
|
||||
currentSnapshot.maleCount?.let { body.addProperty("maleCount", it) }
|
||||
currentSnapshot.femaleCount?.let { body.addProperty("femaleCount", it) }
|
||||
currentSnapshot.fromCity?.let { body.addProperty("fromCity", it) }
|
||||
currentSnapshot.toCity?.let { body.addProperty("toCity", it) }
|
||||
currentSnapshot.memberRelation?.let { body.addProperty("memberRelation", it) }
|
||||
return if (body.size() == 0) null else body
|
||||
}
|
||||
|
||||
putNullableStringIfChanged(
|
||||
key = "transportMode",
|
||||
currentValue = currentSnapshot.transportMode,
|
||||
initialValue = initialSnapshot.transportMode
|
||||
)
|
||||
putNullableIntIfChanged(
|
||||
key = "childCount",
|
||||
currentValue = currentSnapshot.childCount,
|
||||
initialValue = initialSnapshot.childCount
|
||||
)
|
||||
putNullableIntIfChanged(
|
||||
key = "maleCount",
|
||||
currentValue = currentSnapshot.maleCount,
|
||||
initialValue = initialSnapshot.maleCount
|
||||
)
|
||||
putNullableIntIfChanged(
|
||||
key = "femaleCount",
|
||||
currentValue = currentSnapshot.femaleCount,
|
||||
initialValue = initialSnapshot.femaleCount
|
||||
)
|
||||
putNullableStringIfChanged(
|
||||
key = "fromCity",
|
||||
currentValue = currentSnapshot.fromCity,
|
||||
initialValue = initialSnapshot.fromCity
|
||||
)
|
||||
putNullableStringIfChanged(
|
||||
key = "toCity",
|
||||
currentValue = currentSnapshot.toCity,
|
||||
initialValue = initialSnapshot.toCity
|
||||
)
|
||||
putNullableStringIfChanged(
|
||||
key = "memberRelation",
|
||||
currentValue = currentSnapshot.memberRelation,
|
||||
initialValue = initialSnapshot.memberRelation
|
||||
)
|
||||
|
||||
return if (body.size() == 0) null else body
|
||||
}
|
||||
}
|
||||
|
||||
private data class ParsedPhone(
|
||||
val countryCode: String,
|
||||
val nationalNumber: String
|
||||
)
|
||||
|
||||
private data class BookingProfileSnapshot(
|
||||
val transportMode: String?,
|
||||
val childCount: Int?,
|
||||
val maleCount: Int?,
|
||||
val femaleCount: Int?,
|
||||
val fromCity: String?,
|
||||
val toCity: String?,
|
||||
val memberRelation: String?
|
||||
)
|
||||
|
||||
private fun parsePhoneE164(phoneE164: String?): ParsedPhone {
|
||||
val fallback = ParsedPhone(countryCode = "IN", nationalNumber = "")
|
||||
val raw = phoneE164?.trim().orEmpty()
|
||||
if (raw.isBlank()) return fallback
|
||||
|
||||
val util = PhoneNumberUtil.getInstance()
|
||||
val parsed = runCatching { util.parse(raw, null) }.getOrNull()
|
||||
if (parsed != null) {
|
||||
val region = util.getRegionCodeForNumber(parsed)
|
||||
if (!region.isNullOrBlank()) {
|
||||
val option = findPhoneCountryOption(region)
|
||||
val national = util.getNationalSignificantNumber(parsed).orEmpty()
|
||||
.filter { it.isDigit() }
|
||||
.take(option.maxLength)
|
||||
return ParsedPhone(
|
||||
countryCode = option.code,
|
||||
nationalNumber = national
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val digitsOnly = raw.filter { it.isDigit() }.take(findPhoneCountryOption("IN").maxLength)
|
||||
return fallback.copy(nationalNumber = digitsOnly)
|
||||
}
|
||||
|
||||
private fun composePhoneE164(state: GuestInfoState): String? {
|
||||
val countryOption = findPhoneCountryOption(state.phoneCountryCode)
|
||||
val digits = state.phoneNationalNumber.trim()
|
||||
if (digits.length != countryOption.maxLength) return null
|
||||
return "+${countryOption.dialCode}$digits"
|
||||
}
|
||||
|
||||
private fun profileSnapshotFromState(state: GuestInfoState): BookingProfileSnapshot =
|
||||
BookingProfileSnapshot(
|
||||
transportMode = state.transportMode.trim().ifBlank { null },
|
||||
childCount = state.childCount.trim().toIntOrNull(),
|
||||
maleCount = state.maleCount.trim().toIntOrNull(),
|
||||
femaleCount = state.femaleCount.trim().toIntOrNull(),
|
||||
fromCity = state.fromCity.trim().ifBlank { null },
|
||||
toCity = state.toCity.trim().ifBlank { null },
|
||||
memberRelation = state.memberRelation.trim().ifBlank { null }
|
||||
)
|
||||
|
||||
@@ -4,6 +4,11 @@ sealed interface AppRoute {
|
||||
data object Home : AppRoute
|
||||
data class CreateBooking(val propertyId: String) : AppRoute
|
||||
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
|
||||
data class GuestInfoFromBookingDetails(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val guestId: String
|
||||
) : AppRoute
|
||||
data class GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
|
||||
data class ManageRoomStaySelect(
|
||||
val propertyId: String,
|
||||
|
||||
@@ -52,6 +52,11 @@ internal fun handleBackNavigation(
|
||||
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
|
||||
is AppRoute.GuestInfoFromBookingDetails -> refs.route.value = AppRoute.BookingDetailsTabs(
|
||||
currentRoute.propertyId,
|
||||
currentRoute.bookingId,
|
||||
currentRoute.guestId
|
||||
)
|
||||
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
|
||||
currentRoute.propertyId,
|
||||
currentRoute.bookingId,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.android.trisolarispms.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.android.trisolarispms.core.auth.AuthzPolicy
|
||||
import com.android.trisolarispms.ui.guest.GuestInfoScreen
|
||||
import com.android.trisolarispms.ui.navigation.AppRoute
|
||||
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
|
||||
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
|
||||
@@ -25,6 +26,13 @@ internal fun renderBookingRoutes(
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId,
|
||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
||||
onEditGuestInfo = { targetGuestId ->
|
||||
refs.route.value = AppRoute.GuestInfoFromBookingDetails(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = targetGuestId
|
||||
)
|
||||
},
|
||||
onEditSignature = { guestId ->
|
||||
refs.route.value = AppRoute.GuestSignature(
|
||||
currentRoute.propertyId,
|
||||
@@ -51,6 +59,28 @@ internal fun renderBookingRoutes(
|
||||
canCheckOutBooking = authz.canCheckOutBooking(currentRoute.propertyId)
|
||||
)
|
||||
|
||||
is AppRoute.GuestInfoFromBookingDetails -> GuestInfoScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId,
|
||||
initialGuest = refs.selectedGuest.value,
|
||||
initialPhone = refs.selectedGuestPhone.value,
|
||||
onBack = {
|
||||
refs.route.value = AppRoute.BookingDetailsTabs(
|
||||
currentRoute.propertyId,
|
||||
currentRoute.bookingId,
|
||||
currentRoute.guestId
|
||||
)
|
||||
},
|
||||
onSave = {
|
||||
refs.route.value = AppRoute.BookingDetailsTabs(
|
||||
currentRoute.propertyId,
|
||||
currentRoute.bookingId,
|
||||
currentRoute.guestId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
is AppRoute.BookingPayments -> BookingPaymentsScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
|
||||
@@ -120,6 +120,7 @@ internal fun renderHomeGuestRoutes(
|
||||
|
||||
is AppRoute.GuestInfo -> GuestInfoScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId,
|
||||
initialGuest = refs.selectedGuest.value,
|
||||
initialPhone = refs.selectedGuestPhone.value,
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
@@ -87,6 +88,7 @@ fun BookingDetailsTabsScreen(
|
||||
bookingId: String,
|
||||
guestId: String?,
|
||||
onBack: () -> Unit,
|
||||
onEditGuestInfo: (String) -> Unit,
|
||||
onEditSignature: (String) -> Unit,
|
||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||
onOpenPayments: () -> Unit,
|
||||
@@ -214,6 +216,7 @@ fun BookingDetailsTabsScreen(
|
||||
guestId = guestId,
|
||||
isLoading = detailsState.isLoading,
|
||||
error = detailsState.error,
|
||||
onEditGuestInfo = onEditGuestInfo,
|
||||
onEditSignature = onEditSignature,
|
||||
onOpenRazorpayQr = onOpenRazorpayQr,
|
||||
onOpenPayments = onOpenPayments
|
||||
@@ -396,6 +399,7 @@ private fun GuestInfoTabContent(
|
||||
guestId: String?,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onEditGuestInfo: (String) -> Unit,
|
||||
onEditSignature: (String) -> Unit,
|
||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||
onOpenPayments: () -> Unit
|
||||
@@ -420,6 +424,7 @@ private fun GuestInfoTabContent(
|
||||
val bookingStatus = details?.status?.uppercase()
|
||||
val canEditCheckIn = bookingStatus == "OPEN"
|
||||
val canEditCheckOut = bookingStatus == "OPEN" || bookingStatus == "CHECKED_IN"
|
||||
val resolvedGuestId = details?.guestId ?: guestId
|
||||
|
||||
LaunchedEffect(checkInFromDetails, checkOutFromDetails) {
|
||||
draftCheckInAt.value = checkInFromDetails
|
||||
@@ -479,7 +484,23 @@ private fun GuestInfoTabContent(
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
SectionCard(title = "Details") {
|
||||
SectionCard(
|
||||
title = "Details",
|
||||
headerContent = {
|
||||
IconButton(
|
||||
enabled = !resolvedGuestId.isNullOrBlank(),
|
||||
onClick = {
|
||||
resolvedGuestId?.let(onEditGuestInfo)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit guest details",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
GuestDetailRow(label = "Name", value = details?.guestName)
|
||||
GuestDetailRow(label = "Nationality", value = details?.guestNationality)
|
||||
GuestDetailRow(label = "Age", value = details?.guestAge?.let { formatAge(it) })
|
||||
@@ -600,7 +621,6 @@ private fun GuestInfoTabContent(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val resolvedGuestId = details?.guestId ?: guestId
|
||||
SignaturePreview(
|
||||
propertyId = propertyId,
|
||||
guestId = resolvedGuestId,
|
||||
|
||||
Reference in New Issue
Block a user