booking: ability to edit more guest info

This commit is contained in:
androidlover5842
2026-02-07 22:33:43 +05:30
parent 90c2b6fb9f
commit e9c3b4f669
24 changed files with 1515 additions and 254 deletions

View File

@@ -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"
)
}

View File

@@ -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
}
}

View File

@@ -8,6 +8,7 @@ import com.android.trisolarispms.data.api.service.CancellationPolicyApi
import com.android.trisolarispms.data.api.service.CardApi
import com.android.trisolarispms.data.api.service.GuestApi
import com.android.trisolarispms.data.api.service.GuestDocumentApi
import com.android.trisolarispms.data.api.service.GeoApi
import com.android.trisolarispms.data.api.service.ImageTagApi
import com.android.trisolarispms.data.api.service.InboundEmailApi
import com.android.trisolarispms.data.api.service.PropertyApi
@@ -33,6 +34,7 @@ interface ApiService :
CardApi,
GuestApi,
GuestDocumentApi,
GeoApi,
TransportApi,
InboundEmailApi,
AmenityApi,

View File

@@ -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"
}

View File

@@ -0,0 +1,6 @@
package com.android.trisolarispms.data.api.model
data class CitySearchItemDto(
val city: String? = null,
val state: String? = null
)

View File

@@ -1,11 +1,14 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class GuestDto(
val id: String? = null,
val name: String? = null,
val phoneE164: String? = null,
@SerializedName(value = "dob", alternate = ["age"])
val dob: String? = null,
val nationality: String? = null,
val age: String? = null,
val addressText: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val averageScore: Double? = null

View File

@@ -20,6 +20,7 @@ import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
import com.google.gson.JsonObject
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
@@ -65,6 +66,13 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/profile")
suspend fun updateBookingProfile(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: JsonObject
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
suspend fun createRoomRequest(
@Path("propertyId") propertyId: String,

View File

@@ -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>>
}

View File

@@ -30,26 +30,16 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.android.trisolarispms.ui.booking.phoneCountryOptions
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val activity = context as? ComponentActivity
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") }
val now = remember { mutableStateOf(System.currentTimeMillis()) }
val hasNetwork = remember(context, state.error) { hasInternetConnection(context) }
val noNetworkError = state.error?.contains("No internet connection", ignoreCase = true) == true
@@ -97,69 +87,12 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(0.35f)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
val filtered = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) true
else option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
filtered.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
viewModel.onPhoneCountryChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = state.phoneNationalNumber,
onValueChange = viewModel::onPhoneNationalNumberChange,
label = { Text("Number") },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(0.65f)
)
}
PhoneNumberCountryField(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
)
Spacer(modifier = Modifier.height(12.dp))
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true

View File

