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.CardApi
|
||||||
import com.android.trisolarispms.data.api.service.GuestApi
|
import com.android.trisolarispms.data.api.service.GuestApi
|
||||||
import com.android.trisolarispms.data.api.service.GuestDocumentApi
|
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.ImageTagApi
|
||||||
import com.android.trisolarispms.data.api.service.InboundEmailApi
|
import com.android.trisolarispms.data.api.service.InboundEmailApi
|
||||||
import com.android.trisolarispms.data.api.service.PropertyApi
|
import com.android.trisolarispms.data.api.service.PropertyApi
|
||||||
@@ -33,6 +34,7 @@ interface ApiService :
|
|||||||
CardApi,
|
CardApi,
|
||||||
GuestApi,
|
GuestApi,
|
||||||
GuestDocumentApi,
|
GuestDocumentApi,
|
||||||
|
GeoApi,
|
||||||
TransportApi,
|
TransportApi,
|
||||||
InboundEmailApi,
|
InboundEmailApi,
|
||||||
AmenityApi,
|
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
|
package com.android.trisolarispms.data.api.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
data class GuestDto(
|
data class GuestDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val phoneE164: String? = null,
|
val phoneE164: String? = null,
|
||||||
|
@SerializedName(value = "dob", alternate = ["age"])
|
||||||
|
val dob: String? = null,
|
||||||
val nationality: String? = null,
|
val nationality: String? = null,
|
||||||
val age: String? = null,
|
|
||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
val vehicleNumbers: List<String> = emptyList(),
|
val vehicleNumbers: List<String> = emptyList(),
|
||||||
val averageScore: Double? = null
|
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.BookingDetailsResponse
|
||||||
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
|
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
|
||||||
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
|
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.RazorpayQrEventDto
|
||||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||||
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
|
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
|
||||||
@@ -65,6 +66,13 @@ interface BookingApi {
|
|||||||
@Body body: BookingExpectedDatesRequest
|
@Body body: BookingExpectedDatesRequest
|
||||||
): Response<Unit>
|
): 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")
|
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
|
||||||
suspend fun createRoomRequest(
|
suspend fun createRoomRequest(
|
||||||
@Path("propertyId") propertyId: String,
|
@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.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||||
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 kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
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 now = remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
val hasNetwork = remember(context, state.error) { hasInternetConnection(context) }
|
val hasNetwork = remember(context, state.error) { hasInternetConnection(context) }
|
||||||
val noNetworkError = state.error?.contains("No internet connection", ignoreCase = true) == true
|
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)
|
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
|
PhoneNumberCountryField(
|
||||||
Row(
|
phoneCountryCode = state.phoneCountryCode,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
phoneNationalNumber = state.phoneNationalNumber,
|
||||||
) {
|
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true
|
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.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.data.api.model.BookingBillingMode
|
||||||
|
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||||
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -55,15 +58,10 @@ fun BookingCreateScreen(
|
|||||||
val checkInTime = remember { mutableStateOf("12:00") }
|
val checkInTime = remember { mutableStateOf("12:00") }
|
||||||
val checkOutTime = remember { mutableStateOf("11:00") }
|
val checkOutTime = remember { mutableStateOf("11:00") }
|
||||||
val relationMenuExpanded = remember { mutableStateOf(false) }
|
val relationMenuExpanded = remember { mutableStateOf(false) }
|
||||||
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
|
|
||||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||||
val transportOptions = listOf("", "CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
|
|
||||||
val billingModeMenuExpanded = remember { mutableStateOf(false) }
|
val billingModeMenuExpanded = remember { mutableStateOf(false) }
|
||||||
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
|
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
|
||||||
val timeFormatter = remember { DateTimeFormatter.ofPattern("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 ->
|
val applyCheckInSelection: (LocalDate, String) -> Unit = { date, time ->
|
||||||
checkInDate.value = date
|
checkInDate.value = date
|
||||||
@@ -226,85 +224,31 @@ fun BookingCreateScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Row(
|
PhoneNumberCountryField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
phoneCountryCode = state.phoneCountryCode,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
|
||||||
verticalAlignment = Alignment.Top
|
phoneNationalNumber = state.phoneNationalNumber,
|
||||||
) {
|
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
|
||||||
ExposedDropdownMenuBox(
|
countryWeight = 0.3f,
|
||||||
expanded = phoneCountryMenuExpanded.value,
|
numberWeight = 0.7f
|
||||||
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()
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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,
|
value = state.toCity,
|
||||||
onValueChange = viewModel::onToCityChange,
|
onValueChange = viewModel::onToCityChange,
|
||||||
label = { Text("To City (optional)") },
|
label = "To City (optional)",
|
||||||
modifier = Modifier.fillMaxWidth()
|
suggestions = state.toCitySuggestions,
|
||||||
|
isLoading = state.isToCitySearchLoading,
|
||||||
|
onSuggestionSelected = viewModel::onToCitySuggestionSelected
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
@@ -327,7 +271,7 @@ fun BookingCreateScreen(
|
|||||||
expanded = relationMenuExpanded.value,
|
expanded = relationMenuExpanded.value,
|
||||||
onDismissRequest = { relationMenuExpanded.value = false }
|
onDismissRequest = { relationMenuExpanded.value = false }
|
||||||
) {
|
) {
|
||||||
relationOptions.forEach { option ->
|
BookingProfileOptions.memberRelations.forEach { option ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(option) },
|
text = { Text(option) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -359,7 +303,7 @@ fun BookingCreateScreen(
|
|||||||
expanded = transportMenuExpanded.value,
|
expanded = transportMenuExpanded.value,
|
||||||
onDismissRequest = { transportMenuExpanded.value = false }
|
onDismissRequest = { transportMenuExpanded.value = false }
|
||||||
) {
|
) {
|
||||||
transportOptions.forEach { option ->
|
BookingProfileOptions.transportModes.forEach { option ->
|
||||||
val optionLabel = option.ifBlank { "Not set" }
|
val optionLabel = option.ifBlank { "Not set" }
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(optionLabel) },
|
text = { Text(optionLabel) },
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ data class BookingCreateState(
|
|||||||
val billingCheckoutTime: String = "",
|
val billingCheckoutTime: String = "",
|
||||||
val source: String = "",
|
val source: String = "",
|
||||||
val fromCity: String = "",
|
val fromCity: String = "",
|
||||||
|
val fromCitySuggestions: List<String> = emptyList(),
|
||||||
|
val isFromCitySearchLoading: Boolean = false,
|
||||||
val toCity: String = "",
|
val toCity: String = "",
|
||||||
|
val toCitySuggestions: List<String> = emptyList(),
|
||||||
|
val isToCitySearchLoading: Boolean = false,
|
||||||
val memberRelation: String = "",
|
val memberRelation: String = "",
|
||||||
val transportMode: String = "CAR",
|
val transportMode: String = "CAR",
|
||||||
val isTransportModeAuto: Boolean = true,
|
val isTransportModeAuto: Boolean = true,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.android.trisolarispms.ui.booking
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
|
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.ApiClient
|
||||||
|
import com.android.trisolarispms.data.api.core.GeoSearchRepository
|
||||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||||
import com.android.trisolarispms.data.api.model.BookingCreateRequest
|
import com.android.trisolarispms.data.api.model.BookingCreateRequest
|
||||||
import com.android.trisolarispms.data.api.model.BookingCreateResponse
|
import com.android.trisolarispms.data.api.model.BookingCreateResponse
|
||||||
@@ -23,9 +25,39 @@ class BookingCreateViewModel : ViewModel() {
|
|||||||
private val _state = MutableStateFlow(BookingCreateState())
|
private val _state = MutableStateFlow(BookingCreateState())
|
||||||
val state: StateFlow<BookingCreateState> = _state
|
val state: StateFlow<BookingCreateState> = _state
|
||||||
private var expectedCheckoutPreviewRequestId: Long = 0
|
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() {
|
fun reset() {
|
||||||
expectedCheckoutPreviewRequestId = 0
|
expectedCheckoutPreviewRequestId = 0
|
||||||
|
fromCitySearch.cancel()
|
||||||
|
toCitySearch.cancel()
|
||||||
_state.value = BookingCreateState()
|
_state.value = BookingCreateState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +226,36 @@ class BookingCreateViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun onFromCityChange(value: String) {
|
fun onFromCityChange(value: String) {
|
||||||
_state.update { it.copy(fromCity = value, error = null) }
|
_state.update { it.copy(fromCity = value, error = null) }
|
||||||
|
fromCitySearch.onQueryChanged(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onToCityChange(value: String) {
|
fun onToCityChange(value: String) {
|
||||||
_state.update { it.copy(toCity = value, error = null) }
|
_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) {
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Done
|
import androidx.compose.material.icons.filled.Done
|
||||||
@@ -89,13 +91,20 @@ fun SaveTopBarScaffold(
|
|||||||
fun PaddedScreenColumn(
|
fun PaddedScreenColumn(
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
contentPadding: Dp = 24.dp,
|
contentPadding: Dp = 24.dp,
|
||||||
|
scrollable: Boolean = false,
|
||||||
content: @Composable ColumnScope.() -> Unit
|
content: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
|
val baseModifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(contentPadding)
|
||||||
|
val scrollModifier = if (scrollable) {
|
||||||
|
baseModifier.verticalScroll(rememberScrollState())
|
||||||
|
} else {
|
||||||
|
baseModifier
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = scrollModifier,
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(contentPadding),
|
|
||||||
verticalArrangement = Arrangement.Top,
|
verticalArrangement = Arrangement.Top,
|
||||||
content = content
|
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
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -22,6 +18,7 @@ import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
|||||||
@Composable
|
@Composable
|
||||||
fun GuestInfoScreen(
|
fun GuestInfoScreen(
|
||||||
propertyId: String,
|
propertyId: String,
|
||||||
|
bookingId: String,
|
||||||
guestId: String,
|
guestId: String,
|
||||||
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
|
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
|
||||||
initialPhone: String?,
|
initialPhone: String?,
|
||||||
@@ -31,51 +28,75 @@ fun GuestInfoScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(guestId) {
|
LaunchedEffect(propertyId, bookingId, guestId) {
|
||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
viewModel.setInitial(initialGuest, initialPhone)
|
viewModel.setInitial(initialGuest, initialPhone)
|
||||||
viewModel.loadGuest(propertyId, guestId, initialPhone)
|
viewModel.loadGuest(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
guestId = guestId,
|
||||||
|
fallbackPhone = initialPhone
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SaveTopBarScaffold(
|
SaveTopBarScaffold(
|
||||||
title = "Guest Info",
|
title = "Guest Info",
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSave = { viewModel.submit(propertyId, guestId, onSave) }
|
onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
|
||||||
) { padding ->
|
) { padding ->
|
||||||
PaddedScreenColumn(padding = padding) {
|
PaddedScreenColumn(
|
||||||
OutlinedTextField(
|
padding = padding,
|
||||||
value = state.phoneE164,
|
scrollable = true
|
||||||
onValueChange = viewModel::onPhoneChange,
|
) {
|
||||||
label = { Text("Phone E164 (optional)") },
|
GuestInfoFormFields(
|
||||||
modifier = Modifier.fillMaxWidth()
|
phoneCountryCode = state.phoneCountryCode,
|
||||||
)
|
onPhoneCountryCodeChange = { code ->
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
viewModel.onPhoneCountryChange(
|
||||||
OutlinedTextField(
|
value = code,
|
||||||
value = state.name,
|
propertyId = propertyId,
|
||||||
onValueChange = viewModel::onNameChange,
|
guestId = guestId
|
||||||
label = { Text("Name (optional)") },
|
)
|
||||||
modifier = Modifier.fillMaxWidth()
|
},
|
||||||
)
|
phoneNationalNumber = state.phoneNationalNumber,
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
onPhoneNationalNumberChange = { number ->
|
||||||
OutlinedTextField(
|
viewModel.onPhoneNationalNumberChange(
|
||||||
value = state.nationality,
|
value = number,
|
||||||
onValueChange = viewModel::onNationalityChange,
|
propertyId = propertyId,
|
||||||
label = { Text("Nationality (optional)") },
|
guestId = guestId
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
},
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
name = state.name,
|
||||||
OutlinedTextField(
|
onNameChange = viewModel::onNameChange,
|
||||||
value = state.age,
|
nationality = state.nationality,
|
||||||
onValueChange = viewModel::onAgeChange,
|
onNationalityChange = viewModel::onNationalityChange,
|
||||||
label = { Text("DOB (dd/MM/yyyy)") },
|
nationalitySuggestions = state.nationalitySuggestions,
|
||||||
modifier = Modifier.fillMaxWidth()
|
isNationalitySearchLoading = state.isNationalitySearchLoading,
|
||||||
)
|
onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
age = state.age,
|
||||||
OutlinedTextField(
|
onAgeChange = viewModel::onAgeChange,
|
||||||
value = state.addressText,
|
addressText = state.addressText,
|
||||||
onValueChange = viewModel::onAddressChange,
|
onAddressChange = viewModel::onAddressChange,
|
||||||
label = { Text("Address (optional)") },
|
fromCity = state.fromCity,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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) {
|
if (state.isLoading) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
package com.android.trisolarispms.ui.guest
|
package com.android.trisolarispms.ui.guest
|
||||||
|
|
||||||
data class GuestInfoState(
|
data class GuestInfoState(
|
||||||
val phoneE164: String = "",
|
val phoneCountryCode: String = "IN",
|
||||||
|
val phoneNationalNumber: String = "",
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val nationality: String = "",
|
val nationality: String = "",
|
||||||
|
val nationalitySuggestions: List<String> = emptyList(),
|
||||||
|
val isNationalitySearchLoading: Boolean = false,
|
||||||
val age: String = "",
|
val age: String = "",
|
||||||
val addressText: 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 vehicleNumbers: List<String> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ package com.android.trisolarispms.ui.guest
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.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.GuestDto
|
||||||
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -13,13 +23,69 @@ import kotlinx.coroutines.launch
|
|||||||
class GuestInfoViewModel : ViewModel() {
|
class GuestInfoViewModel : ViewModel() {
|
||||||
private val _state = MutableStateFlow(GuestInfoState())
|
private val _state = MutableStateFlow(GuestInfoState())
|
||||||
val state: StateFlow<GuestInfoState> = _state
|
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() {
|
fun reset() {
|
||||||
|
nationalitySearchJob?.cancel()
|
||||||
|
nationalitySearchJob = null
|
||||||
|
phoneAutofillJob?.cancel()
|
||||||
|
phoneAutofillJob = null
|
||||||
|
lastAutofilledPhoneE164 = null
|
||||||
|
fromCitySearch.cancel()
|
||||||
|
toCitySearch.cancel()
|
||||||
|
initialBookingProfile = null
|
||||||
_state.value = GuestInfoState()
|
_state.value = GuestInfoState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPhoneChange(value: String) {
|
fun onPhoneCountryChange(value: String, propertyId: String, guestId: String) {
|
||||||
_state.update { it.copy(phoneE164 = value, error = null) }
|
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) {
|
fun onNameChange(value: String) {
|
||||||
@@ -28,6 +94,19 @@ class GuestInfoViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun onNationalityChange(value: String) {
|
fun onNationalityChange(value: String) {
|
||||||
_state.update { it.copy(nationality = value, error = null) }
|
_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) {
|
fun onAgeChange(value: String) {
|
||||||
@@ -38,13 +117,71 @@ class GuestInfoViewModel : ViewModel() {
|
|||||||
_state.update { it.copy(addressText = value, error = null) }
|
_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 {
|
_state.update {
|
||||||
it.copy(
|
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(),
|
name = guest?.name.orEmpty(),
|
||||||
nationality = guest?.nationality.orEmpty(),
|
nationality = guest?.nationality.orEmpty(),
|
||||||
age = guest?.age.orEmpty(),
|
nationalitySuggestions = emptyList(),
|
||||||
|
isNationalitySearchLoading = false,
|
||||||
|
age = guest?.dob.orEmpty(),
|
||||||
addressText = guest?.addressText.orEmpty(),
|
addressText = guest?.addressText.orEmpty(),
|
||||||
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
|
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
|
||||||
error = null
|
error = null
|
||||||
@@ -52,75 +189,416 @@ class GuestInfoViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadGuest(propertyId: String, guestId: String, fallbackPhone: String?) {
|
fun loadGuest(propertyId: String, bookingId: String, guestId: String, fallbackPhone: String?) {
|
||||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
if (propertyId.isBlank()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
var loadError: String? = null
|
||||||
try {
|
try {
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.getGuest(propertyId = propertyId, guestId = guestId)
|
if (guestId.isNotBlank()) {
|
||||||
val guest = response.body()
|
val guestResponse = api.getGuest(propertyId = propertyId, guestId = guestId)
|
||||||
if (response.isSuccessful && guest != null) {
|
val guest = guestResponse.body()
|
||||||
_state.update {
|
if (guestResponse.isSuccessful && guest != null) {
|
||||||
it.copy(
|
val parsedPhone = parsePhoneE164(guest.phoneE164 ?: fallbackPhone)
|
||||||
phoneE164 = guest.phoneE164 ?: fallbackPhone.orEmpty(),
|
_state.update {
|
||||||
name = guest.name.orEmpty(),
|
it.copy(
|
||||||
nationality = guest.nationality.orEmpty(),
|
phoneCountryCode = parsedPhone.countryCode,
|
||||||
age = guest.age.orEmpty(),
|
phoneNationalNumber = parsedPhone.nationalNumber,
|
||||||
addressText = guest.addressText.orEmpty(),
|
name = guest.name.orEmpty(),
|
||||||
vehicleNumbers = guest.vehicleNumbers ?: emptyList(),
|
nationality = guest.nationality.orEmpty(),
|
||||||
isLoading = false,
|
nationalitySuggestions = emptyList(),
|
||||||
error = null
|
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(
|
if (bookingId.isNotBlank()) {
|
||||||
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
|
val detailsResponse = api.getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||||
isLoading = false,
|
val details = detailsResponse.body()
|
||||||
error = "Load failed: ${response.code()}"
|
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) {
|
} catch (e: Exception) {
|
||||||
_state.update {
|
loadError = e.localizedMessage ?: "Load failed"
|
||||||
it.copy(
|
}
|
||||||
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
|
val parsedPhone = parsePhoneE164(fallbackPhone)
|
||||||
isLoading = false,
|
_state.update {
|
||||||
error = e.localizedMessage ?: "Load failed"
|
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) {
|
fun submit(propertyId: String, bookingId: String, guestId: String, onDone: () -> Unit) {
|
||||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
if (propertyId.isBlank() || bookingId.isBlank() || guestId.isBlank()) return
|
||||||
val current = state.value
|
val current = state.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.updateGuest(
|
var submitError: String? = null
|
||||||
propertyId = propertyId,
|
val fullPhoneE164 = composePhoneE164(current)
|
||||||
guestId = guestId,
|
var matchedGuestToLinkId: String? = null
|
||||||
body = GuestUpdateRequest(
|
if (!fullPhoneE164.isNullOrBlank() && submitError == null) {
|
||||||
phoneE164 = current.phoneE164.trim().ifBlank { null },
|
val searchResponse = api.searchGuests(
|
||||||
name = current.name.trim().ifBlank { null },
|
propertyId = propertyId,
|
||||||
nationality = current.nationality.trim().ifBlank { null },
|
phone = fullPhoneE164
|
||||||
age = current.age.trim().ifBlank { null },
|
|
||||||
addressText = current.addressText.trim().ifBlank { null }
|
|
||||||
)
|
)
|
||||||
)
|
if (searchResponse.isSuccessful) {
|
||||||
if (response.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) }
|
_state.update { it.copy(isLoading = false, error = null) }
|
||||||
onDone()
|
onDone()
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
|
_state.update { it.copy(isLoading = false, error = submitError) }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 object Home : AppRoute
|
||||||
data class CreateBooking(val propertyId: String) : AppRoute
|
data class CreateBooking(val propertyId: String) : AppRoute
|
||||||
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: 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 GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
|
||||||
data class ManageRoomStaySelect(
|
data class ManageRoomStaySelect(
|
||||||
val propertyId: String,
|
val propertyId: String,
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ internal fun handleBackNavigation(
|
|||||||
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||||
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||||
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
|
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(
|
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
|
||||||
currentRoute.propertyId,
|
currentRoute.propertyId,
|
||||||
currentRoute.bookingId,
|
currentRoute.bookingId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.android.trisolarispms.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import com.android.trisolarispms.core.auth.AuthzPolicy
|
import com.android.trisolarispms.core.auth.AuthzPolicy
|
||||||
|
import com.android.trisolarispms.ui.guest.GuestInfoScreen
|
||||||
import com.android.trisolarispms.ui.navigation.AppRoute
|
import com.android.trisolarispms.ui.navigation.AppRoute
|
||||||
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
|
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
|
||||||
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
|
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
|
||||||
@@ -25,6 +26,13 @@ internal fun renderBookingRoutes(
|
|||||||
bookingId = currentRoute.bookingId,
|
bookingId = currentRoute.bookingId,
|
||||||
guestId = currentRoute.guestId,
|
guestId = currentRoute.guestId,
|
||||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
||||||
|
onEditGuestInfo = { targetGuestId ->
|
||||||
|
refs.route.value = AppRoute.GuestInfoFromBookingDetails(
|
||||||
|
propertyId = currentRoute.propertyId,
|
||||||
|
bookingId = currentRoute.bookingId,
|
||||||
|
guestId = targetGuestId
|
||||||
|
)
|
||||||
|
},
|
||||||
onEditSignature = { guestId ->
|
onEditSignature = { guestId ->
|
||||||
refs.route.value = AppRoute.GuestSignature(
|
refs.route.value = AppRoute.GuestSignature(
|
||||||
currentRoute.propertyId,
|
currentRoute.propertyId,
|
||||||
@@ -51,6 +59,28 @@ internal fun renderBookingRoutes(
|
|||||||
canCheckOutBooking = authz.canCheckOutBooking(currentRoute.propertyId)
|
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(
|
is AppRoute.BookingPayments -> BookingPaymentsScreen(
|
||||||
propertyId = currentRoute.propertyId,
|
propertyId = currentRoute.propertyId,
|
||||||
bookingId = currentRoute.bookingId,
|
bookingId = currentRoute.bookingId,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ internal fun renderHomeGuestRoutes(
|
|||||||
|
|
||||||
is AppRoute.GuestInfo -> GuestInfoScreen(
|
is AppRoute.GuestInfo -> GuestInfoScreen(
|
||||||
propertyId = currentRoute.propertyId,
|
propertyId = currentRoute.propertyId,
|
||||||
|
bookingId = currentRoute.bookingId,
|
||||||
guestId = currentRoute.guestId,
|
guestId = currentRoute.guestId,
|
||||||
initialGuest = refs.selectedGuest.value,
|
initialGuest = refs.selectedGuest.value,
|
||||||
initialPhone = refs.selectedGuestPhone.value,
|
initialPhone = refs.selectedGuestPhone.value,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
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.Error
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.QrCode
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
@@ -87,6 +88,7 @@ fun BookingDetailsTabsScreen(
|
|||||||
bookingId: String,
|
bookingId: String,
|
||||||
guestId: String?,
|
guestId: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onEditGuestInfo: (String) -> Unit,
|
||||||
onEditSignature: (String) -> Unit,
|
onEditSignature: (String) -> Unit,
|
||||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||||
onOpenPayments: () -> Unit,
|
onOpenPayments: () -> Unit,
|
||||||
@@ -214,6 +216,7 @@ fun BookingDetailsTabsScreen(
|
|||||||
guestId = guestId,
|
guestId = guestId,
|
||||||
isLoading = detailsState.isLoading,
|
isLoading = detailsState.isLoading,
|
||||||
error = detailsState.error,
|
error = detailsState.error,
|
||||||
|
onEditGuestInfo = onEditGuestInfo,
|
||||||
onEditSignature = onEditSignature,
|
onEditSignature = onEditSignature,
|
||||||
onOpenRazorpayQr = onOpenRazorpayQr,
|
onOpenRazorpayQr = onOpenRazorpayQr,
|
||||||
onOpenPayments = onOpenPayments
|
onOpenPayments = onOpenPayments
|
||||||
@@ -396,6 +399,7 @@ private fun GuestInfoTabContent(
|
|||||||
guestId: String?,
|
guestId: String?,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
onEditGuestInfo: (String) -> Unit,
|
||||||
onEditSignature: (String) -> Unit,
|
onEditSignature: (String) -> Unit,
|
||||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||||
onOpenPayments: () -> Unit
|
onOpenPayments: () -> Unit
|
||||||
@@ -420,6 +424,7 @@ private fun GuestInfoTabContent(
|
|||||||
val bookingStatus = details?.status?.uppercase()
|
val bookingStatus = details?.status?.uppercase()
|
||||||
val canEditCheckIn = bookingStatus == "OPEN"
|
val canEditCheckIn = bookingStatus == "OPEN"
|
||||||
val canEditCheckOut = bookingStatus == "OPEN" || bookingStatus == "CHECKED_IN"
|
val canEditCheckOut = bookingStatus == "OPEN" || bookingStatus == "CHECKED_IN"
|
||||||
|
val resolvedGuestId = details?.guestId ?: guestId
|
||||||
|
|
||||||
LaunchedEffect(checkInFromDetails, checkOutFromDetails) {
|
LaunchedEffect(checkInFromDetails, checkOutFromDetails) {
|
||||||
draftCheckInAt.value = checkInFromDetails
|
draftCheckInAt.value = checkInFromDetails
|
||||||
@@ -479,7 +484,23 @@ private fun GuestInfoTabContent(
|
|||||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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 = "Name", value = details?.guestName)
|
||||||
GuestDetailRow(label = "Nationality", value = details?.guestNationality)
|
GuestDetailRow(label = "Nationality", value = details?.guestNationality)
|
||||||
GuestDetailRow(label = "Age", value = details?.guestAge?.let { formatAge(it) })
|
GuestDetailRow(label = "Age", value = details?.guestAge?.let { formatAge(it) })
|
||||||
@@ -600,7 +621,6 @@ private fun GuestInfoTabContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
val resolvedGuestId = details?.guestId ?: guestId
|
|
||||||
SignaturePreview(
|
SignaturePreview(
|
||||||
propertyId = propertyId,
|
propertyId = propertyId,
|
||||||
guestId = resolvedGuestId,
|
guestId = resolvedGuestId,
|
||||||
|
|||||||
Reference in New Issue
Block a user