From e9c3b4f66970b9a9e82a8c4876974e368961e187 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 7 Feb 2026 22:33:43 +0530 Subject: [PATCH] booking: ability to edit more guest info --- .../core/booking/BookingProfileOptions.kt | 22 + .../core/viewmodel/CitySearchController.kt | 51 ++ .../trisolarispms/data/api/core/ApiService.kt | 2 + .../data/api/core/GeoSearchRepository.kt | 30 + .../trisolarispms/data/api/model/GeoModels.kt | 6 + .../data/api/model/GuestModels.kt | 5 +- .../data/api/service/BookingApi.kt | 8 + .../trisolarispms/data/api/service/GeoApi.kt | 20 + .../trisolarispms/ui/auth/AuthScreen.kt | 81 +-- .../ui/booking/BookingCreateScreen.kt | 108 +--- .../ui/booking/BookingCreateState.kt | 4 + .../ui/booking/BookingCreateViewModel.kt | 58 ++ .../ui/common/CityAutocompleteField.kt | 89 +++ .../ui/common/PhoneNumberCountryField.kt | 112 ++++ .../trisolarispms/ui/common/ScreenChrome.kt | 17 +- .../ui/guest/GuestInfoFormFields.kt | 396 ++++++++++++ .../trisolarispms/ui/guest/GuestInfoScreen.kt | 103 ++-- .../trisolarispms/ui/guest/GuestInfoState.kt | 16 +- .../ui/guest/GuestInfoViewModel.kt | 576 ++++++++++++++++-- .../trisolarispms/ui/navigation/AppRoute.kt | 5 + .../ui/navigation/MainBackNavigation.kt | 5 + .../ui/navigation/MainRoutesBooking.kt | 30 + .../ui/navigation/MainRoutesHomeGuest.kt | 1 + .../ui/roomstay/BookingDetailsTabsScreen.kt | 24 +- 24 files changed, 1515 insertions(+), 254 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/core/booking/BookingProfileOptions.kt create mode 100644 app/src/main/java/com/android/trisolarispms/core/viewmodel/CitySearchController.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/core/GeoSearchRepository.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/GeoModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/service/GeoApi.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/common/CityAutocompleteField.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/common/PhoneNumberCountryField.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoFormFields.kt diff --git a/app/src/main/java/com/android/trisolarispms/core/booking/BookingProfileOptions.kt b/app/src/main/java/com/android/trisolarispms/core/booking/BookingProfileOptions.kt new file mode 100644 index 0000000..1e9bc6e --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/core/booking/BookingProfileOptions.kt @@ -0,0 +1,22 @@ +package com.android.trisolarispms.core.booking + +object BookingProfileOptions { + val memberRelations: List = listOf( + "FRIENDS", + "FAMILY", + "GROUP", + "ALONE" + ) + + val transportModes: List = listOf( + "", + "CAR", + "BIKE", + "TRAIN", + "PLANE", + "BUS", + "FOOT", + "CYCLE", + "OTHER" + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/core/viewmodel/CitySearchController.kt b/app/src/main/java/com/android/trisolarispms/core/viewmodel/CitySearchController.kt new file mode 100644 index 0000000..c914d2c --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/core/viewmodel/CitySearchController.kt @@ -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) -> Unit, + private val search: suspend (query: String, limit: Int) -> List, + 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 + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt index 4fe5581..5c18f1b 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/core/GeoSearchRepository.kt b/app/src/main/java/com/android/trisolarispms/data/api/core/GeoSearchRepository.kt new file mode 100644 index 0000000..42350a8 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/core/GeoSearchRepository.kt @@ -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 { + 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" +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/GeoModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/GeoModels.kt new file mode 100644 index 0000000..eab60b5 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/GeoModels.kt @@ -0,0 +1,6 @@ +package com.android.trisolarispms.data.api.model + +data class CitySearchItemDto( + val city: String? = null, + val state: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt index 2229967..5105963 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt @@ -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 = emptyList(), val averageScore: Double? = null diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt index b85178d..796cd64 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt @@ -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 + @POST("properties/{propertyId}/bookings/{bookingId}/profile") + suspend fun updateBookingProfile( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: JsonObject + ): Response + @POST("properties/{propertyId}/bookings/{bookingId}/room-requests") suspend fun createRoomRequest( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/GeoApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/GeoApi.kt new file mode 100644 index 0000000..d3dc920 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/GeoApi.kt @@ -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> + + @GET("geo/countries/search") + suspend fun searchCountries( + @Query("q") query: String, + @Query("limit") limit: Int = 20 + ): Response> +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt index a60e67c..ced3b22 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthScreen.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt index 3ca8d3f..875e404 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt @@ -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) }, diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt index d24ac5a..48f9819 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt @@ -14,7 +14,11 @@ data class BookingCreateState( val billingCheckoutTime: String = "", val source: String = "", val fromCity: String = "", + val fromCitySuggestions: List = emptyList(), + val isFromCitySearchLoading: Boolean = false, val toCity: String = "", + val toCitySuggestions: List = emptyList(), + val isToCitySearchLoading: Boolean = false, val memberRelation: String = "", val transportMode: String = "CAR", val isTransportModeAuto: Boolean = true, diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt index ec5af36..2714c76 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt @@ -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 = _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) { diff --git a/app/src/main/java/com/android/trisolarispms/ui/common/CityAutocompleteField.kt b/app/src/main/java/com/android/trisolarispms/ui/common/CityAutocompleteField.kt new file mode 100644 index 0000000..002c8f3 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/common/CityAutocompleteField.kt @@ -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, + 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) + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/common/PhoneNumberCountryField.kt b/app/src/main/java/com/android/trisolarispms/ui/common/PhoneNumberCountryField.kt new file mode 100644 index 0000000..e94816b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/common/PhoneNumberCountryField.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/common/ScreenChrome.kt b/app/src/main/java/com/android/trisolarispms/ui/common/ScreenChrome.kt index 5dfbf18..7664d0c 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/common/ScreenChrome.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/common/ScreenChrome.kt @@ -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 ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoFormFields.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoFormFields.kt new file mode 100644 index 0000000..a9ae7ac --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoFormFields.kt @@ -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, + isNationalitySearchLoading: Boolean, + onNationalitySuggestionSelected: (String) -> Unit, + age: String, + onAgeChange: (String) -> Unit, + addressText: String, + onAddressChange: (String) -> Unit, + fromCity: String, + onFromCityChange: (String) -> Unit, + fromCitySuggestions: List, + isFromCitySearchLoading: Boolean, + onFromCitySuggestionSelected: (String) -> Unit, + toCity: String, + onToCityChange: (String) -> Unit, + toCitySuggestions: List, + 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 +) { + 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() +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt index a1fa62b..52d5683 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt @@ -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)) diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt index 33baa6e..21f1f72 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt @@ -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 = emptyList(), + val isNationalitySearchLoading: Boolean = false, val age: String = "", val addressText: String = "", + val fromCity: String = "", + val fromCitySuggestions: List = emptyList(), + val isFromCitySearchLoading: Boolean = false, + val toCity: String = "", + val toCitySuggestions: List = emptyList(), + val isToCitySearchLoading: Boolean = false, + val memberRelation: String = "", + val transportMode: String = "", + val childCount: String = "", + val maleCount: String = "", + val femaleCount: String = "", val vehicleNumbers: List = emptyList(), val isLoading: Boolean = false, val error: String? = null diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt index 84e1015..9035d2e 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt @@ -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 = _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 } + ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt index 655dff8..bd9daee 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt index e52b4a7..749dbaa 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt index 5b98806..c79c2cb 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt index 78a3ac1..4c9e0a3 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index 7b0a657..bcf61da 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -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,