@@ -31,7 +31,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import java.time.LocalDate
import java.time.OffsetDateTime
@@ -55,15 +58,10 @@ fun BookingCreateScreen(
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val relationMenuExpanded = remember { mutableStateOf(false) }
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) }
val transportOptions = listOf("", "CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
val billingModeMenuExpanded = remember { mutableStateOf(false) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") }
val applyCheckInSelection: (LocalDate, String) -> Unit = { date, time ->
checkInDate.value = date
@@ -226,85 +224,31 @@ fun BookingCreateScreen(
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(0.3f)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
val filteredCountries = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) {
true
} else {
option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
}
filteredCountries.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
viewModel.onPhoneCountryChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = state.phoneNationalNumber,
onValueChange = viewModel::onPhoneNationalNumberChange,
label = { Text("Number") },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(0.7f)
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.fromCity,
onValueChange = viewModel::onFromCityChange,
label = { Text("From City (optional)") },
modifier = Modifier.fillMaxWidth()
PhoneNumberCountryField(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
countryWeight = 0.3f,
numberWeight = 0.7f
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
CityAutocompleteField(
value = state.fromCity,
onValueChange = viewModel::onFromCityChange,
label = "From City (optional)",
suggestions = state.fromCitySuggestions,
isLoading = state.isFromCitySearchLoading,
onSuggestionSelected = viewModel::onFromCitySuggestionSelected
)
Spacer(modifier = Modifier.height(12.dp))
CityAutocompleteField(
value = state.toCity,
onValueChange = viewModel::onToCityChange,
label = { Text("To City (optional)") },
modifier = Modifier.fillMaxWidth()
label = "To City (optional)",
suggestions = state.toCitySuggestions,
isLoading = state.isToCitySearchLoading,
onSuggestionSelected = viewModel::onToCitySuggestionSelected
)
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
@@ -327,7 +271,7 @@ fun BookingCreateScreen(
expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false }
) {
relationOptions.forEach { option ->
BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
@@ -359,7 +303,7 @@ fun BookingCreateScreen(
expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false }
) {
transportOptions.forEach { option ->
BookingProfileOptions.transportModes.forEach { option ->
val optionLabel = option.ifBlank { "Not set" }
DropdownMenuItem(
text = { Text(optionLabel) },

View File

@@ -14,7 +14,11 @@ data class BookingCreateState(
val billingCheckoutTime: String = "",
val source: String = "",
val fromCity: String = "",
val fromCitySuggestions: List<String> = emptyList(),
val isFromCitySearchLoading: Boolean = false,
val toCity: String = "",
val toCitySuggestions: List<String> = emptyList(),
val isToCitySearchLoading: Boolean = false,
val memberRelation: String = "",
val transportMode: String = "CAR",
val isTransportModeAuto: Boolean = true,

View File

@@ -3,7 +3,9 @@ package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
import com.android.trisolarispms.core.viewmodel.CitySearchController
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.GeoSearchRepository
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
@@ -23,9 +25,39 @@ class BookingCreateViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingCreateState())
val state: StateFlow<BookingCreateState> = _state
private var expectedCheckoutPreviewRequestId: Long = 0
private val fromCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isFromCitySearchLoading = isLoading,
fromCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
private val toCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isToCitySearchLoading = isLoading,
toCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
fun reset() {
expectedCheckoutPreviewRequestId = 0
fromCitySearch.cancel()
toCitySearch.cancel()
_state.value = BookingCreateState()
}
@@ -194,10 +226,36 @@ class BookingCreateViewModel : ViewModel() {
fun onFromCityChange(value: String) {
_state.update { it.copy(fromCity = value, error = null) }
fromCitySearch.onQueryChanged(value)
}
fun onToCityChange(value: String) {
_state.update { it.copy(toCity = value, error = null) }
toCitySearch.onQueryChanged(value)
}
fun onFromCitySuggestionSelected(value: String) {
fromCitySearch.cancel()
_state.update {
it.copy(
fromCity = value,
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
error = null
)
}
}
fun onToCitySuggestionSelected(value: String) {
toCitySearch.cancel()
_state.update {
it.copy(
toCity = value,
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
error = null
)
}
}
fun onMemberRelationChange(value: String) {

View File

@@ -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)
}
)
}
}
}
}
}

View File

@@ -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
)
}
}

View File

