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.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,

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

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.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,

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.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

View File

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

View File

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

View File

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

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.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
) )

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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