@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
@@ -89,13 +91,20 @@ fun SaveTopBarScaffold(
fun PaddedScreenColumn(
padding: PaddingValues,
contentPadding: Dp = 24.dp,
scrollable: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
val baseModifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(contentPadding)
val scrollModifier = if (scrollable) {
baseModifier.verticalScroll(rememberScrollState())
} else {
baseModifier
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(contentPadding),
modifier = scrollModifier,
verticalArrangement = Arrangement.Top,
content = content
)

View File

@@ -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()
}

View File

@@ -1,13 +1,9 @@
package com.android.trisolarispms.ui.guest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +18,7 @@ import com.android.trisolarispms.ui.common.SaveTopBarScaffold
@Composable
fun GuestInfoScreen(
propertyId: String,
bookingId: String,
guestId: String,
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
initialPhone: String?,
@@ -31,51 +28,75 @@ fun GuestInfoScreen(
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(guestId) {
LaunchedEffect(propertyId, bookingId, guestId) {
viewModel.reset()
viewModel.setInitial(initialGuest, initialPhone)
viewModel.loadGuest(propertyId, guestId, initialPhone)
viewModel.loadGuest(
propertyId = propertyId,
bookingId = bookingId,
guestId = guestId,
fallbackPhone = initialPhone
)
}
SaveTopBarScaffold(
title = "Guest Info",
onBack = onBack,
onSave = { viewModel.submit(propertyId, guestId, onSave) }
onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
) { padding ->
PaddedScreenColumn(padding = padding) {
OutlinedTextField(
value = state.phoneE164,
onValueChange = viewModel::onPhoneChange,
label = { Text("Phone E164 (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.nationality,
onValueChange = viewModel::onNationalityChange,
label = { Text("Nationality (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.age,
onValueChange = viewModel::onAgeChange,
label = { Text("DOB (dd/MM/yyyy)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.addressText,
onValueChange = viewModel::onAddressChange,
label = { Text("Address (optional)") },
modifier = Modifier.fillMaxWidth()
PaddedScreenColumn(
padding = padding,
scrollable = true
) {
GuestInfoFormFields(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = { code ->
viewModel.onPhoneCountryChange(
value = code,
propertyId = propertyId,
guestId = guestId
)
},
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = { number ->
viewModel.onPhoneNationalNumberChange(
value = number,
propertyId = propertyId,
guestId = guestId
)
},
name = state.name,
onNameChange = viewModel::onNameChange,
nationality = state.nationality,
onNationalityChange = viewModel::onNationalityChange,
nationalitySuggestions = state.nationalitySuggestions,
isNationalitySearchLoading = state.isNationalitySearchLoading,
onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
age = state.age,
onAgeChange = viewModel::onAgeChange,
addressText = state.addressText,
onAddressChange = viewModel::onAddressChange,
fromCity = state.fromCity,
onFromCityChange = viewModel::onFromCityChange,
fromCitySuggestions = state.fromCitySuggestions,
isFromCitySearchLoading = state.isFromCitySearchLoading,
onFromCitySuggestionSelected = viewModel::onFromCitySuggestionSelected,
toCity = state.toCity,
onToCityChange = viewModel::onToCityChange,
toCitySuggestions = state.toCitySuggestions,
isToCitySearchLoading = state.isToCitySearchLoading,
onToCitySuggestionSelected = viewModel::onToCitySuggestionSelected,
memberRelation = state.memberRelation,
onMemberRelationChange = viewModel::onMemberRelationChange,
transportMode = state.transportMode,
onTransportModeChange = viewModel::onTransportModeChange,
childCount = state.childCount,
onChildCountChange = viewModel::onChildCountChange,
maleCount = state.maleCount,
onMaleCountChange = viewModel::onMaleCountChange,
femaleCount = state.femaleCount,
onFemaleCountChange = viewModel::onFemaleCountChange,
vehicleNumbers = state.vehicleNumbers
)
if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp))

View File

@@ -1,11 +1,25 @@
package com.android.trisolarispms.ui.guest
data class GuestInfoState(
val phoneE164: String = "",
val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
val name: String = "",
val nationality: String = "",
val nationalitySuggestions: List<String> = emptyList(),
val isNationalitySearchLoading: Boolean = false,
val age: String = "",
val addressText: String = "",
val fromCity: String = "",
val fromCitySuggestions: List<String> = emptyList(),
val isFromCitySearchLoading: Boolean = false,
val toCity: String = "",
val toCitySuggestions: List<String> = emptyList(),
val isToCitySearchLoading: Boolean = false,
val memberRelation: String = "",
val transportMode: String = "",
val childCount: String = "",
val maleCount: String = "",
val femaleCount: String = "",
val vehicleNumbers: List<String> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null

View File

@@ -2,9 +2,19 @@ package com.android.trisolarispms.ui.guest
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.core.viewmodel.CitySearchController
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.GeoSearchRepository
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.GuestDto
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -13,13 +23,69 @@ import kotlinx.coroutines.launch
class GuestInfoViewModel : ViewModel() {
private val _state = MutableStateFlow(GuestInfoState())
val state: StateFlow<GuestInfoState> = _state
private var nationalitySearchJob: Job? = null
private var phoneAutofillJob: Job? = null
private var lastAutofilledPhoneE164: String? = null
private var initialBookingProfile: BookingProfileSnapshot? = null
private val fromCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isFromCitySearchLoading = isLoading,
fromCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
private val toCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isToCitySearchLoading = isLoading,
toCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
fun reset() {
nationalitySearchJob?.cancel()
nationalitySearchJob = null
phoneAutofillJob?.cancel()
phoneAutofillJob = null
lastAutofilledPhoneE164 = null
fromCitySearch.cancel()
toCitySearch.cancel()
initialBookingProfile = null
_state.value = GuestInfoState()
}
fun onPhoneChange(value: String) {
_state.update { it.copy(phoneE164 = value, error = null) }
fun onPhoneCountryChange(value: String, propertyId: String, guestId: String) {
val option = findPhoneCountryOption(value)
_state.update { current ->
val trimmed = current.phoneNationalNumber.filter { it.isDigit() }.take(option.maxLength)
current.copy(
phoneCountryCode = option.code,
phoneNationalNumber = trimmed,
error = null
)
}
autoFillByPhoneIfExists(propertyId = propertyId, currentGuestId = guestId)
}
fun onPhoneNationalNumberChange(value: String, propertyId: String, guestId: String) {
val option = findPhoneCountryOption(_state.value.phoneCountryCode)
val trimmed = value.filter { it.isDigit() }.take(option.maxLength)
_state.update { it.copy(phoneNationalNumber = trimmed, error = null) }
autoFillByPhoneIfExists(propertyId = propertyId, currentGuestId = guestId)
}
fun onNameChange(value: String) {
@@ -28,6 +94,19 @@ class GuestInfoViewModel : ViewModel() {
fun onNationalityChange(value: String) {
_state.update { it.copy(nationality = value, error = null) }
searchCountrySuggestions(value)
}
fun onNationalitySuggestionSelected(suggestion: String) {
nationalitySearchJob?.cancel()
_state.update {
it.copy(
nationality = suggestion,
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
error = null
)
}
}
fun onAgeChange(value: String) {
@@ -38,13 +117,71 @@ class GuestInfoViewModel : ViewModel() {
_state.update { it.copy(addressText = value, error = null) }
}
fun setInitial(guest: GuestDto?, phone: String?) {
fun onFromCityChange(value: String) {
_state.update { it.copy(fromCity = value, error = null) }
fromCitySearch.onQueryChanged(value)
}
fun onToCityChange(value: String) {
_state.update { it.copy(toCity = value, error = null) }
toCitySearch.onQueryChanged(value)
}
fun onFromCitySuggestionSelected(value: String) {
fromCitySearch.cancel()
_state.update {
it.copy(
phoneE164 = guest?.phoneE164 ?: phone.orEmpty(),
fromCity = value,
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
error = null
)
}
}
fun onToCitySuggestionSelected(value: String) {
toCitySearch.cancel()
_state.update {
it.copy(
toCity = value,
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
error = null
)
}
}
fun onMemberRelationChange(value: String) {
_state.update { it.copy(memberRelation = value, error = null) }
}
fun onTransportModeChange(value: String) {
_state.update { it.copy(transportMode = value, error = null) }
}
fun onChildCountChange(value: String) {
_state.update { it.copy(childCount = value.filter { char -> char.isDigit() }, error = null) }
}
fun onMaleCountChange(value: String) {
_state.update { it.copy(maleCount = value.filter { char -> char.isDigit() }, error = null) }
}
fun onFemaleCountChange(value: String) {
_state.update { it.copy(femaleCount = value.filter { char -> char.isDigit() }, error = null) }
}
fun setInitial(guest: GuestDto?, phone: String?) {
val parsedPhone = parsePhoneE164(guest?.phoneE164 ?: phone)
_state.update {
it.copy(
phoneCountryCode = parsedPhone.countryCode,
phoneNationalNumber = parsedPhone.nationalNumber,
name = guest?.name.orEmpty(),
nationality = guest?.nationality.orEmpty(),
age = guest?.age.orEmpty(),
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
age = guest?.dob.orEmpty(),
addressText = guest?.addressText.orEmpty(),
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
error = null
@@ -52,75 +189,416 @@ class GuestInfoViewModel : ViewModel() {
}
}
fun loadGuest(propertyId: String, guestId: String, fallbackPhone: String?) {
if (propertyId.isBlank() || guestId.isBlank()) return
fun loadGuest(propertyId: String, bookingId: String, guestId: String, fallbackPhone: String?) {
if (propertyId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
var loadError: String? = null
try {
val api = ApiClient.create()
val response = api.getGuest(propertyId = propertyId, guestId = guestId)
val guest = response.body()
if (response.isSuccessful && guest != null) {
_state.update {
it.copy(
phoneE164 = guest.phoneE164 ?: fallbackPhone.orEmpty(),
name = guest.name.orEmpty(),
nationality = guest.nationality.orEmpty(),
age = guest.age.orEmpty(),
addressText = guest.addressText.orEmpty(),
vehicleNumbers = guest.vehicleNumbers ?: emptyList(),
isLoading = false,
error = null
)
if (guestId.isNotBlank()) {
val guestResponse = api.getGuest(propertyId = propertyId, guestId = guestId)
val guest = guestResponse.body()
if (guestResponse.isSuccessful && guest != null) {
val parsedPhone = parsePhoneE164(guest.phoneE164 ?: fallbackPhone)
_state.update {
it.copy(
phoneCountryCode = parsedPhone.countryCode,
phoneNationalNumber = parsedPhone.nationalNumber,
name = guest.name.orEmpty(),
nationality = guest.nationality.orEmpty(),
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
age = guest.dob.orEmpty(),
addressText = guest.addressText.orEmpty(),
vehicleNumbers = guest.vehicleNumbers,
error = null
)
}
} else {
val parsedPhone = parsePhoneE164(fallbackPhone)
_state.update {
it.copy(
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
isNationalitySearchLoading = false
)
}
loadError = "Load failed: ${guestResponse.code()}"
}
} else {
_state.update {
it.copy(
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
isLoading = false,
error = "Load failed: ${response.code()}"
}
if (bookingId.isNotBlank()) {
val detailsResponse = api.getBookingDetails(propertyId = propertyId, bookingId = bookingId)
val details = detailsResponse.body()
if (detailsResponse.isSuccessful && details != null) {
val snapshot = BookingProfileSnapshot(
transportMode = details.transportMode?.trim()?.ifBlank { null },
childCount = details.childCount,
maleCount = details.maleCount,
femaleCount = details.femaleCount,
fromCity = details.fromCity?.trim()?.ifBlank { null },
toCity = details.toCity?.trim()?.ifBlank { null },
memberRelation = details.memberRelation?.trim()?.ifBlank { null }
)
initialBookingProfile = snapshot
_state.update {
it.copy(
fromCity = snapshot.fromCity.orEmpty(),
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
toCity = snapshot.toCity.orEmpty(),
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
memberRelation = snapshot.memberRelation.orEmpty(),
transportMode = snapshot.transportMode.orEmpty(),
childCount = snapshot.childCount?.toString().orEmpty(),
maleCount = snapshot.maleCount?.toString().orEmpty(),
femaleCount = snapshot.femaleCount?.toString().orEmpty()
)
}
} else if (loadError == null) {
loadError = "Load failed: ${detailsResponse.code()}"
}
}
} catch (e: Exception) {
_state.update {
it.copy(
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
isLoading = false,
error = e.localizedMessage ?: "Load failed"
)
}
loadError = e.localizedMessage ?: "Load failed"
}
val parsedPhone = parsePhoneE164(fallbackPhone)
_state.update {
it.copy(
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
isNationalitySearchLoading = false,
isLoading = false,
error = loadError
)
}
}
}
fun submit(propertyId: String, guestId: String, onDone: () -> Unit) {
if (propertyId.isBlank() || guestId.isBlank()) return
fun submit(propertyId: String, bookingId: String, guestId: String, onDone: () -> Unit) {
if (propertyId.isBlank() || bookingId.isBlank() || guestId.isBlank()) return
val current = state.value
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateGuest(
propertyId = propertyId,
guestId = guestId,
body = GuestUpdateRequest(
phoneE164 = current.phoneE164.trim().ifBlank { null },
name = current.name.trim().ifBlank { null },
nationality = current.nationality.trim().ifBlank { null },
age = current.age.trim().ifBlank { null },
addressText = current.addressText.trim().ifBlank { null }
var submitError: String? = null
val fullPhoneE164 = composePhoneE164(current)
var matchedGuestToLinkId: String? = null
if (!fullPhoneE164.isNullOrBlank() && submitError == null) {
val searchResponse = api.searchGuests(
propertyId = propertyId,
phone = fullPhoneE164
)
)
if (response.isSuccessful) {
if (searchResponse.isSuccessful) {
matchedGuestToLinkId = searchResponse.body()
.orEmpty()
.firstOrNull { it.id != guestId }
?.id
}
}
if (!matchedGuestToLinkId.isNullOrBlank() && submitError == null) {
val linkResponse = api.linkGuest(
propertyId = propertyId,
bookingId = bookingId,
body = BookingLinkGuestRequest(guestId = matchedGuestToLinkId)
)
if (!linkResponse.isSuccessful) {
submitError = "Link failed: ${linkResponse.code()}"
}
} else if (submitError == null) {
val countryOption = findPhoneCountryOption(current.phoneCountryCode)
val nationalNumber = current.phoneNationalNumber.trim()
val phoneE164 = if (nationalNumber.isBlank()) {
null
} else {
"+${countryOption.dialCode}$nationalNumber"
}
val response = api.updateGuest(
propertyId = propertyId,
guestId = guestId,
body = GuestUpdateRequest(
phoneE164 = phoneE164,
name = current.name.trim().ifBlank { null },
nationality = current.nationality.trim().ifBlank { null },
age = current.age.trim().ifBlank { null },
addressText = current.addressText.trim().ifBlank { null }
)
)
if (!response.isSuccessful) {
submitError = "Update failed: ${response.code()}"
}
}
if (submitError == null) {
val profilePayload = buildBookingProfilePayload(current)
if (profilePayload != null) {
val profileResponse = api.updateBookingProfile(
propertyId = propertyId,
bookingId = bookingId,
body = profilePayload
)
if (!profileResponse.isSuccessful) {
submitError = "Profile update failed: ${profileResponse.code()}"
} else {
initialBookingProfile = profileSnapshotFromState(_state.value)
}
}
}
if (submitError == null) {
_state.update { it.copy(isLoading = false, error = null) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
_state.update { it.copy(isLoading = false, error = submitError) }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
}
}
}
private fun searchCountrySuggestions(value: String) {
nationalitySearchJob?.cancel()
nationalitySearchJob = null
val query = value.trim()
if (query.length < 3) {
_state.update {
it.copy(
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false
)
}
return
}
nationalitySearchJob = viewModelScope.launch {
delay(300)
_state.update { current ->
if (current.nationality.trim() != query) {
current
} else {
current.copy(isNationalitySearchLoading = true)
}
}
try {
val response = ApiClient.create().searchCountries(query = query, limit = 20)
val suggestions = if (response.isSuccessful) response.body().orEmpty() else emptyList()
_state.update { current ->
if (current.nationality.trim() != query) {
current
} else {
current.copy(
nationalitySuggestions = suggestions,
isNationalitySearchLoading = false
)
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
_state.update { current ->
if (current.nationality.trim() != query) {
current
} else {
current.copy(
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false
)
}
}
}
}
}
private fun autoFillByPhoneIfExists(propertyId: String, currentGuestId: String) {
if (propertyId.isBlank()) return
val currentPhone = composePhoneE164(_state.value)
if (currentPhone.isNullOrBlank()) {
phoneAutofillJob?.cancel()
phoneAutofillJob = null
lastAutofilledPhoneE164 = null
return
}
if (lastAutofilledPhoneE164 == currentPhone) return
phoneAutofillJob?.cancel()
phoneAutofillJob = viewModelScope.launch {
try {
val response = ApiClient.create().searchGuests(
propertyId = propertyId,
phone = currentPhone
)
if (!response.isSuccessful) return@launch
val guests = response.body().orEmpty()
val matchedGuest = guests.firstOrNull { it.id != currentGuestId } ?: guests.firstOrNull()
if (matchedGuest == null) {
lastAutofilledPhoneE164 = currentPhone
return@launch
}
_state.update { current ->
if (composePhoneE164(current) != currentPhone) {
current
} else {
current.copy(
name = matchedGuest.name.orEmpty(),
nationality = matchedGuest.nationality.orEmpty(),
age = matchedGuest.dob.orEmpty(),
addressText = matchedGuest.addressText.orEmpty(),
vehicleNumbers = matchedGuest.vehicleNumbers,
error = null
)
}
}
lastAutofilledPhoneE164 = currentPhone
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore lookup failures; manual entry should continue uninterrupted.
}
}
}
private fun buildBookingProfilePayload(current: GuestInfoState): JsonObject? {
val currentSnapshot = profileSnapshotFromState(current)
val initialSnapshot = initialBookingProfile
val body = JsonObject()
fun putNullableStringIfChanged(
key: String,
currentValue: String?,
initialValue: String?
) {
if (currentValue == initialValue) return
if (currentValue == null) {
body.add(key, JsonNull.INSTANCE)
} else {
body.addProperty(key, currentValue)
}
}
fun putNullableIntIfChanged(
key: String,
currentValue: Int?,
initialValue: Int?
) {
if (currentValue == initialValue) return
if (currentValue == null) {
body.add(key, JsonNull.INSTANCE)
} else {
body.addProperty(key, currentValue)
}
}
if (initialSnapshot == null) {
currentSnapshot.transportMode?.let { body.addProperty("transportMode", it) }
currentSnapshot.childCount?.let { body.addProperty("childCount", it) }
currentSnapshot.maleCount?.let { body.addProperty("maleCount", it) }
currentSnapshot.femaleCount?.let { body.addProperty("femaleCount", it) }
currentSnapshot.fromCity?.let { body.addProperty("fromCity", it) }
currentSnapshot.toCity?.let { body.addProperty("toCity", it) }
currentSnapshot.memberRelation?.let { body.addProperty("memberRelation", it) }
return if (body.size() == 0) null else body
}
putNullableStringIfChanged(
key = "transportMode",
currentValue = currentSnapshot.transportMode,
initialValue = initialSnapshot.transportMode
)
putNullableIntIfChanged(
key = "childCount",
currentValue = currentSnapshot.childCount,
initialValue = initialSnapshot.childCount
)
putNullableIntIfChanged(
key = "maleCount",
currentValue = currentSnapshot.maleCount,
initialValue = initialSnapshot.maleCount
)
putNullableIntIfChanged(
key = "femaleCount",
currentValue = currentSnapshot.femaleCount,
initialValue = initialSnapshot.femaleCount
)
putNullableStringIfChanged(
key = "fromCity",
currentValue = currentSnapshot.fromCity,
initialValue = initialSnapshot.fromCity
)
putNullableStringIfChanged(
key = "toCity",
currentValue = currentSnapshot.toCity,
initialValue = initialSnapshot.toCity
)
putNullableStringIfChanged(
key = "memberRelation",
currentValue = currentSnapshot.memberRelation,
initialValue = initialSnapshot.memberRelation
)
return if (body.size() == 0) null else body
}
}
private data class ParsedPhone(
val countryCode: String,
val nationalNumber: String
)
private data class BookingProfileSnapshot(
val transportMode: String?,
val childCount: Int?,
val maleCount: Int?,
val femaleCount: Int?,
val fromCity: String?,
val toCity: String?,
val memberRelation: String?
)
private fun parsePhoneE164(phoneE164: String?): ParsedPhone {
val fallback = ParsedPhone(countryCode = "IN", nationalNumber = "")
val raw = phoneE164?.trim().orEmpty()
if (raw.isBlank()) return fallback
val util = PhoneNumberUtil.getInstance()
val parsed = runCatching { util.parse(raw, null) }.getOrNull()
if (parsed != null) {
val region = util.getRegionCodeForNumber(parsed)
if (!region.isNullOrBlank()) {
val option = findPhoneCountryOption(region)
val national = util.getNationalSignificantNumber(parsed).orEmpty()
.filter { it.isDigit() }
.take(option.maxLength)
return ParsedPhone(
countryCode = option.code,
nationalNumber = national
)
}
}
val digitsOnly = raw.filter { it.isDigit() }.take(findPhoneCountryOption("IN").maxLength)
return fallback.copy(nationalNumber = digitsOnly)
}
private fun composePhoneE164(state: GuestInfoState): String? {
val countryOption = findPhoneCountryOption(state.phoneCountryCode)
val digits = state.phoneNationalNumber.trim()
if (digits.length != countryOption.maxLength) return null
return "+${countryOption.dialCode}$digits"
}
private fun profileSnapshotFromState(state: GuestInfoState): BookingProfileSnapshot =
BookingProfileSnapshot(
transportMode = state.transportMode.trim().ifBlank { null },
childCount = state.childCount.trim().toIntOrNull(),
maleCount = state.maleCount.trim().toIntOrNull(),
femaleCount = state.femaleCount.trim().toIntOrNull(),
fromCity = state.fromCity.trim().ifBlank { null },
toCity = state.toCity.trim().ifBlank { null },
memberRelation = state.memberRelation.trim().ifBlank { null }
)

View File

@@ -4,6 +4,11 @@ sealed interface AppRoute {
data object Home : AppRoute
data class CreateBooking(val propertyId: String) : AppRoute
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class GuestInfoFromBookingDetails(
val propertyId: String,
val bookingId: String,
val guestId: String
) : AppRoute
data class GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class ManageRoomStaySelect(
val propertyId: String,

View File

@@ -52,6 +52,11 @@ internal fun handleBackNavigation(
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
is AppRoute.GuestInfoFromBookingDetails -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,

View File

@@ -2,6 +2,7 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.AuthzPolicy
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.navigation.AppRoute
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
@@ -25,6 +26,13 @@ internal fun renderBookingRoutes(
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onEditGuestInfo = { targetGuestId ->
refs.route.value = AppRoute.GuestInfoFromBookingDetails(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = targetGuestId
)
},
onEditSignature = { guestId ->
refs.route.value = AppRoute.GuestSignature(
currentRoute.propertyId,
@@ -51,6 +59,28 @@ internal fun renderBookingRoutes(
canCheckOutBooking = authz.canCheckOutBooking(currentRoute.propertyId)
)
is AppRoute.GuestInfoFromBookingDetails -> GuestInfoScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
initialGuest = refs.selectedGuest.value,
initialPhone = refs.selectedGuestPhone.value,
onBack = {
refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
},
onSave = {
refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.BookingPayments -> BookingPaymentsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,

View File

@@ -120,6 +120,7 @@ internal fun renderHomeGuestRoutes(
is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
initialGuest = refs.selectedGuest.value,
initialPhone = refs.selectedGuestPhone.value,

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCode
@@ -87,6 +88,7 @@ fun BookingDetailsTabsScreen(
bookingId: String,
guestId: String?,
onBack: () -> Unit,
onEditGuestInfo: (String) -> Unit,
onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit,
@@ -214,6 +216,7 @@ fun BookingDetailsTabsScreen(
guestId = guestId,
isLoading = detailsState.isLoading,
error = detailsState.error,
onEditGuestInfo = onEditGuestInfo,
onEditSignature = onEditSignature,
onOpenRazorpayQr = onOpenRazorpayQr,
onOpenPayments = onOpenPayments
@@ -396,6 +399,7 @@ private fun GuestInfoTabContent(
guestId: String?,
isLoading: Boolean,
error: String?,
onEditGuestInfo: (String) -> Unit,
onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit
@@ -420,6 +424,7 @@ private fun GuestInfoTabContent(
val bookingStatus = details?.status?.uppercase()
val canEditCheckIn = bookingStatus == "OPEN"
val canEditCheckOut = bookingStatus == "OPEN" || bookingStatus == "CHECKED_IN"
val resolvedGuestId = details?.guestId ?: guestId
LaunchedEffect(checkInFromDetails, checkOutFromDetails) {
draftCheckInAt.value = checkInFromDetails
@@ -479,7 +484,23 @@ private fun GuestInfoTabContent(
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
SectionCard(title = "Details") {
SectionCard(
title = "Details",
headerContent = {
IconButton(
enabled = !resolvedGuestId.isNullOrBlank(),
onClick = {
resolvedGuestId?.let(onEditGuestInfo)
}
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit guest details",
tint = MaterialTheme.colorScheme.primary
)
}
}
) {
GuestDetailRow(label = "Name", value = details?.guestName)
GuestDetailRow(label = "Nationality", value = details?.guestNationality)
GuestDetailRow(label = "Age", value = details?.guestAge?.let { formatAge(it) })
@@ -600,7 +621,6 @@ private fun GuestInfoTabContent(
}
Spacer(modifier = Modifier.height(16.dp))
val resolvedGuestId = details?.guestId ?: guestId
SignaturePreview(
propertyId = propertyId,
guestId = resolvedGuestId,