Compare commits
12 Commits
3a90aa848d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69a01a460 | ||
|
|
1000f2411c | ||
|
|
e9c3b4f669 | ||
|
|
90c2b6fb9f | ||
|
|
1e5f412f82 | ||
|
|
a67eacd77f | ||
|
|
f9b09e2376 | ||
|
|
e1250a0f32 | ||
|
|
d69ed60a6e | ||
|
|
56f13f5e79 | ||
|
|
9555ae2e40 | ||
|
|
9d942d6411 |
@@ -538,6 +538,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
|
||||
|
||||
### Non-negotiable coding rules
|
||||
|
||||
- Duplicate code is forbidden.
|
||||
- Never add duplicate business logic in multiple files.
|
||||
- Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns.
|
||||
- If similar logic appears 2+ times, extract shared function/class immediately.
|
||||
@@ -575,6 +576,14 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
|
||||
- Imports/packages follow the structure above.
|
||||
- Build passes: `./gradlew :app:compileDebugKotlin`.
|
||||
|
||||
### Room DB synchronization rule (mandatory)
|
||||
|
||||
- For any editable API-backed field, follow this write path only: `UI action -> server request -> Room DB update -> UI reacts from Room observers`.
|
||||
- Server is source of truth; do not bypass server by writing final business state directly from UI.
|
||||
- UI must render from Room-backed state, not from one-off API responses or direct text mutation.
|
||||
- Avoid forced full refresh after each mutation unless strictly required by backend limitations; prefer targeted Room updates for linked entities.
|
||||
- On mutation failure, keep prior DB state unchanged and surface error state to UI.
|
||||
|
||||
### Guest Documents Authorization (mandatory)
|
||||
|
||||
- View access: `ADMIN`, `MANAGER` (and super admin).
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
extensions.configure<ApplicationExtension>("android") {
|
||||
namespace = "com.android.trisolarispms"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.android.trisolarispms"
|
||||
@@ -29,16 +32,24 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
@@ -63,6 +74,9 @@ dependencies {
|
||||
implementation(libs.calendar.compose)
|
||||
implementation(libs.libphonenumber)
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.auth.ktx)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
@@ -38,6 +38,12 @@ class AuthzPolicy(
|
||||
fun canCreateBookingFor(propertyId: String): Boolean =
|
||||
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||
|
||||
fun canCheckOutRoomStay(propertyId: String): Boolean =
|
||||
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER)
|
||||
|
||||
fun canCheckOutBooking(propertyId: String): Boolean =
|
||||
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||
|
||||
fun canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
|
||||
|
||||
fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.android.trisolarispms.core.booking
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
private val defaultPropertyZone: ZoneId = ZoneId.of("Asia/Kolkata")
|
||||
|
||||
fun isFutureBookingCheckIn(expectedCheckInAt: String?, zoneId: ZoneId = defaultPropertyZone): Boolean {
|
||||
if (expectedCheckInAt.isNullOrBlank()) return false
|
||||
val checkInDate = runCatching { OffsetDateTime.parse(expectedCheckInAt).toLocalDate() }.getOrNull() ?: return false
|
||||
val today = LocalDate.now(zoneId)
|
||||
return checkInDate.isAfter(today)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.android.trisolarispms.core.booking
|
||||
|
||||
object BookingProfileOptions {
|
||||
val memberRelations: List<String> = listOf(
|
||||
"FRIENDS",
|
||||
"FAMILY",
|
||||
"GROUP",
|
||||
"ALONE"
|
||||
)
|
||||
|
||||
val transportModes: List<String> = listOf(
|
||||
"",
|
||||
"CAR",
|
||||
"BIKE",
|
||||
"TRAIN",
|
||||
"PLANE",
|
||||
"BUS",
|
||||
"FOOT",
|
||||
"CYCLE",
|
||||
"OTHER"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.android.trisolarispms.core.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CitySearchController(
|
||||
private val scope: CoroutineScope,
|
||||
private val onUpdate: (isLoading: Boolean, suggestions: List<String>) -> Unit,
|
||||
private val search: suspend (query: String, limit: Int) -> List<String>,
|
||||
private val minQueryLength: Int = 2,
|
||||
private val defaultLimit: Int = 20,
|
||||
private val debounceMs: Long = 300L
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
fun onQueryChanged(rawQuery: String) {
|
||||
val query = rawQuery.trim()
|
||||
job?.cancel()
|
||||
if (query.length < minQueryLength) {
|
||||
onUpdate(false, emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
job = scope.launch {
|
||||
delay(debounceMs)
|
||||
if (!isActive) return@launch
|
||||
onUpdate(true, emptyList())
|
||||
try {
|
||||
val suggestions = search(query, defaultLimit)
|
||||
if (isActive) {
|
||||
onUpdate(false, suggestions)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
if (isActive) {
|
||||
onUpdate(false, emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.android.trisolarispms.data.api.service.CancellationPolicyApi
|
||||
import com.android.trisolarispms.data.api.service.CardApi
|
||||
import com.android.trisolarispms.data.api.service.GuestApi
|
||||
import com.android.trisolarispms.data.api.service.GuestDocumentApi
|
||||
import com.android.trisolarispms.data.api.service.GeoApi
|
||||
import com.android.trisolarispms.data.api.service.ImageTagApi
|
||||
import com.android.trisolarispms.data.api.service.InboundEmailApi
|
||||
import com.android.trisolarispms.data.api.service.PropertyApi
|
||||
@@ -33,6 +34,7 @@ interface ApiService :
|
||||
CardApi,
|
||||
GuestApi,
|
||||
GuestDocumentApi,
|
||||
GeoApi,
|
||||
TransportApi,
|
||||
InboundEmailApi,
|
||||
AmenityApi,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.api.core
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
|
||||
object GeoSearchRepository {
|
||||
suspend fun searchCityDisplayValues(
|
||||
query: String,
|
||||
limit: Int = 20
|
||||
): List<String> {
|
||||
val response = ApiClient.create().searchCities(query = query, limit = limit)
|
||||
if (!response.isSuccessful) return emptyList()
|
||||
return response.body()
|
||||
.orEmpty()
|
||||
.mapNotNull(::extractCityDisplayValue)
|
||||
.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractCityDisplayValue(element: JsonElement): String? {
|
||||
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
|
||||
return element.asString.trim().ifBlank { null }
|
||||
}
|
||||
if (!element.isJsonObject) return null
|
||||
|
||||
val obj = element.asJsonObject
|
||||
val city = obj.get("city")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
|
||||
?: return null
|
||||
val state = obj.get("state")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
|
||||
return if (state == null) city else "$city, $state"
|
||||
}
|
||||
@@ -56,6 +56,7 @@ data class BookingListItem(
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val roomNumbers: List<Int> = emptyList(),
|
||||
val source: String? = null,
|
||||
val fromCity: String? = null,
|
||||
@@ -101,6 +102,13 @@ data class BookingExpectedDatesRequest(
|
||||
val expectedCheckOutAt: String? = null
|
||||
)
|
||||
|
||||
data class BookingRoomRequestCreateRequest(
|
||||
val roomTypeCode: String,
|
||||
val quantity: Int,
|
||||
val fromAt: String,
|
||||
val toAt: String
|
||||
)
|
||||
|
||||
data class BookingBillableNightsRequest(
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null
|
||||
@@ -112,6 +120,22 @@ data class BookingBillableNightsResponse(
|
||||
val billableNights: Long? = null
|
||||
)
|
||||
|
||||
data class BookingExpectedCheckoutPreviewRequest(
|
||||
val checkInAt: String,
|
||||
val billableNights: Int? = null,
|
||||
val billingMode: BookingBillingMode? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null
|
||||
)
|
||||
|
||||
data class BookingExpectedCheckoutPreviewResponse(
|
||||
val expectedCheckOutAt: String? = null,
|
||||
val billableNights: Int? = null,
|
||||
val billingMode: BookingBillingMode? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null
|
||||
)
|
||||
|
||||
data class BookingDetailsResponse(
|
||||
val id: String? = null,
|
||||
val status: String? = null,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class CitySearchItemDto(
|
||||
val city: String? = null,
|
||||
val state: String? = null
|
||||
)
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GuestDto(
|
||||
val id: String? = null,
|
||||
val name: String? = null,
|
||||
val phoneE164: String? = null,
|
||||
@SerializedName(value = "dob", alternate = ["age"])
|
||||
val dob: String? = null,
|
||||
val nationality: String? = null,
|
||||
val age: String? = null,
|
||||
val addressText: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val averageScore: Double? = null
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class RoomCreateRequest(
|
||||
val roomNumber: Int,
|
||||
val floor: Int? = null,
|
||||
@@ -47,16 +49,11 @@ data class RoomAvailabilityResponse(
|
||||
)
|
||||
|
||||
data class RoomAvailabilityRangeResponse(
|
||||
val roomTypeName: String? = null,
|
||||
val freeRoomNumbers: List<Int> = emptyList(),
|
||||
val freeCount: Int? = null
|
||||
)
|
||||
|
||||
data class RoomAvailableRateResponse(
|
||||
val roomId: String? = null,
|
||||
val roomNumber: Int? = null,
|
||||
@SerializedName(value = "roomTypeCode", alternate = ["code"])
|
||||
val roomTypeCode: String? = null,
|
||||
val roomTypeName: String? = null,
|
||||
val freeRoomNumbers: List<Int> = emptyList(),
|
||||
val freeCount: Int? = null,
|
||||
val averageRate: Double? = null,
|
||||
val currency: String? = null,
|
||||
val ratePlanCode: String? = null
|
||||
|
||||
@@ -12,5 +12,7 @@ data class ActiveRoomStayDto(
|
||||
val roomTypeName: String? = null,
|
||||
val fromAt: String? = null,
|
||||
val checkinAt: String? = null,
|
||||
val expectedCheckoutAt: String? = null
|
||||
val expectedCheckoutAt: String? = null,
|
||||
val nightlyRate: Long? = null,
|
||||
val currency: String? = null
|
||||
)
|
||||
|
||||
@@ -13,10 +13,14 @@ import com.android.trisolarispms.data.api.model.BookingListItem
|
||||
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingBillableNightsResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||
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
|
||||
@@ -62,6 +66,20 @@ interface BookingApi {
|
||||
@Body body: BookingExpectedDatesRequest
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/profile")
|
||||
suspend fun updateBookingProfile(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("bookingId") bookingId: String,
|
||||
@Body body: JsonObject
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
|
||||
suspend fun createRoomRequest(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("bookingId") bookingId: String,
|
||||
@Body body: BookingRoomRequestCreateRequest
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
|
||||
suspend fun previewBillableNights(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@@ -69,6 +87,12 @@ interface BookingApi {
|
||||
@Body body: BookingBillableNightsRequest
|
||||
): Response<BookingBillableNightsResponse>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/expected-checkout-preview")
|
||||
suspend fun previewExpectedCheckout(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Body body: BookingExpectedCheckoutPreviewRequest
|
||||
): Response<BookingExpectedCheckoutPreviewResponse>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/billing-policy")
|
||||
suspend fun updateBookingBillingPolicy(
|
||||
@Path("propertyId") propertyId: String,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.android.trisolarispms.data.api.service
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface GeoApi {
|
||||
@GET("geo/cities/search")
|
||||
suspend fun searchCities(
|
||||
@Query("q") query: String,
|
||||
@Query("limit") limit: Int = 20
|
||||
): Response<List<JsonElement>>
|
||||
|
||||
@GET("geo/countries/search")
|
||||
suspend fun searchCountries(
|
||||
@Query("q") query: String,
|
||||
@Query("limit") limit: Int = 20
|
||||
): Response<List<String>>
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.android.trisolarispms.data.api.service
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomBoardDto
|
||||
import com.android.trisolarispms.data.api.model.RoomCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.RoomDto
|
||||
@@ -55,7 +54,8 @@ interface RoomApi {
|
||||
suspend fun getRoomAvailabilityRange(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String
|
||||
@Query("to") to: String,
|
||||
@Query("ratePlanCode") ratePlanCode: String? = null
|
||||
): Response<List<RoomAvailabilityRangeResponse>>
|
||||
|
||||
@GET("properties/{propertyId}/rooms/available")
|
||||
@@ -63,14 +63,6 @@ interface RoomApi {
|
||||
@Path("propertyId") propertyId: String
|
||||
): Response<List<RoomDto>>
|
||||
|
||||
@GET("properties/{propertyId}/rooms/available-range-with-rate")
|
||||
suspend fun listAvailableRoomsWithRate(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String,
|
||||
@Query("ratePlanCode") ratePlanCode: String? = null
|
||||
): Response<List<RoomAvailableRateResponse>>
|
||||
|
||||
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
|
||||
suspend fun listRoomsByType(
|
||||
@Path("propertyId") propertyId: String,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.local.booking
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BookingDetailsCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM booking_details_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
fun observe(propertyId: String, bookingId: String): Flow<BookingDetailsCacheEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: BookingDetailsCacheEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM booking_details_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun delete(propertyId: String, bookingId: String)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.android.trisolarispms.data.local.booking
|
||||
|
||||
import androidx.room.Entity
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
|
||||
@Entity(
|
||||
tableName = "booking_details_cache",
|
||||
primaryKeys = ["propertyId", "bookingId"]
|
||||
)
|
||||
data class BookingDetailsCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val detailsId: String? = null,
|
||||
val status: String? = null,
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val guestNationality: String? = null,
|
||||
val guestAge: String? = null,
|
||||
val guestAddressText: String? = null,
|
||||
val guestSignatureUrl: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val roomNumbers: List<Int> = emptyList(),
|
||||
val source: String? = null,
|
||||
val fromCity: String? = null,
|
||||
val toCity: String? = null,
|
||||
val memberRelation: String? = null,
|
||||
val transportMode: String? = null,
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null,
|
||||
val checkInAt: String? = null,
|
||||
val checkOutAt: String? = null,
|
||||
val adultCount: Int? = null,
|
||||
val maleCount: Int? = null,
|
||||
val femaleCount: Int? = null,
|
||||
val childCount: Int? = null,
|
||||
val totalGuestCount: Int? = null,
|
||||
val expectedGuestCount: Int? = null,
|
||||
val totalNightlyRate: Long? = null,
|
||||
val notes: String? = null,
|
||||
val registeredByName: String? = null,
|
||||
val registeredByPhone: String? = null,
|
||||
val expectedPay: Long? = null,
|
||||
val amountCollected: Long? = null,
|
||||
val pending: Long? = null,
|
||||
val billableNights: Long? = null,
|
||||
val billingMode: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun BookingDetailsResponse.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): BookingDetailsCacheEntity = BookingDetailsCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
detailsId = id,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
guestNationality = guestNationality,
|
||||
guestAge = guestAge,
|
||||
guestAddressText = guestAddressText,
|
||||
guestSignatureUrl = guestSignatureUrl,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
adultCount = adultCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
childCount = childCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
totalNightlyRate = totalNightlyRate,
|
||||
notes = notes,
|
||||
registeredByName = registeredByName,
|
||||
registeredByPhone = registeredByPhone,
|
||||
expectedPay = expectedPay,
|
||||
amountCollected = amountCollected,
|
||||
pending = pending,
|
||||
billableNights = billableNights,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime
|
||||
)
|
||||
|
||||
internal fun BookingDetailsCacheEntity.toApiModel(): BookingDetailsResponse = BookingDetailsResponse(
|
||||
id = detailsId ?: bookingId,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
guestNationality = guestNationality,
|
||||
guestAge = guestAge,
|
||||
guestAddressText = guestAddressText,
|
||||
guestSignatureUrl = guestSignatureUrl,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
adultCount = adultCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
childCount = childCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
totalNightlyRate = totalNightlyRate,
|
||||
notes = notes,
|
||||
registeredByName = registeredByName,
|
||||
registeredByPhone = registeredByPhone,
|
||||
expectedPay = expectedPay,
|
||||
amountCollected = amountCollected,
|
||||
pending = pending,
|
||||
billableNights = billableNights,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.android.trisolarispms.data.local.booking
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class BookingDetailsRepository(
|
||||
private val dao: BookingDetailsCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeBookingDetails(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Flow<BookingDetailsResponse?> =
|
||||
dao.observe(propertyId = propertyId, bookingId = bookingId).map { cached ->
|
||||
cached?.toApiModel()
|
||||
}
|
||||
|
||||
suspend fun refreshBookingDetails(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Result<Unit> = runCatching {
|
||||
val response = createApi().getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body() ?: throw IllegalStateException("Load failed: empty response")
|
||||
dao.upsert(body.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
|
||||
}
|
||||
|
||||
suspend fun updateExpectedDates(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
body: BookingExpectedDatesRequest
|
||||
): Result<Unit> = runCatching {
|
||||
val api = createApi()
|
||||
val response = api.updateExpectedDates(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = body
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Update failed: ${response.code()}")
|
||||
}
|
||||
refreshBookingDetails(propertyId = propertyId, bookingId = bookingId).getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun storeSnapshot(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
details: BookingDetailsResponse
|
||||
) {
|
||||
dao.upsert(details.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.android.trisolarispms.data.local.bookinglist
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BookingListCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM booking_list_cache
|
||||
WHERE propertyId = :propertyId AND status = :status
|
||||
ORDER BY sortOrder ASC, bookingId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByStatus(propertyId: String, status: String): Flow<List<BookingListCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<BookingListCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM booking_list_cache
|
||||
WHERE propertyId = :propertyId AND status = :status
|
||||
"""
|
||||
)
|
||||
suspend fun deleteByPropertyAndStatus(propertyId: String, status: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForStatus(
|
||||
propertyId: String,
|
||||
status: String,
|
||||
items: List<BookingListCacheEntity>
|
||||
) {
|
||||
deleteByPropertyAndStatus(propertyId = propertyId, status = status)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.android.trisolarispms.data.local.bookinglist
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.BookingListItem
|
||||
|
||||
@Entity(
|
||||
tableName = "booking_list_cache",
|
||||
primaryKeys = ["propertyId", "bookingId"],
|
||||
indices = [
|
||||
Index(value = ["propertyId"]),
|
||||
Index(value = ["propertyId", "status"])
|
||||
]
|
||||
)
|
||||
data class BookingListCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val status: String? = null,
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val roomNumbers: List<Int> = emptyList(),
|
||||
val source: String? = null,
|
||||
val fromCity: String? = null,
|
||||
val toCity: String? = null,
|
||||
val memberRelation: String? = null,
|
||||
val transportMode: String? = null,
|
||||
val checkInAt: String? = null,
|
||||
val checkOutAt: String? = null,
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null,
|
||||
val adultCount: Int? = null,
|
||||
val childCount: Int? = null,
|
||||
val maleCount: Int? = null,
|
||||
val femaleCount: Int? = null,
|
||||
val totalGuestCount: Int? = null,
|
||||
val expectedGuestCount: Int? = null,
|
||||
val notes: String? = null,
|
||||
val pending: Long? = null,
|
||||
val billingMode: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun BookingListItem.toCacheEntity(
|
||||
propertyId: String,
|
||||
sortOrder: Int
|
||||
): BookingListCacheEntity? {
|
||||
val safeBookingId = id?.trim()?.ifBlank { null } ?: return null
|
||||
return BookingListCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = safeBookingId,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
adultCount = adultCount,
|
||||
childCount = childCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
notes = notes,
|
||||
pending = pending,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun BookingListCacheEntity.toApiModel(): BookingListItem = BookingListItem(
|
||||
id = bookingId,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
adultCount = adultCount,
|
||||
childCount = childCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
notes = notes,
|
||||
pending = pending,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.android.trisolarispms.data.local.bookinglist
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.BookingListItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class BookingListRepository(
|
||||
private val dao: BookingListCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByStatus(propertyId: String, status: String): Flow<List<BookingListItem>> =
|
||||
dao.observeByStatus(propertyId = propertyId, status = status).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refreshByStatus(propertyId: String, status: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listBookings(propertyId = propertyId, status = status)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexedNotNull { index, booking ->
|
||||
booking.toCacheEntity(propertyId = propertyId, sortOrder = index)
|
||||
}
|
||||
dao.replaceForStatus(propertyId = propertyId, status = status, items = rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.android.trisolarispms.data.local.core
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheDao
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheEntity
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListCacheDao
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListCacheEntity
|
||||
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentCacheDao
|
||||
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentCacheEntity
|
||||
import com.android.trisolarispms.data.local.payment.PaymentCacheDao
|
||||
import com.android.trisolarispms.data.local.payment.PaymentCacheEntity
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayCacheDao
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayQrEventCacheEntity
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayRequestCacheEntity
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheDao
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheEntity
|
||||
import androidx.room.migration.Migration
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
BookingDetailsCacheEntity::class,
|
||||
ActiveRoomStayCacheEntity::class,
|
||||
BookingListCacheEntity::class,
|
||||
PaymentCacheEntity::class,
|
||||
RazorpayRequestCacheEntity::class,
|
||||
RazorpayQrEventCacheEntity::class,
|
||||
GuestDocumentCacheEntity::class
|
||||
],
|
||||
version = 5,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(RoomConverters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun bookingDetailsCacheDao(): BookingDetailsCacheDao
|
||||
abstract fun activeRoomStayCacheDao(): ActiveRoomStayCacheDao
|
||||
abstract fun bookingListCacheDao(): BookingListCacheDao
|
||||
abstract fun paymentCacheDao(): PaymentCacheDao
|
||||
abstract fun razorpayCacheDao(): RazorpayCacheDao
|
||||
abstract fun guestDocumentCacheDao(): GuestDocumentCacheDao
|
||||
|
||||
companion object {
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `active_room_stay_cache` (
|
||||
`roomStayId` TEXT NOT NULL,
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT,
|
||||
`guestId` TEXT,
|
||||
`guestName` TEXT,
|
||||
`guestPhone` TEXT,
|
||||
`roomId` TEXT,
|
||||
`roomNumber` INTEGER,
|
||||
`roomTypeCode` TEXT,
|
||||
`roomTypeName` TEXT,
|
||||
`fromAt` TEXT,
|
||||
`checkinAt` TEXT,
|
||||
`expectedCheckoutAt` TEXT,
|
||||
`nightlyRate` INTEGER,
|
||||
`currency` TEXT,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`roomStayId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_active_room_stay_cache_propertyId`
|
||||
ON `active_room_stay_cache` (`propertyId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_active_room_stay_cache_propertyId_bookingId`
|
||||
ON `active_room_stay_cache` (`propertyId`, `bookingId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `booking_list_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`status` TEXT,
|
||||
`guestId` TEXT,
|
||||
`guestName` TEXT,
|
||||
`guestPhone` TEXT,
|
||||
`vehicleNumbers` TEXT NOT NULL,
|
||||
`roomNumbers` TEXT NOT NULL,
|
||||
`source` TEXT,
|
||||
`fromCity` TEXT,
|
||||
`toCity` TEXT,
|
||||
`memberRelation` TEXT,
|
||||
`transportMode` TEXT,
|
||||
`checkInAt` TEXT,
|
||||
`checkOutAt` TEXT,
|
||||
`expectedCheckInAt` TEXT,
|
||||
`expectedCheckOutAt` TEXT,
|
||||
`adultCount` INTEGER,
|
||||
`childCount` INTEGER,
|
||||
`maleCount` INTEGER,
|
||||
`femaleCount` INTEGER,
|
||||
`totalGuestCount` INTEGER,
|
||||
`expectedGuestCount` INTEGER,
|
||||
`notes` TEXT,
|
||||
`pending` INTEGER,
|
||||
`billingMode` TEXT,
|
||||
`billingCheckinTime` TEXT,
|
||||
`billingCheckoutTime` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_booking_list_cache_propertyId`
|
||||
ON `booking_list_cache` (`propertyId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_booking_list_cache_propertyId_status`
|
||||
ON `booking_list_cache` (`propertyId`, `status`)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `payment_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`paymentId` TEXT NOT NULL,
|
||||
`amount` INTEGER,
|
||||
`currency` TEXT,
|
||||
`method` TEXT,
|
||||
`gatewayPaymentId` TEXT,
|
||||
`gatewayTxnId` TEXT,
|
||||
`bankRefNum` TEXT,
|
||||
`mode` TEXT,
|
||||
`pgType` TEXT,
|
||||
`payerVpa` TEXT,
|
||||
`payerName` TEXT,
|
||||
`paymentSource` TEXT,
|
||||
`reference` TEXT,
|
||||
`notes` TEXT,
|
||||
`receivedAt` TEXT,
|
||||
`receivedByUserId` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`, `paymentId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_payment_cache_propertyId_bookingId`
|
||||
ON `payment_cache` (`propertyId`, `bookingId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `razorpay_request_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`requestKey` TEXT NOT NULL,
|
||||
`type` TEXT,
|
||||
`requestId` TEXT,
|
||||
`amount` INTEGER,
|
||||
`currency` TEXT,
|
||||
`status` TEXT,
|
||||
`createdAt` TEXT,
|
||||
`qrId` TEXT,
|
||||
`imageUrl` TEXT,
|
||||
`expiryAt` TEXT,
|
||||
`paymentLinkId` TEXT,
|
||||
`paymentLink` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`, `requestKey`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_razorpay_request_cache_propertyId_bookingId`
|
||||
ON `razorpay_request_cache` (`propertyId`, `bookingId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `razorpay_qr_event_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`qrId` TEXT NOT NULL,
|
||||
`eventKey` TEXT NOT NULL,
|
||||
`event` TEXT,
|
||||
`status` TEXT,
|
||||
`receivedAt` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`, `qrId`, `eventKey`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_razorpay_qr_event_cache_propertyId_bookingId_qrId`
|
||||
ON `razorpay_qr_event_cache` (`propertyId`, `bookingId`, `qrId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `guest_document_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`guestId` TEXT NOT NULL,
|
||||
`documentId` TEXT NOT NULL,
|
||||
`bookingId` TEXT,
|
||||
`uploadedByUserId` TEXT,
|
||||
`uploadedAt` TEXT,
|
||||
`originalFilename` TEXT,
|
||||
`contentType` TEXT,
|
||||
`sizeBytes` INTEGER,
|
||||
`extractedDataJson` TEXT,
|
||||
`extractedAt` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `guestId`, `documentId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_guest_document_cache_propertyId_guestId`
|
||||
ON `guest_document_cache` (`propertyId`, `guestId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.android.trisolarispms.data.local.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
|
||||
object LocalDatabaseProvider {
|
||||
@Volatile
|
||||
private var instance: AppDatabase? = null
|
||||
|
||||
fun get(context: Context): AppDatabase {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"trisolaris_pms_local.db"
|
||||
)
|
||||
.addMigrations(AppDatabase.MIGRATION_1_2)
|
||||
.addMigrations(AppDatabase.MIGRATION_2_3)
|
||||
.addMigrations(AppDatabase.MIGRATION_3_4)
|
||||
.addMigrations(AppDatabase.MIGRATION_4_5)
|
||||
.build()
|
||||
.also { built ->
|
||||
instance = built
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.android.trisolarispms.data.local.core
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
class RoomConverters {
|
||||
private val gson = Gson()
|
||||
private val stringListType = object : TypeToken<List<String>>() {}.type
|
||||
private val intListType = object : TypeToken<List<Int>>() {}.type
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(value: List<String>?): String = gson.toJson(value.orEmpty())
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(value: String?): List<String> {
|
||||
if (value.isNullOrBlank()) return emptyList()
|
||||
return runCatching { gson.fromJson<List<String>>(value, stringListType) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromIntList(value: List<Int>?): String = gson.toJson(value.orEmpty())
|
||||
|
||||
@TypeConverter
|
||||
fun toIntList(value: String?): List<Int> {
|
||||
if (value.isNullOrBlank()) return emptyList()
|
||||
return runCatching { gson.fromJson<List<Int>>(value, intListType) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.android.trisolarispms.data.local.guestdoc
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface GuestDocumentCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM guest_document_cache
|
||||
WHERE propertyId = :propertyId AND guestId = :guestId
|
||||
ORDER BY sortOrder ASC, documentId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByGuest(propertyId: String, guestId: String): Flow<List<GuestDocumentCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<GuestDocumentCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM guest_document_cache
|
||||
WHERE propertyId = :propertyId AND guestId = :guestId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteByGuest(propertyId: String, guestId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForGuest(
|
||||
propertyId: String,
|
||||
guestId: String,
|
||||
items: List<GuestDocumentCacheEntity>
|
||||
) {
|
||||
deleteByGuest(propertyId = propertyId, guestId = guestId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.android.trisolarispms.data.local.guestdoc
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.GuestDocumentDto
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
@Entity(
|
||||
tableName = "guest_document_cache",
|
||||
primaryKeys = ["propertyId", "guestId", "documentId"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "guestId"])
|
||||
]
|
||||
)
|
||||
data class GuestDocumentCacheEntity(
|
||||
val propertyId: String,
|
||||
val guestId: String,
|
||||
val documentId: String,
|
||||
val bookingId: String? = null,
|
||||
val uploadedByUserId: String? = null,
|
||||
val uploadedAt: String? = null,
|
||||
val originalFilename: String? = null,
|
||||
val contentType: String? = null,
|
||||
val sizeBytes: Long? = null,
|
||||
val extractedDataJson: String? = null,
|
||||
val extractedAt: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
private val extractedDataType = object : TypeToken<Map<String, String>>() {}.type
|
||||
|
||||
internal fun GuestDocumentDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
guestId: String,
|
||||
sortOrder: Int
|
||||
): GuestDocumentCacheEntity? {
|
||||
val safeDocumentId = id?.trim()?.ifBlank { null } ?: return null
|
||||
val extractedJson = runCatching { Gson().toJson(extractedData ?: emptyMap<String, String>()) }
|
||||
.getOrNull()
|
||||
return GuestDocumentCacheEntity(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
documentId = safeDocumentId,
|
||||
bookingId = bookingId,
|
||||
uploadedByUserId = uploadedByUserId,
|
||||
uploadedAt = uploadedAt,
|
||||
originalFilename = originalFilename,
|
||||
contentType = contentType,
|
||||
sizeBytes = sizeBytes,
|
||||
extractedDataJson = extractedJson,
|
||||
extractedAt = extractedAt,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun GuestDocumentCacheEntity.toApiModel(): GuestDocumentDto {
|
||||
val extractedMap = runCatching {
|
||||
if (extractedDataJson.isNullOrBlank()) emptyMap<String, String>()
|
||||
else Gson().fromJson<Map<String, String>>(extractedDataJson, extractedDataType)
|
||||
}.getOrElse { emptyMap() }
|
||||
return GuestDocumentDto(
|
||||
id = documentId,
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
bookingId = bookingId,
|
||||
uploadedByUserId = uploadedByUserId,
|
||||
uploadedAt = uploadedAt,
|
||||
originalFilename = originalFilename,
|
||||
contentType = contentType,
|
||||
sizeBytes = sizeBytes,
|
||||
extractedData = extractedMap,
|
||||
extractedAt = extractedAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.android.trisolarispms.data.local.guestdoc
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.GuestDocumentDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GuestDocumentRepository(
|
||||
private val dao: GuestDocumentCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByGuest(propertyId: String, guestId: String): Flow<List<GuestDocumentDto>> =
|
||||
dao.observeByGuest(propertyId = propertyId, guestId = guestId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refresh(propertyId: String, guestId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listGuestDocuments(propertyId = propertyId, guestId = guestId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexedNotNull { index, doc ->
|
||||
doc.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows)
|
||||
}
|
||||
|
||||
suspend fun storeSnapshot(
|
||||
propertyId: String,
|
||||
guestId: String,
|
||||
documents: List<GuestDocumentDto>
|
||||
) {
|
||||
val rows = documents.mapIndexedNotNull { index, doc ->
|
||||
doc.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.android.trisolarispms.data.local.payment
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PaymentCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM payment_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
ORDER BY sortOrder ASC, paymentId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<PaymentCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<PaymentCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM payment_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteByBooking(propertyId: String, bookingId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
items: List<PaymentCacheEntity>
|
||||
) {
|
||||
deleteByBooking(propertyId = propertyId, bookingId = bookingId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.android.trisolarispms.data.local.payment
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.PaymentDto
|
||||
|
||||
@Entity(
|
||||
tableName = "payment_cache",
|
||||
primaryKeys = ["propertyId", "bookingId", "paymentId"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "bookingId"])
|
||||
]
|
||||
)
|
||||
data class PaymentCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val paymentId: String,
|
||||
val amount: Long? = null,
|
||||
val currency: String? = null,
|
||||
val method: String? = null,
|
||||
val gatewayPaymentId: String? = null,
|
||||
val gatewayTxnId: String? = null,
|
||||
val bankRefNum: String? = null,
|
||||
val mode: String? = null,
|
||||
val pgType: String? = null,
|
||||
val payerVpa: String? = null,
|
||||
val payerName: String? = null,
|
||||
val paymentSource: String? = null,
|
||||
val reference: String? = null,
|
||||
val notes: String? = null,
|
||||
val receivedAt: String? = null,
|
||||
val receivedByUserId: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun PaymentDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
sortOrder: Int
|
||||
): PaymentCacheEntity? {
|
||||
val safePaymentId = id?.trim()?.ifBlank { null } ?: return null
|
||||
return PaymentCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
paymentId = safePaymentId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
method = method,
|
||||
gatewayPaymentId = gatewayPaymentId,
|
||||
gatewayTxnId = gatewayTxnId,
|
||||
bankRefNum = bankRefNum,
|
||||
mode = mode,
|
||||
pgType = pgType,
|
||||
payerVpa = payerVpa,
|
||||
payerName = payerName,
|
||||
paymentSource = paymentSource,
|
||||
reference = reference,
|
||||
notes = notes,
|
||||
receivedAt = receivedAt,
|
||||
receivedByUserId = receivedByUserId,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun PaymentCacheEntity.toApiModel(): PaymentDto = PaymentDto(
|
||||
id = paymentId,
|
||||
bookingId = bookingId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
method = method,
|
||||
gatewayPaymentId = gatewayPaymentId,
|
||||
gatewayTxnId = gatewayTxnId,
|
||||
bankRefNum = bankRefNum,
|
||||
mode = mode,
|
||||
pgType = pgType,
|
||||
payerVpa = payerVpa,
|
||||
payerName = payerName,
|
||||
paymentSource = paymentSource,
|
||||
reference = reference,
|
||||
notes = notes,
|
||||
receivedAt = receivedAt,
|
||||
receivedByUserId = receivedByUserId
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.android.trisolarispms.data.local.payment
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.PaymentDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class PaymentRepository(
|
||||
private val dao: PaymentCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<PaymentDto>> =
|
||||
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refresh(propertyId: String, bookingId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listPayments(propertyId = propertyId, bookingId = bookingId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexedNotNull { index, payment ->
|
||||
payment.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceForBooking(propertyId = propertyId, bookingId = bookingId, items = rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface RazorpayCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM razorpay_request_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
ORDER BY sortOrder ASC, requestKey ASC
|
||||
"""
|
||||
)
|
||||
fun observeRequests(propertyId: String, bookingId: String): Flow<List<RazorpayRequestCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertRequests(items: List<RazorpayRequestCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM razorpay_request_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteRequests(propertyId: String, bookingId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceRequests(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
items: List<RazorpayRequestCacheEntity>
|
||||
) {
|
||||
deleteRequests(propertyId = propertyId, bookingId = bookingId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertRequests(items)
|
||||
}
|
||||
}
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM razorpay_qr_event_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId AND qrId = :qrId
|
||||
ORDER BY sortOrder ASC, eventKey ASC
|
||||
"""
|
||||
)
|
||||
fun observeQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Flow<List<RazorpayQrEventCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertQrEvents(items: List<RazorpayQrEventCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM razorpay_qr_event_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId AND qrId = :qrId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteQrEvents(propertyId: String, bookingId: String, qrId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String,
|
||||
items: List<RazorpayQrEventCacheEntity>
|
||||
) {
|
||||
deleteQrEvents(propertyId = propertyId, bookingId = bookingId, qrId = qrId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertQrEvents(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class RazorpayCacheRepository(
|
||||
private val dao: RazorpayCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeRequests(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Flow<List<RazorpayRequestListItemDto>> =
|
||||
dao.observeRequests(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
fun observeQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Flow<List<RazorpayQrEventDto>> =
|
||||
dao.observeQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refreshRequests(propertyId: String, bookingId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listRazorpayRequests(propertyId = propertyId, bookingId = bookingId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexed { index, item ->
|
||||
item.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceRequests(propertyId = propertyId, bookingId = bookingId, items = rows)
|
||||
}
|
||||
|
||||
suspend fun refreshQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Result<List<RazorpayQrEventDto>> = runCatching {
|
||||
val response = createApi().listRazorpayQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body().orEmpty()
|
||||
val rows = body.mapIndexed { index, item ->
|
||||
item.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId,
|
||||
items = rows
|
||||
)
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
|
||||
|
||||
@Entity(
|
||||
tableName = "razorpay_qr_event_cache",
|
||||
primaryKeys = ["propertyId", "bookingId", "qrId", "eventKey"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "bookingId", "qrId"])
|
||||
]
|
||||
)
|
||||
data class RazorpayQrEventCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val qrId: String,
|
||||
val eventKey: String,
|
||||
val event: String? = null,
|
||||
val status: String? = null,
|
||||
val receivedAt: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun RazorpayQrEventDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String,
|
||||
sortOrder: Int
|
||||
): RazorpayQrEventCacheEntity {
|
||||
val key = "${receivedAt.orEmpty()}:${event.orEmpty()}:${status.orEmpty()}:$sortOrder"
|
||||
return RazorpayQrEventCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId,
|
||||
eventKey = key,
|
||||
event = event,
|
||||
status = status,
|
||||
receivedAt = receivedAt,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun RazorpayQrEventCacheEntity.toApiModel(): RazorpayQrEventDto = RazorpayQrEventDto(
|
||||
event = event,
|
||||
qrId = qrId,
|
||||
status = status,
|
||||
receivedAt = receivedAt
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||
|
||||
@Entity(
|
||||
tableName = "razorpay_request_cache",
|
||||
primaryKeys = ["propertyId", "bookingId", "requestKey"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "bookingId"])
|
||||
]
|
||||
)
|
||||
data class RazorpayRequestCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val requestKey: String,
|
||||
val type: String? = null,
|
||||
val requestId: String? = null,
|
||||
val amount: Long? = null,
|
||||
val currency: String? = null,
|
||||
val status: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val qrId: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
val expiryAt: String? = null,
|
||||
val paymentLinkId: String? = null,
|
||||
val paymentLink: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun RazorpayRequestListItemDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
sortOrder: Int
|
||||
): RazorpayRequestCacheEntity {
|
||||
val key = requestId?.trim()?.ifBlank { null }
|
||||
?: qrId?.trim()?.ifBlank { null }?.let { "qr:$it" }
|
||||
?: paymentLinkId?.trim()?.ifBlank { null }?.let { "plink:$it" }
|
||||
?: "idx:$sortOrder:${createdAt.orEmpty()}:${type.orEmpty()}"
|
||||
return RazorpayRequestCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
requestKey = key,
|
||||
type = type,
|
||||
requestId = requestId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
qrId = qrId,
|
||||
imageUrl = imageUrl,
|
||||
expiryAt = expiryAt,
|
||||
paymentLinkId = paymentLinkId,
|
||||
paymentLink = paymentLink,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun RazorpayRequestCacheEntity.toApiModel(): RazorpayRequestListItemDto = RazorpayRequestListItemDto(
|
||||
type = type,
|
||||
requestId = requestId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
qrId = qrId,
|
||||
imageUrl = imageUrl,
|
||||
expiryAt = expiryAt,
|
||||
paymentLinkId = paymentLinkId,
|
||||
paymentLink = paymentLink
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.android.trisolarispms.data.local.roomstay
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ActiveRoomStayCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM active_room_stay_cache
|
||||
WHERE propertyId = :propertyId
|
||||
ORDER BY roomNumber ASC, roomStayId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayCacheEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM active_room_stay_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
ORDER BY roomNumber ASC, roomStayId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<ActiveRoomStayCacheEntity>)
|
||||
|
||||
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId")
|
||||
suspend fun deleteByProperty(propertyId: String)
|
||||
|
||||
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId AND roomStayId = :roomStayId")
|
||||
suspend fun deleteByRoomStay(propertyId: String, roomStayId: String)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE active_room_stay_cache
|
||||
SET expectedCheckoutAt = :expectedCheckoutAt,
|
||||
updatedAtEpochMs = :updatedAtEpochMs
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun updateExpectedCheckoutAtForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
expectedCheckoutAt: String?,
|
||||
updatedAtEpochMs: Long
|
||||
)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForProperty(propertyId: String, items: List<ActiveRoomStayCacheEntity>) {
|
||||
deleteByProperty(propertyId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.android.trisolarispms.data.local.roomstay
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
|
||||
@Entity(
|
||||
tableName = "active_room_stay_cache",
|
||||
indices = [
|
||||
Index(value = ["propertyId"]),
|
||||
Index(value = ["propertyId", "bookingId"])
|
||||
]
|
||||
)
|
||||
data class ActiveRoomStayCacheEntity(
|
||||
@PrimaryKey
|
||||
val roomStayId: String,
|
||||
val propertyId: String,
|
||||
val bookingId: String? = null,
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val roomId: String? = null,
|
||||
val roomNumber: Int? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val roomTypeName: String? = null,
|
||||
val fromAt: String? = null,
|
||||
val checkinAt: String? = null,
|
||||
val expectedCheckoutAt: String? = null,
|
||||
val nightlyRate: Long? = null,
|
||||
val currency: String? = null,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun ActiveRoomStayDto.toCacheEntity(propertyId: String): ActiveRoomStayCacheEntity? {
|
||||
val stayId = roomStayId?.trim()?.ifBlank { null } ?: return null
|
||||
return ActiveRoomStayCacheEntity(
|
||||
roomStayId = stayId,
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
roomId = roomId,
|
||||
roomNumber = roomNumber,
|
||||
roomTypeCode = roomTypeCode,
|
||||
roomTypeName = roomTypeName,
|
||||
fromAt = fromAt,
|
||||
checkinAt = checkinAt,
|
||||
expectedCheckoutAt = expectedCheckoutAt,
|
||||
nightlyRate = nightlyRate,
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
|
||||
internal fun ActiveRoomStayCacheEntity.toApiModel(): ActiveRoomStayDto = ActiveRoomStayDto(
|
||||
roomStayId = roomStayId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
roomId = roomId,
|
||||
roomNumber = roomNumber,
|
||||
roomTypeCode = roomTypeCode,
|
||||
roomTypeName = roomTypeName,
|
||||
fromAt = fromAt,
|
||||
checkinAt = checkinAt,
|
||||
expectedCheckoutAt = expectedCheckoutAt,
|
||||
nightlyRate = nightlyRate,
|
||||
currency = currency
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.android.trisolarispms.data.local.roomstay
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class ActiveRoomStayRepository(
|
||||
private val dao: ActiveRoomStayCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayDto>> =
|
||||
dao.observeByProperty(propertyId = propertyId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayDto>> =
|
||||
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refresh(propertyId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listActiveRoomStays(propertyId = propertyId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapNotNull { it.toCacheEntity(propertyId = propertyId) }
|
||||
dao.replaceForProperty(propertyId = propertyId, items = rows)
|
||||
}
|
||||
|
||||
suspend fun removeFromCache(propertyId: String, roomStayId: String) {
|
||||
dao.deleteByRoomStay(propertyId = propertyId, roomStayId = roomStayId)
|
||||
}
|
||||
|
||||
suspend fun patchExpectedCheckoutForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
expectedCheckoutAt: String?
|
||||
) {
|
||||
dao.updateExpectedCheckoutAtForBooking(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
expectedCheckoutAt = expectedCheckoutAt,
|
||||
updatedAtEpochMs = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -11,18 +11,13 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -36,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
|
||||
@@ -51,39 +49,50 @@ fun BookingCreateScreen(
|
||||
viewModel: BookingCreateViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val showCheckInPicker = remember { mutableStateOf(false) }
|
||||
val showCheckOutPicker = remember { mutableStateOf(false) }
|
||||
val showCheckInDatePicker = remember { mutableStateOf(false) }
|
||||
val showCheckInTimePicker = remember { mutableStateOf(false) }
|
||||
val showCheckOutDatePicker = remember { mutableStateOf(false) }
|
||||
val showCheckOutTimePicker = remember { mutableStateOf(false) }
|
||||
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val checkInTime = remember { mutableStateOf("12:00") }
|
||||
val checkOutTime = remember { mutableStateOf("11:00") }
|
||||
val checkInNow = remember { mutableStateOf(true) }
|
||||
val sourceMenuExpanded = remember { mutableStateOf(false) }
|
||||
val sourceOptions = listOf("WALKIN", "OTA", "AGENT")
|
||||
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 phoneCountryMenuExpanded = remember { mutableStateOf(false) }
|
||||
val phoneCountries = remember { phoneCountryOptions() }
|
||||
val phoneCountrySearch = remember { mutableStateOf("") }
|
||||
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
|
||||
|
||||
val applyCheckInSelection: (LocalDate, String) -> Unit = { date, time ->
|
||||
checkInDate.value = date
|
||||
checkInTime.value = time
|
||||
val checkInAt = formatBookingIso(date, time)
|
||||
viewModel.onExpectedCheckInAtChange(checkInAt)
|
||||
val currentCheckOutDate = checkOutDate.value
|
||||
if (currentCheckOutDate != null && currentCheckOutDate.isBefore(date)) {
|
||||
checkOutDate.value = date
|
||||
val adjustedCheckOutAt = formatBookingIso(date, checkOutTime.value)
|
||||
viewModel.onExpectedCheckOutAtChange(adjustedCheckOutAt)
|
||||
}
|
||||
viewModel.autoSetBillingFromCheckIn(checkInAt)
|
||||
viewModel.refreshExpectedCheckoutPreview(propertyId)
|
||||
}
|
||||
|
||||
LaunchedEffect(propertyId) {
|
||||
viewModel.reset()
|
||||
viewModel.loadBillingPolicy(propertyId)
|
||||
val now = OffsetDateTime.now()
|
||||
checkInDate.value = now.toLocalDate()
|
||||
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
val nowIso = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
viewModel.onExpectedCheckInAtChange(nowIso)
|
||||
viewModel.autoSetBillingFromCheckIn(nowIso)
|
||||
checkInNow.value = true
|
||||
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
|
||||
checkOutDate.value = defaultCheckoutDate
|
||||
checkOutTime.value = "11:00"
|
||||
viewModel.onExpectedCheckOutAtChange(formatBookingIso(defaultCheckoutDate, checkOutTime.value))
|
||||
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
|
||||
}
|
||||
|
||||
LaunchedEffect(state.expectedCheckOutAt) {
|
||||
val parsed = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull() ?: return@LaunchedEffect
|
||||
checkOutDate.value = parsed.toLocalDate()
|
||||
checkOutTime.value = parsed.format(timeFormatter)
|
||||
}
|
||||
|
||||
SaveTopBarScaffold(
|
||||
@@ -99,60 +108,30 @@ fun BookingCreateScreen(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Check in now")
|
||||
Switch(
|
||||
checked = checkInNow.value,
|
||||
onCheckedChange = { enabled ->
|
||||
checkInNow.value = enabled
|
||||
if (enabled) {
|
||||
val now = OffsetDateTime.now()
|
||||
checkInDate.value = now.toLocalDate()
|
||||
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
val nowIso = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
viewModel.onExpectedCheckInAtChange(nowIso)
|
||||
viewModel.autoSetBillingFromCheckIn(nowIso)
|
||||
} else {
|
||||
viewModel.onExpectedCheckInAtChange("")
|
||||
}
|
||||
}
|
||||
)
|
||||
val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()
|
||||
}
|
||||
if (!checkInNow.value) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
|
||||
}.orEmpty(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Expected Check-in") },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showCheckInPicker.value = true }) {
|
||||
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val checkOutDisplay = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
|
||||
}.orEmpty(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Expected Check-out") },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showCheckOutPicker.value = true }) {
|
||||
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
val cardDateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
val cardTimeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
|
||||
val totalTimeText = remember(state.expectedCheckInAt, state.expectedCheckOutAt) {
|
||||
val start = runCatching { OffsetDateTime.parse(state.expectedCheckInAt) }.getOrNull()
|
||||
val end = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull()
|
||||
formatBookingDurationText(start, end)
|
||||
}
|
||||
BookingDateTimeQuickEditorCard(
|
||||
checkInDateText = checkInDisplay?.format(cardDateFormatter) ?: "--/--/----",
|
||||
checkInTimeText = checkInDisplay?.format(cardTimeFormatter) ?: "--:--",
|
||||
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
|
||||
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
|
||||
totalTimeText = totalTimeText,
|
||||
checkInEditable = true,
|
||||
onCheckInDateClick = { showCheckInDatePicker.value = true },
|
||||
onCheckInTimeClick = { showCheckInTimePicker.value = true },
|
||||
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
|
||||
onCheckOutTimeClick = { showCheckOutTimePicker.value = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
@@ -181,6 +160,7 @@ fun BookingCreateScreen(
|
||||
onClick = {
|
||||
billingModeMenuExpanded.value = false
|
||||
viewModel.onBillingModeChange(mode)
|
||||
viewModel.refreshExpectedCheckoutPreview(propertyId)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -191,7 +171,10 @@ fun BookingCreateScreen(
|
||||
BookingTimePickerTextField(
|
||||
value = state.billingCheckoutTime,
|
||||
label = { Text("Billing check-out (HH:mm)") },
|
||||
onTimeSelected = viewModel::onBillingCheckoutTimeChange,
|
||||
onTimeSelected = { selectedTime ->
|
||||
viewModel.onBillingCheckoutTimeChange(selectedTime)
|
||||
viewModel.refreshExpectedCheckoutPreview(propertyId)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -241,115 +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))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = sourceMenuExpanded.value,
|
||||
onExpandedChange = { sourceMenuExpanded.value = !sourceMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.source,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Source") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceMenuExpanded.value) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = sourceMenuExpanded.value,
|
||||
onDismissRequest = { sourceMenuExpanded.value = false }
|
||||
) {
|
||||
sourceOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
sourceMenuExpanded.value = false
|
||||
viewModel.onSourceChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -372,7 +271,7 @@ fun BookingCreateScreen(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onDismissRequest = { relationMenuExpanded.value = false }
|
||||
) {
|
||||
relationOptions.forEach { option ->
|
||||
BookingProfileOptions.memberRelations.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
@@ -389,7 +288,7 @@ fun BookingCreateScreen(
|
||||
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.transportMode,
|
||||
value = state.transportMode.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transport Mode") },
|
||||
@@ -404,9 +303,10 @@ 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(option) },
|
||||
text = { Text(optionLabel) },
|
||||
onClick = {
|
||||
transportMenuExpanded.value = false
|
||||
viewModel.onTransportModeChange(option)
|
||||
@@ -473,36 +373,50 @@ fun BookingCreateScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showCheckInPicker.value) {
|
||||
BookingDateTimePickerDialog(
|
||||
title = "Select check-in",
|
||||
initialDate = checkInDate.value,
|
||||
initialTime = checkInTime.value,
|
||||
if (showCheckInDatePicker.value) {
|
||||
BookingDatePickerDialog(
|
||||
initialDate = checkInDate.value ?: LocalDate.now(),
|
||||
minDate = LocalDate.now(),
|
||||
onDismiss = { showCheckInPicker.value = false },
|
||||
onConfirm = { date, time ->
|
||||
checkInDate.value = date
|
||||
checkInTime.value = time
|
||||
val formatted = formatBookingIso(date, time)
|
||||
viewModel.onExpectedCheckInAtChange(formatted)
|
||||
showCheckInPicker.value = false
|
||||
onDismiss = { showCheckInDatePicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
applyCheckInSelection(selectedDate, checkInTime.value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckOutPicker.value) {
|
||||
BookingDateTimePickerDialog(
|
||||
title = "Select check-out",
|
||||
initialDate = checkOutDate.value,
|
||||
initialTime = checkOutTime.value,
|
||||
if (showCheckInTimePicker.value) {
|
||||
BookingTimePickerDialog(
|
||||
initialTime = checkInTime.value,
|
||||
onDismiss = { showCheckInTimePicker.value = false },
|
||||
onTimeSelected = { selectedTime ->
|
||||
val selectedDate = checkInDate.value ?: LocalDate.now()
|
||||
applyCheckInSelection(selectedDate, selectedTime)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckOutDatePicker.value) {
|
||||
BookingDatePickerDialog(
|
||||
initialDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now()),
|
||||
minDate = checkInDate.value ?: LocalDate.now(),
|
||||
onDismiss = { showCheckOutPicker.value = false },
|
||||
onConfirm = { date, time ->
|
||||
checkOutDate.value = date
|
||||
checkOutTime.value = time
|
||||
val formatted = formatBookingIso(date, time)
|
||||
onDismiss = { showCheckOutDatePicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
checkOutDate.value = selectedDate
|
||||
val formatted = formatBookingIso(selectedDate, checkOutTime.value)
|
||||
viewModel.onExpectedCheckOutAtChange(formatted)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckOutTimePicker.value) {
|
||||
BookingTimePickerDialog(
|
||||
initialTime = checkOutTime.value,
|
||||
onDismiss = { showCheckOutTimePicker.value = false },
|
||||
onTimeSelected = { selectedTime ->
|
||||
checkOutTime.value = selectedTime
|
||||
val selectedDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now())
|
||||
val formatted = formatBookingIso(selectedDate, selectedTime)
|
||||
viewModel.onExpectedCheckOutAtChange(formatted)
|
||||
showCheckOutPicker.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,11 +12,16 @@ data class BookingCreateState(
|
||||
val expectedCheckOutAt: String = "",
|
||||
val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
|
||||
val billingCheckoutTime: String = "",
|
||||
val source: String = "WALKIN",
|
||||
val source: String = "",
|
||||
val fromCity: String = "",
|
||||
val fromCitySuggestions: List<String> = emptyList(),
|
||||
val isFromCitySearchLoading: Boolean = false,
|
||||
val toCity: String = "",
|
||||
val toCitySuggestions: List<String> = emptyList(),
|
||||
val isToCitySearchLoading: Boolean = false,
|
||||
val memberRelation: String = "",
|
||||
val transportMode: String = "CAR",
|
||||
val isTransportModeAuto: Boolean = true,
|
||||
val childCount: String = "",
|
||||
val maleCount: String = "",
|
||||
val femaleCount: String = "",
|
||||
|
||||
@@ -1,28 +1,87 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
|
||||
import com.android.trisolarispms.data.api.model.GuestDto
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class BookingCreateViewModel : ViewModel() {
|
||||
class BookingCreateViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private companion object {
|
||||
const val DEFAULT_PREVIEW_BILLABLE_NIGHTS = 1
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(BookingCreateState())
|
||||
val state: StateFlow<BookingCreateState> = _state
|
||||
private val activeRoomStayRepository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private val bookingListRepository = BookingListRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
|
||||
)
|
||||
private val bookingDetailsRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
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()
|
||||
}
|
||||
|
||||
fun onExpectedCheckInAtChange(value: String) {
|
||||
_state.update { it.copy(expectedCheckInAt = value, error = null) }
|
||||
_state.update { current ->
|
||||
val withCheckIn = current.copy(expectedCheckInAt = value, error = null)
|
||||
withCheckIn.withDefaultTransportModeForCheckIn(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun onExpectedCheckOutAtChange(value: String) {
|
||||
@@ -76,6 +135,50 @@ class BookingCreateViewModel : ViewModel() {
|
||||
_state.update { it.copy(billingCheckoutTime = value, error = null) }
|
||||
}
|
||||
|
||||
fun refreshExpectedCheckoutPreview(propertyId: String) {
|
||||
if (propertyId.isBlank()) return
|
||||
val requestBody = buildExpectedCheckoutPreviewRequest(_state.value) ?: return
|
||||
val requestId = ++expectedCheckoutPreviewRequestId
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.previewExpectedCheckout(
|
||||
propertyId = propertyId,
|
||||
body = requestBody
|
||||
)
|
||||
val expectedCheckOutAt = response.body()?.expectedCheckOutAt?.trim().orEmpty()
|
||||
if (!response.isSuccessful || expectedCheckOutAt.isBlank() || requestId != expectedCheckoutPreviewRequestId) {
|
||||
return@launch
|
||||
}
|
||||
_state.update { current ->
|
||||
if (requestId != expectedCheckoutPreviewRequestId) {
|
||||
current
|
||||
} else {
|
||||
current.copy(expectedCheckOutAt = expectedCheckOutAt, error = null)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Keep user-entered check-out on preview failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildExpectedCheckoutPreviewRequest(state: BookingCreateState): BookingExpectedCheckoutPreviewRequest? {
|
||||
val expectedCheckInAt = state.expectedCheckInAt.trim()
|
||||
if (expectedCheckInAt.isBlank()) return null
|
||||
val customBillingCheckoutTime = state.billingCheckoutTime.trim().ifBlank { null }
|
||||
return BookingExpectedCheckoutPreviewRequest(
|
||||
checkInAt = expectedCheckInAt,
|
||||
billableNights = DEFAULT_PREVIEW_BILLABLE_NIGHTS,
|
||||
billingMode = state.billingMode,
|
||||
billingCheckoutTime = if (state.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
|
||||
customBillingCheckoutTime
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun onPhoneCountryChange(value: String) {
|
||||
val option = findPhoneCountryOption(value)
|
||||
_state.update { current ->
|
||||
@@ -139,10 +242,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) {
|
||||
@@ -150,19 +279,34 @@ class BookingCreateViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun onTransportModeChange(value: String) {
|
||||
_state.update { it.copy(transportMode = value, error = null) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
transportMode = value,
|
||||
isTransportModeAuto = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onChildCountChange(value: String) {
|
||||
_state.update { it.copy(childCount = value.filter { it.isDigit() }, error = null) }
|
||||
_state.update { current ->
|
||||
current.copy(childCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onMaleCountChange(value: String) {
|
||||
_state.update { it.copy(maleCount = value.filter { it.isDigit() }, error = null) }
|
||||
_state.update { current ->
|
||||
current.copy(maleCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onFemaleCountChange(value: String) {
|
||||
_state.update { it.copy(femaleCount = value.filter { it.isDigit() }, error = null) }
|
||||
_state.update { current ->
|
||||
current.copy(femaleCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onExpectedGuestCountChange(value: String) {
|
||||
@@ -230,6 +374,10 @@ class BookingCreateViewModel : ViewModel() {
|
||||
)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
syncCreateBookingCaches(
|
||||
propertyId = propertyId,
|
||||
bookingId = body.id
|
||||
)
|
||||
_state.update { it.copy(isLoading = false, error = null) }
|
||||
onDone(body, null, phone)
|
||||
} else {
|
||||
@@ -240,4 +388,34 @@ class BookingCreateViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncCreateBookingCaches(propertyId: String, bookingId: String?) {
|
||||
activeRoomStayRepository.refresh(propertyId = propertyId)
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "OPEN")
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "CHECKED_IN")
|
||||
val safeBookingId = bookingId?.trim().orEmpty()
|
||||
if (safeBookingId.isNotBlank()) {
|
||||
bookingDetailsRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = safeBookingId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookingCreateState.withDefaultTransportModeForCheckIn(expectedCheckInAt: String): BookingCreateState {
|
||||
if (!isTransportModeAuto) return this
|
||||
val defaultMode = if (isFutureBookingCheckIn(expectedCheckInAt)) "" else "CAR"
|
||||
if (transportMode == defaultMode) return this
|
||||
return copy(transportMode = defaultMode)
|
||||
}
|
||||
|
||||
private fun BookingCreateState.withDefaultMemberRelationForFamily(): BookingCreateState {
|
||||
if (memberRelation.isNotBlank()) return this
|
||||
val child = childCount.toIntOrNull() ?: 0
|
||||
val male = maleCount.toIntOrNull() ?: 0
|
||||
val female = femaleCount.toIntOrNull() ?: 0
|
||||
val shouldDefaultFamily = child >= 1 || (male >= 1 && female >= 1)
|
||||
if (!shouldDefaultFamily) return this
|
||||
return copy(memberRelation = "FAMILY")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
internal fun BookingDateTimeQuickEditorCard(
|
||||
checkInDateText: String,
|
||||
checkInTimeText: String,
|
||||
checkOutDateText: String,
|
||||
checkOutTimeText: String,
|
||||
totalTimeText: String?,
|
||||
checkInEditable: Boolean,
|
||||
checkOutEditable: Boolean = true,
|
||||
onCheckInDateClick: () -> Unit,
|
||||
onCheckInTimeClick: () -> Unit,
|
||||
onCheckOutDateClick: () -> Unit,
|
||||
onCheckOutTimeClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
BookingDateTimeQuickEditorRow(
|
||||
label = "Check In Time:",
|
||||
dateText = checkInDateText,
|
||||
timeText = checkInTimeText,
|
||||
editable = checkInEditable,
|
||||
onDateClick = onCheckInDateClick,
|
||||
onTimeClick = onCheckInTimeClick
|
||||
)
|
||||
BookingDateTimeQuickEditorRow(
|
||||
label = "Check Out Time:",
|
||||
dateText = checkOutDateText,
|
||||
timeText = checkOutTimeText,
|
||||
editable = checkOutEditable,
|
||||
onDateClick = onCheckOutDateClick,
|
||||
onTimeClick = onCheckOutTimeClick
|
||||
)
|
||||
if (!totalTimeText.isNullOrBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Total Time:",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
text = totalTimeText,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookingDateTimeQuickEditorRow(
|
||||
label: String,
|
||||
dateText: String,
|
||||
timeText: String,
|
||||
editable: Boolean,
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit
|
||||
) {
|
||||
val valueColor = if (editable) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = timeText,
|
||||
color = valueColor,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.clickable(enabled = editable, onClick = onTimeClick)
|
||||
.padding(vertical = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = dateText,
|
||||
color = valueColor,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.clickable(enabled = editable, onClick = onDateClick)
|
||||
.padding(vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BookingDatePickerDialog(
|
||||
initialDate: LocalDate,
|
||||
minDate: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onDateSelected: (LocalDate) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState = rememberUpdatedState(onDismiss)
|
||||
val selectDateState = rememberUpdatedState(onDateSelected)
|
||||
|
||||
DisposableEffect(context, initialDate, minDate) {
|
||||
val dialog = DatePickerDialog(
|
||||
context,
|
||||
{ _, year, monthOfYear, dayOfMonth ->
|
||||
selectDateState.value(LocalDate.of(year, monthOfYear + 1, dayOfMonth))
|
||||
},
|
||||
initialDate.year,
|
||||
initialDate.monthValue - 1,
|
||||
initialDate.dayOfMonth
|
||||
)
|
||||
val minDateMillis = minDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
dialog.datePicker.minDate = minDateMillis
|
||||
dialog.setOnDismissListener { dismissState.value() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BookingTimePickerDialog(
|
||||
initialTime: String,
|
||||
onDismiss: () -> Unit,
|
||||
onTimeSelected: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState = rememberUpdatedState(onDismiss)
|
||||
val selectTimeState = rememberUpdatedState(onTimeSelected)
|
||||
|
||||
val initialHour = initialTime.split(":").getOrNull(0)?.toIntOrNull()?.coerceIn(0, 23) ?: 12
|
||||
val initialMinute = initialTime.split(":").getOrNull(1)?.toIntOrNull()?.coerceIn(0, 59) ?: 0
|
||||
|
||||
DisposableEffect(context, initialHour, initialMinute) {
|
||||
val dialog = TimePickerDialog(
|
||||
context,
|
||||
{ _, hourOfDay, minute ->
|
||||
selectTimeState.value("%02d:%02d".format(hourOfDay, minute))
|
||||
},
|
||||
initialHour,
|
||||
initialMinute,
|
||||
true
|
||||
)
|
||||
dialog.setOnDismissListener { dismissState.value() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatBookingDurationText(
|
||||
start: OffsetDateTime?,
|
||||
end: OffsetDateTime?
|
||||
): String? {
|
||||
if (start == null || end == null || !end.isAfter(start)) return null
|
||||
val totalMinutes = Duration.between(start, end).toMinutes()
|
||||
val totalHours = totalMinutes / 60
|
||||
val minutes = totalMinutes % 60
|
||||
return if (totalHours >= 24) {
|
||||
val days = totalHours / 24
|
||||
val hoursLeft = totalHours % 24
|
||||
"%02dd:%02dh left".format(days, hoursLeft)
|
||||
} else if (totalHours > 0) {
|
||||
"${totalHours}h ${minutes}m"
|
||||
} else {
|
||||
"${minutes}m"
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||
import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun BookingExpectedDatesScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
status: String?,
|
||||
expectedCheckInAt: String?,
|
||||
expectedCheckOutAt: String?,
|
||||
onBack: () -> Unit,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val checkInTime = remember { mutableStateOf("12:00") }
|
||||
val checkOutTime = remember { mutableStateOf("11:00") }
|
||||
val isLoading = remember { mutableStateOf(false) }
|
||||
val error = remember { mutableStateOf<String?>(null) }
|
||||
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
|
||||
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
|
||||
val today = LocalDate.now(displayZone)
|
||||
val bookingStatus = status?.uppercase()
|
||||
val editableCheckIn = bookingStatus == "OPEN"
|
||||
val billableNights = remember { mutableStateOf<Long?>(null) }
|
||||
val isBillableNightsLoading = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(bookingId) {
|
||||
val now = OffsetDateTime.now()
|
||||
expectedCheckInAt?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
|
||||
val zoned = parsed.atZoneSameInstant(displayZone)
|
||||
checkInDate.value = zoned.toLocalDate()
|
||||
checkInTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
}
|
||||
} ?: run {
|
||||
checkInDate.value = now.toLocalDate()
|
||||
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
}
|
||||
expectedCheckOutAt?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
|
||||
val zoned = parsed.atZoneSameInstant(displayZone)
|
||||
checkOutDate.value = zoned.toLocalDate()
|
||||
checkOutTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
}
|
||||
} ?: run {
|
||||
checkOutDate.value = (checkInDate.value ?: now.toLocalDate()).plusDays(1)
|
||||
checkOutTime.value = "11:00"
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
propertyId,
|
||||
bookingId,
|
||||
bookingStatus,
|
||||
checkInDate.value,
|
||||
checkInTime.value,
|
||||
checkOutDate.value,
|
||||
checkOutTime.value
|
||||
) {
|
||||
val inAt = if (editableCheckIn) {
|
||||
checkInDate.value?.let { formatBookingIso(it, checkInTime.value) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val outAt = checkOutDate.value?.let { formatBookingIso(it, checkOutTime.value) }
|
||||
val previewBody = when (bookingStatus) {
|
||||
"OPEN" -> {
|
||||
if (inAt.isNullOrBlank() || outAt.isNullOrBlank()) null
|
||||
else BookingBillableNightsRequest(expectedCheckInAt = inAt, expectedCheckOutAt = outAt)
|
||||
}
|
||||
"CHECKED_IN" -> {
|
||||
if (outAt.isNullOrBlank()) null
|
||||
else BookingBillableNightsRequest(expectedCheckOutAt = outAt)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (previewBody == null) {
|
||||
billableNights.value = null
|
||||
isBillableNightsLoading.value = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
isBillableNightsLoading.value = true
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.previewBillableNights(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = previewBody
|
||||
)
|
||||
billableNights.value = if (response.isSuccessful) response.body()?.billableNights else null
|
||||
} catch (_: Exception) {
|
||||
billableNights.value = null
|
||||
} finally {
|
||||
isBillableNightsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
SaveTopBarScaffold(
|
||||
title = "Update Expected Dates",
|
||||
onBack = onBack,
|
||||
saveEnabled = !isLoading.value,
|
||||
onSave = {
|
||||
val inAt = if (editableCheckIn) {
|
||||
checkInDate.value?.let { formatBookingIso(it, checkInTime.value) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val outAt = checkOutDate.value?.let { formatBookingIso(it, checkOutTime.value) }
|
||||
val hasCheckInChanged = editableCheckIn && !isSameBookingDateTime(inAt, expectedCheckInAt)
|
||||
val hasCheckOutChanged = !isSameBookingDateTime(outAt, expectedCheckOutAt)
|
||||
if (!hasCheckInChanged && !hasCheckOutChanged) {
|
||||
onDone()
|
||||
return@SaveTopBarScaffold
|
||||
}
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
scope.launch {
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.updateExpectedDates(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingExpectedDatesRequest(
|
||||
expectedCheckInAt = inAt,
|
||||
expectedCheckOutAt = outAt
|
||||
)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
onDone()
|
||||
} else {
|
||||
error.value = "Update failed: ${response.code()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error.value = e.localizedMessage ?: "Update failed"
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
PaddedScreenColumn(padding = padding) {
|
||||
if (editableCheckIn) {
|
||||
OutlinedTextField(
|
||||
value = checkInDate.value?.let {
|
||||
formatBookingIso(it, checkInTime.value)
|
||||
}?.let { iso ->
|
||||
runCatching {
|
||||
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
|
||||
}.getOrDefault(iso)
|
||||
}.orEmpty(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Expected Check-in") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BookingDateTimePickerInline(
|
||||
title = "Select check-in",
|
||||
initialDate = checkInDate.value,
|
||||
initialTime = checkInTime.value,
|
||||
minDate = today,
|
||||
onValueChange = { date, time ->
|
||||
checkInDate.value = date
|
||||
checkInTime.value = time
|
||||
if (checkOutDate.value?.isBefore(date) == true) {
|
||||
checkOutDate.value = date
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = checkOutDate.value?.let {
|
||||
formatBookingIso(it, checkOutTime.value)
|
||||
}?.let { iso ->
|
||||
runCatching {
|
||||
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
|
||||
}.getOrDefault(iso)
|
||||
}.orEmpty(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Expected Check-out") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = if (isBillableNightsLoading.value) {
|
||||
"Billable Nights: Calculating..."
|
||||
} else {
|
||||
"Billable Nights: ${billableNights.value?.toString() ?: "-"}"
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
val checkOutMinDate = maxOf(checkInDate.value ?: today, today)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BookingDateTimePickerInline(
|
||||
title = "Select check-out",
|
||||
initialDate = checkOutDate.value,
|
||||
initialTime = checkOutTime.value,
|
||||
minDate = checkOutMinDate,
|
||||
onValueChange = { date, time ->
|
||||
checkOutDate.value = date
|
||||
checkOutTime.value = time
|
||||
}
|
||||
)
|
||||
if (isLoading.value) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
error.value?.let {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameBookingDateTime(current: String?, original: String?): Boolean {
|
||||
if (current.isNullOrBlank() && original.isNullOrBlank()) return true
|
||||
if (current.isNullOrBlank() || original.isNullOrBlank()) return false
|
||||
val currentInstant = runCatching { OffsetDateTime.parse(current).toInstant() }.getOrNull()
|
||||
val originalInstant = runCatching { OffsetDateTime.parse(original).toInstant() }.getOrNull()
|
||||
return if (currentInstant != null && originalInstant != null) {
|
||||
currentInstant == originalInstant
|
||||
} else {
|
||||
current.trim() == original.trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
|
||||
@Composable
|
||||
fun BookingRoomRequestScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
fromAt: String,
|
||||
toAt: String,
|
||||
onBack: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
viewModel: BookingRoomRequestViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(propertyId, fromAt, toAt) {
|
||||
viewModel.load(propertyId, fromAt, toAt)
|
||||
}
|
||||
|
||||
BackTopBarScaffold(
|
||||
title = "Select Room Types",
|
||||
onBack = onBack,
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val hasSelection = state.roomTypes.any { it.quantity > 0 }
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.submit(propertyId, bookingId, fromAt, toAt, onDone)
|
||||
},
|
||||
enabled = hasSelection && !state.isSubmitting,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (state.isSubmitting) "Saving..." else "Proceed")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
PaddedScreenColumn(padding = padding, contentPadding = 16.dp) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (!state.isLoading) {
|
||||
if (state.roomTypes.isEmpty()) {
|
||||
Text(text = "No room types found")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(state.roomTypes) { item ->
|
||||
RoomTypeQuantityCard(
|
||||
item = item,
|
||||
onIncrease = { viewModel.increaseQuantity(item.roomTypeCode) },
|
||||
onDecrease = { viewModel.decreaseQuantity(item.roomTypeCode) },
|
||||
onRateChange = { viewModel.updateRate(item.roomTypeCode, it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomTypeQuantityCard(
|
||||
item: BookingRoomTypeQuantityItem,
|
||||
onIncrease: () -> Unit,
|
||||
onDecrease: () -> Unit,
|
||||
onRateChange: (String) -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = item.roomTypeName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "${item.roomTypeCode} • Available: ${item.maxQuantity}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onDecrease, enabled = item.quantity > 0) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "Decrease")
|
||||
}
|
||||
Text(
|
||||
text = item.quantity.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onIncrease, enabled = item.quantity < item.maxQuantity) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Increase")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = item.rateInput,
|
||||
onValueChange = onRateChange,
|
||||
label = { Text("Rate / night") },
|
||||
placeholder = { Text("Enter rate") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
prefix = { item.currency?.takeIf { it.isNotBlank() }?.let { Text("$it ") } },
|
||||
supportingText = {
|
||||
item.ratePlanCode?.takeIf { it.isNotBlank() }?.let { Text("Plan: $it") }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
data class BookingRoomTypeQuantityItem(
|
||||
val roomTypeCode: String,
|
||||
val roomTypeName: String,
|
||||
val maxQuantity: Int,
|
||||
val quantity: Int = 0,
|
||||
val rateInput: String = "",
|
||||
val currency: String? = null,
|
||||
val ratePlanCode: String? = null
|
||||
)
|
||||
|
||||
data class BookingRoomRequestState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSubmitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
val roomTypes: List<BookingRoomTypeQuantityItem> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class BookingRoomRequestViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(BookingRoomRequestState())
|
||||
val state: StateFlow<BookingRoomRequestState> = _state
|
||||
private val activeRoomStayRepository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private val bookingListRepository = BookingListRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
|
||||
)
|
||||
private val bookingDetailsRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
|
||||
fun load(propertyId: String, fromAt: String, toAt: String) {
|
||||
if (propertyId.isBlank()) return
|
||||
val fromDate = fromAt.toDateOnly() ?: run {
|
||||
_state.update { it.copy(error = "Invalid check-in date") }
|
||||
return
|
||||
}
|
||||
val toDate = toAt.toDateOnly() ?: run {
|
||||
_state.update { it.copy(error = "Invalid check-out date") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val availabilityResponse = api.getRoomAvailabilityRange(propertyId, from = fromDate, to = toDate)
|
||||
if (!availabilityResponse.isSuccessful) {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${availabilityResponse.code()}") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val currentByType = _state.value.roomTypes.associateBy { it.roomTypeCode }
|
||||
val items = availabilityResponse.body().orEmpty()
|
||||
.mapNotNull { entry ->
|
||||
val maxQuantity = (entry.freeCount ?: entry.freeRoomNumbers.size).coerceAtLeast(0)
|
||||
if (maxQuantity <= 0) return@mapNotNull null
|
||||
val code = entry.roomTypeCode?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
val name = entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() } ?: code
|
||||
val previous = currentByType[code]
|
||||
val defaultRateInput = entry.averageRate?.toLong()?.toString().orEmpty()
|
||||
BookingRoomTypeQuantityItem(
|
||||
roomTypeCode = code,
|
||||
roomTypeName = name,
|
||||
maxQuantity = maxQuantity,
|
||||
quantity = previous?.quantity?.coerceAtMost(maxQuantity) ?: 0,
|
||||
rateInput = previous?.rateInput ?: defaultRateInput,
|
||||
currency = entry.currency,
|
||||
ratePlanCode = entry.ratePlanCode
|
||||
)
|
||||
}
|
||||
.sortedBy { it.roomTypeName }
|
||||
|
||||
_state.update { it.copy(isLoading = false, roomTypes = items, error = null) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseQuantity(roomTypeCode: String) {
|
||||
updateQuantity(roomTypeCode, delta = 1)
|
||||
}
|
||||
|
||||
fun decreaseQuantity(roomTypeCode: String) {
|
||||
updateQuantity(roomTypeCode, delta = -1)
|
||||
}
|
||||
|
||||
fun updateRate(roomTypeCode: String, value: String) {
|
||||
val digitsOnly = value.filter { it.isDigit() }
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
roomTypes = current.roomTypes.map { item ->
|
||||
if (item.roomTypeCode == roomTypeCode) {
|
||||
item.copy(rateInput = digitsOnly)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
},
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submit(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
fromAt: String,
|
||||
toAt: String,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank() || fromAt.isBlank() || toAt.isBlank()) {
|
||||
_state.update { it.copy(error = "Booking dates are missing") }
|
||||
return
|
||||
}
|
||||
val selected = _state.value.roomTypes.filter { it.quantity > 0 }
|
||||
if (selected.isEmpty()) {
|
||||
_state.update { it.copy(error = "Select at least one room type") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSubmitting = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
for (item in selected) {
|
||||
val response = api.createRoomRequest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingRoomRequestCreateRequest(
|
||||
roomTypeCode = item.roomTypeCode,
|
||||
quantity = item.quantity,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
_state.update { it.copy(isSubmitting = false, error = "Create failed: ${response.code()}") }
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
syncRoomRequestCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update { it.copy(isSubmitting = false, error = null) }
|
||||
onDone()
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isSubmitting = false, error = e.localizedMessage ?: "Create failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateQuantity(roomTypeCode: String, delta: Int) {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
roomTypes = current.roomTypes.map { item ->
|
||||
if (item.roomTypeCode != roomTypeCode) {
|
||||
item
|
||||
} else {
|
||||
val updated = (item.quantity + delta).coerceIn(0, item.maxQuantity)
|
||||
item.copy(quantity = updated)
|
||||
}
|
||||
},
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncRoomRequestCaches(propertyId: String, bookingId: String) {
|
||||
activeRoomStayRepository.refresh(propertyId = propertyId)
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "OPEN")
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "CHECKED_IN")
|
||||
bookingDetailsRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toDateOnly(): String? =
|
||||
runCatching {
|
||||
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CityAutocompleteField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
suggestions: List<String>,
|
||||
isLoading: Boolean,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
val query = value.trim()
|
||||
val canShowMenu = expanded.value && (isLoading || suggestions.isNotEmpty() || query.length >= 2)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = canShowMenu,
|
||||
onExpandedChange = { expanded.value = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { input ->
|
||||
onValueChange(input)
|
||||
expanded.value = input.trim().length >= 2
|
||||
},
|
||||
label = { Text(label) },
|
||||
supportingText = {
|
||||
if (query.length < 2) {
|
||||
Text("Type at least 2 letters")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = expanded.value && (isLoading || suggestions.isNotEmpty())
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = canShowMenu,
|
||||
onDismissRequest = { expanded.value = false }
|
||||
) {
|
||||
if (isLoading) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Searching...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else if (query.length >= 2 && suggestions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No cities found") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
suggestions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
expanded.value = false
|
||||
onSuggestionSelected(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
|
||||
import com.android.trisolarispms.ui.booking.phoneCountryOptions
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PhoneNumberCountryField(
|
||||
phoneCountryCode: String,
|
||||
onPhoneCountryCodeChange: (String) -> Unit,
|
||||
phoneNationalNumber: String,
|
||||
onPhoneNationalNumberChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
countryLabel: String = "Country",
|
||||
numberLabel: String = "Number",
|
||||
countryWeight: Float = 0.35f,
|
||||
numberWeight: Float = 0.65f
|
||||
) {
|
||||
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
|
||||
val phoneCountrySearch = remember { mutableStateOf("") }
|
||||
val phoneCountries = remember { phoneCountryOptions() }
|
||||
val selectedCountry = findPhoneCountryOption(phoneCountryCode)
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
|
||||
modifier = Modifier.weight(countryWeight)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(countryLabel) },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onDismissRequest = { phoneCountryMenuExpanded.value = false }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = phoneCountrySearch.value,
|
||||
onValueChange = { phoneCountrySearch.value = it },
|
||||
label = { Text("Search") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val filtered = phoneCountries.filter { option ->
|
||||
val query = phoneCountrySearch.value.trim()
|
||||
if (query.isBlank()) {
|
||||
true
|
||||
} else {
|
||||
option.name.contains(query, ignoreCase = true) ||
|
||||
option.code.contains(query, ignoreCase = true) ||
|
||||
option.dialCode.contains(query)
|
||||
}
|
||||
}
|
||||
filtered.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.name} (+${option.dialCode})") },
|
||||
onClick = {
|
||||
phoneCountryMenuExpanded.value = false
|
||||
phoneCountrySearch.value = ""
|
||||
onPhoneCountryCodeChange(option.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = phoneNationalNumber,
|
||||
onValueChange = onPhoneNationalNumberChange,
|
||||
label = { Text(numberLabel) },
|
||||
prefix = { Text("+${selectedCountry.dialCode}") },
|
||||
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.weight(numberWeight),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
@@ -89,13 +91,20 @@ fun SaveTopBarScaffold(
|
||||
fun PaddedScreenColumn(
|
||||
padding: PaddingValues,
|
||||
contentPadding: Dp = 24.dp,
|
||||
scrollable: Boolean = false,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val baseModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(contentPadding)
|
||||
val scrollModifier = if (scrollable) {
|
||||
baseModifier.verticalScroll(rememberScrollState())
|
||||
} else {
|
||||
baseModifier
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(contentPadding),
|
||||
modifier = scrollModifier,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
content = content
|
||||
)
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.core.booking.BookingProfileOptions
|
||||
import com.android.trisolarispms.ui.common.CityAutocompleteField
|
||||
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GuestInfoFormFields(
|
||||
phoneCountryCode: String,
|
||||
onPhoneCountryCodeChange: (String) -> Unit,
|
||||
phoneNationalNumber: String,
|
||||
onPhoneNationalNumberChange: (String) -> Unit,
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
nationality: String,
|
||||
onNationalityChange: (String) -> Unit,
|
||||
nationalitySuggestions: List<String>,
|
||||
isNationalitySearchLoading: Boolean,
|
||||
onNationalitySuggestionSelected: (String) -> Unit,
|
||||
age: String,
|
||||
onAgeChange: (String) -> Unit,
|
||||
addressText: String,
|
||||
onAddressChange: (String) -> Unit,
|
||||
fromCity: String,
|
||||
onFromCityChange: (String) -> Unit,
|
||||
fromCitySuggestions: List<String>,
|
||||
isFromCitySearchLoading: Boolean,
|
||||
onFromCitySuggestionSelected: (String) -> Unit,
|
||||
toCity: String,
|
||||
onToCityChange: (String) -> Unit,
|
||||
toCitySuggestions: List<String>,
|
||||
isToCitySearchLoading: Boolean,
|
||||
onToCitySuggestionSelected: (String) -> Unit,
|
||||
memberRelation: String,
|
||||
onMemberRelationChange: (String) -> Unit,
|
||||
transportMode: String,
|
||||
onTransportModeChange: (String) -> Unit,
|
||||
childCount: String,
|
||||
onChildCountChange: (String) -> Unit,
|
||||
maleCount: String,
|
||||
onMaleCountChange: (String) -> Unit,
|
||||
femaleCount: String,
|
||||
onFemaleCountChange: (String) -> Unit,
|
||||
vehicleNumbers: List<String>
|
||||
) {
|
||||
val showDobPicker = remember { mutableStateOf(false) }
|
||||
val nationalityMenuExpanded = remember { mutableStateOf(false) }
|
||||
val relationMenuExpanded = remember { mutableStateOf(false) }
|
||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||
val dobFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
val transportOptions = remember(vehicleNumbers) {
|
||||
if (vehicleNumbers.isNotEmpty()) {
|
||||
listOf("", "CAR", "BIKE")
|
||||
} else {
|
||||
BookingProfileOptions.transportModes
|
||||
}
|
||||
}
|
||||
var dobFieldValue by remember {
|
||||
mutableStateOf(TextFieldValue(text = age, selection = TextRange(age.length)))
|
||||
}
|
||||
|
||||
LaunchedEffect(age) {
|
||||
if (age != dobFieldValue.text) {
|
||||
dobFieldValue = TextFieldValue(text = age, selection = TextRange(age.length))
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PhoneNumberCountryField(
|
||||
phoneCountryCode = phoneCountryCode,
|
||||
onPhoneCountryCodeChange = onPhoneCountryCodeChange,
|
||||
phoneNationalNumber = phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = onPhoneNationalNumberChange,
|
||||
numberLabel = "Phone (optional)"
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
|
||||
onExpandedChange = { nationalityMenuExpanded.value = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = nationality,
|
||||
onValueChange = { value ->
|
||||
onNationalityChange(value)
|
||||
nationalityMenuExpanded.value = value.trim().length >= 3
|
||||
},
|
||||
label = { Text("Nationality (optional)") },
|
||||
supportingText = {
|
||||
if (nationality.trim().length < 3) {
|
||||
Text("Type at least 3 letters")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty())
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
|
||||
onDismissRequest = { nationalityMenuExpanded.value = false }
|
||||
) {
|
||||
if (isNationalitySearchLoading) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Searching...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else if (nationality.trim().length >= 3 && nationalitySuggestions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No countries found") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
nationalitySuggestions.forEach { suggestion ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(suggestion)
|
||||
},
|
||||
onClick = {
|
||||
nationalityMenuExpanded.value = false
|
||||
onNationalitySuggestionSelected(suggestion)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = dobFieldValue,
|
||||
onValueChange = { input ->
|
||||
val formatted = formatDobInput(input.text)
|
||||
dobFieldValue = TextFieldValue(
|
||||
text = formatted,
|
||||
selection = TextRange(formatted.length)
|
||||
)
|
||||
if (formatted != age) onAgeChange(formatted)
|
||||
},
|
||||
label = { Text("DOB (dd/MM/yyyy)") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CalendarMonth,
|
||||
contentDescription = "Pick DOB",
|
||||
modifier = Modifier.clickable { showDobPicker.value = true }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = addressText,
|
||||
onValueChange = onAddressChange,
|
||||
label = { Text("Address (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
CityAutocompleteField(
|
||||
value = fromCity,
|
||||
onValueChange = onFromCityChange,
|
||||
label = "From City (optional)",
|
||||
suggestions = fromCitySuggestions,
|
||||
isLoading = isFromCitySearchLoading,
|
||||
onSuggestionSelected = onFromCitySuggestionSelected
|
||||
)
|
||||
CityAutocompleteField(
|
||||
value = toCity,
|
||||
onValueChange = onToCityChange,
|
||||
label = "To City (optional)",
|
||||
suggestions = toCitySuggestions,
|
||||
isLoading = isToCitySearchLoading,
|
||||
onSuggestionSelected = onToCitySuggestionSelected
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = memberRelation.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Member Relation") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onDismissRequest = { relationMenuExpanded.value = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Not set") },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
onMemberRelationChange("")
|
||||
}
|
||||
)
|
||||
BookingProfileOptions.memberRelations.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
onMemberRelationChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = transportMode.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transport Mode") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = transportMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onDismissRequest = { transportMenuExpanded.value = false }
|
||||
) {
|
||||
transportOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.ifBlank { "Not set" }) },
|
||||
onClick = {
|
||||
transportMenuExpanded.value = false
|
||||
onTransportModeChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = childCount,
|
||||
onValueChange = onChildCountChange,
|
||||
label = { Text("Child Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = maleCount,
|
||||
onValueChange = onMaleCountChange,
|
||||
label = { Text("Male Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = femaleCount,
|
||||
onValueChange = onFemaleCountChange,
|
||||
label = { Text("Female Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDobPicker.value) {
|
||||
val initialDate = age.toLocalDateOrNull(dobFormatter) ?: LocalDate.now().minusYears(18)
|
||||
GuestDobDatePickerDialog(
|
||||
initialDate = initialDate,
|
||||
onDismiss = { showDobPicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
onAgeChange(selectedDate.format(dobFormatter))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuestDobDatePickerDialog(
|
||||
initialDate: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onDateSelected: (LocalDate) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState by rememberUpdatedState(onDismiss)
|
||||
val selectState by rememberUpdatedState(onDateSelected)
|
||||
|
||||
DisposableEffect(context, initialDate) {
|
||||
val dialog = DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, dayOfMonth ->
|
||||
selectState(LocalDate.of(year, month + 1, dayOfMonth))
|
||||
},
|
||||
initialDate.year,
|
||||
initialDate.monthValue - 1,
|
||||
initialDate.dayOfMonth
|
||||
)
|
||||
val todayMillis = LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
val minDateMillis = LocalDate.of(1900, 1, 1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
dialog.datePicker.maxDate = todayMillis
|
||||
dialog.datePicker.minDate = minDateMillis
|
||||
dialog.setOnDismissListener { dismissState() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLocalDateOrNull(formatter: DateTimeFormatter): LocalDate? =
|
||||
runCatching { LocalDate.parse(this, formatter) }.getOrNull()
|
||||
|
||||
private fun formatDobInput(raw: String): String {
|
||||
val digits = raw.filter { it.isDigit() }.take(8)
|
||||
if (digits.isEmpty()) return ""
|
||||
val builder = StringBuilder(digits.length + 2)
|
||||
digits.forEachIndexed { index, char ->
|
||||
builder.append(char)
|
||||
if ((index == 1 || index == 3) && index != digits.lastIndex) {
|
||||
builder.append('/')
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -22,6 +18,7 @@ import com.android.trisolarispms.ui.common.SaveTopBarScaffold
|
||||
@Composable
|
||||
fun GuestInfoScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
guestId: String,
|
||||
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
|
||||
initialPhone: String?,
|
||||
@@ -31,51 +28,75 @@ fun GuestInfoScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(guestId) {
|
||||
LaunchedEffect(propertyId, bookingId, guestId) {
|
||||
viewModel.reset()
|
||||
viewModel.setInitial(initialGuest, initialPhone)
|
||||
viewModel.loadGuest(propertyId, guestId, initialPhone)
|
||||
viewModel.loadGuest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fallbackPhone = initialPhone
|
||||
)
|
||||
}
|
||||
|
||||
SaveTopBarScaffold(
|
||||
title = "Guest Info",
|
||||
onBack = onBack,
|
||||
onSave = { viewModel.submit(propertyId, guestId, onSave) }
|
||||
onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
|
||||
) { padding ->
|
||||
PaddedScreenColumn(padding = padding) {
|
||||
OutlinedTextField(
|
||||
value = state.phoneE164,
|
||||
onValueChange = viewModel::onPhoneChange,
|
||||
label = { Text("Phone E164 (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = viewModel::onNameChange,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.nationality,
|
||||
onValueChange = viewModel::onNationalityChange,
|
||||
label = { Text("Nationality (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.age,
|
||||
onValueChange = viewModel::onAgeChange,
|
||||
label = { Text("DOB (dd/MM/yyyy)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.addressText,
|
||||
onValueChange = viewModel::onAddressChange,
|
||||
label = { Text("Address (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
PaddedScreenColumn(
|
||||
padding = padding,
|
||||
scrollable = true
|
||||
) {
|
||||
GuestInfoFormFields(
|
||||
phoneCountryCode = state.phoneCountryCode,
|
||||
onPhoneCountryCodeChange = { code ->
|
||||
viewModel.onPhoneCountryChange(
|
||||
value = code,
|
||||
propertyId = propertyId,
|
||||
guestId = guestId
|
||||
)
|
||||
},
|
||||
phoneNationalNumber = state.phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = { number ->
|
||||
viewModel.onPhoneNationalNumberChange(
|
||||
value = number,
|
||||
propertyId = propertyId,
|
||||
guestId = guestId
|
||||
)
|
||||
},
|
||||
name = state.name,
|
||||
onNameChange = viewModel::onNameChange,
|
||||
nationality = state.nationality,
|
||||
onNationalityChange = viewModel::onNationalityChange,
|
||||
nationalitySuggestions = state.nationalitySuggestions,
|
||||
isNationalitySearchLoading = state.isNationalitySearchLoading,
|
||||
onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
|
||||
age = state.age,
|
||||
onAgeChange = viewModel::onAgeChange,
|
||||
addressText = state.addressText,
|
||||
onAddressChange = viewModel::onAddressChange,
|
||||
fromCity = state.fromCity,
|
||||
onFromCityChange = viewModel::onFromCityChange,
|
||||
fromCitySuggestions = state.fromCitySuggestions,
|
||||
isFromCitySearchLoading = state.isFromCitySearchLoading,
|
||||
onFromCitySuggestionSelected = viewModel::onFromCitySuggestionSelected,
|
||||
toCity = state.toCity,
|
||||
onToCityChange = viewModel::onToCityChange,
|
||||
toCitySuggestions = state.toCitySuggestions,
|
||||
isToCitySearchLoading = state.isToCitySearchLoading,
|
||||
onToCitySuggestionSelected = viewModel::onToCitySuggestionSelected,
|
||||
memberRelation = state.memberRelation,
|
||||
onMemberRelationChange = viewModel::onMemberRelationChange,
|
||||
transportMode = state.transportMode,
|
||||
onTransportModeChange = viewModel::onTransportModeChange,
|
||||
childCount = state.childCount,
|
||||
onChildCountChange = viewModel::onChildCountChange,
|
||||
maleCount = state.maleCount,
|
||||
onMaleCountChange = viewModel::onMaleCountChange,
|
||||
femaleCount = state.femaleCount,
|
||||
onFemaleCountChange = viewModel::onFemaleCountChange,
|
||||
vehicleNumbers = state.vehicleNumbers
|
||||
)
|
||||
if (state.isLoading) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
data class GuestInfoState(
|
||||
val phoneE164: String = "",
|
||||
val phoneCountryCode: String = "IN",
|
||||
val phoneNationalNumber: String = "",
|
||||
val name: String = "",
|
||||
val nationality: String = "",
|
||||
val nationalitySuggestions: List<String> = emptyList(),
|
||||
val isNationalitySearchLoading: Boolean = false,
|
||||
val age: String = "",
|
||||
val addressText: String = "",
|
||||
val fromCity: String = "",
|
||||
val fromCitySuggestions: List<String> = emptyList(),
|
||||
val isFromCitySearchLoading: Boolean = false,
|
||||
val toCity: String = "",
|
||||
val toCitySuggestions: List<String> = emptyList(),
|
||||
val isToCitySearchLoading: Boolean = false,
|
||||
val memberRelation: String = "",
|
||||
val transportMode: String = "",
|
||||
val childCount: String = "",
|
||||
val maleCount: String = "",
|
||||
val femaleCount: String = "",
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
|
||||
@@ -1,25 +1,103 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
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.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
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
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GuestInfoViewModel : ViewModel() {
|
||||
class GuestInfoViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(GuestInfoState())
|
||||
val state: StateFlow<GuestInfoState> = _state
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
private val roomStayRepository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
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 +106,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 +129,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 +201,421 @@ 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 (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) {
|
||||
roomStayRepository.refresh(propertyId = propertyId)
|
||||
bookingRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
_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 }
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
@Composable
|
||||
fun GuestSignatureScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
guestId: String,
|
||||
onBack: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
@@ -68,7 +69,7 @@ fun GuestSignatureScreen(
|
||||
onClick = {
|
||||
val svg = buildSignatureSvg(strokes, canvasSize.value)
|
||||
if (!svg.isNullOrBlank()) {
|
||||
viewModel.uploadSignature(propertyId, guestId, svg, onDone)
|
||||
viewModel.uploadSignature(propertyId, bookingId, guestId, svg, onDone)
|
||||
}
|
||||
},
|
||||
enabled = strokes.isNotEmpty() && !state.isLoading
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -10,15 +13,26 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class GuestSignatureViewModel : ViewModel() {
|
||||
class GuestSignatureViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(GuestSignatureState())
|
||||
val state: StateFlow<GuestSignatureState> = _state
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
|
||||
fun reset() {
|
||||
_state.value = GuestSignatureState()
|
||||
}
|
||||
|
||||
fun uploadSignature(propertyId: String, guestId: String, svg: String, onDone: () -> Unit) {
|
||||
fun uploadSignature(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
guestId: String,
|
||||
svg: String,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
||||
launchRequest(
|
||||
state = _state,
|
||||
@@ -35,6 +49,12 @@ class GuestSignatureViewModel : ViewModel() {
|
||||
)
|
||||
val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part)
|
||||
if (response.isSuccessful) {
|
||||
if (bookingId.isNotBlank()) {
|
||||
bookingRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(isLoading = false, error = null) }
|
||||
onDone()
|
||||
} else {
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package com.android.trisolarispms.ui.guestdocs
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiConstants
|
||||
import com.android.trisolarispms.data.api.model.GuestDocumentDto
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
@@ -24,53 +29,47 @@ import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Locale
|
||||
|
||||
class GuestDocumentsViewModel : ViewModel() {
|
||||
class GuestDocumentsViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(GuestDocumentsState())
|
||||
val state: StateFlow<GuestDocumentsState> = _state
|
||||
private val gson = Gson()
|
||||
private val repository = GuestDocumentRepository(
|
||||
dao = LocalDatabaseProvider.get(application).guestDocumentCacheDao()
|
||||
)
|
||||
private var eventSource: EventSource? = null
|
||||
private var streamKey: String? = null
|
||||
private var observeKey: String? = null
|
||||
private var observeJob: Job? = null
|
||||
|
||||
fun load(propertyId: String, guestId: String) {
|
||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
||||
observeCache(propertyId = propertyId, guestId = guestId)
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.listGuestDocuments(propertyId, guestId)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
documents = body,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Load failed: ${response.code()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = e.localizedMessage ?: "Load failed"
|
||||
)
|
||||
}
|
||||
val result = repository.refresh(propertyId = propertyId, guestId = guestId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = result.exceptionOrNull()?.localizedMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startStream(propertyId: String, guestId: String) {
|
||||
if (propertyId.isBlank() || guestId.isBlank()) return
|
||||
observeCache(propertyId = propertyId, guestId = guestId)
|
||||
val key = "$propertyId:$guestId"
|
||||
if (streamKey == key && eventSource != null) return
|
||||
stopStream()
|
||||
streamKey = key
|
||||
_state.update { it.copy(isLoading = true, error = null, documents = emptyList()) }
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
repository.refresh(propertyId = propertyId, guestId = guestId)
|
||||
_state.update { it.copy(isLoading = false) }
|
||||
}
|
||||
val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
|
||||
val url = "${ApiConstants.BASE_URL}properties/$propertyId/guests/$guestId/documents/stream"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
@@ -91,19 +90,16 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
data: String
|
||||
) {
|
||||
if (data.isBlank() || type == "ping") return
|
||||
val docs = try {
|
||||
val docs = runCatching {
|
||||
gson.fromJson(data, Array<GuestDocumentDto>::class.java)?.toList()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (docs != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
documents = docs,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}.getOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
repository.storeSnapshot(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
documents = docs
|
||||
)
|
||||
_state.update { current -> current.copy(isLoading = false, error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +122,6 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
)
|
||||
_state.update { it.copy(isLoading = false) }
|
||||
}
|
||||
|
||||
fun stopStream() {
|
||||
@@ -166,49 +161,43 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
}
|
||||
val filename = resolveFileName(resolver, uri) ?: "document"
|
||||
val file = copyToCache(resolver, uri, context.cacheDir, filename)
|
||||
uploadFile(propertyId, guestId, bookingId, file, mime)
|
||||
val uploadError = uploadFile(propertyId, guestId, bookingId, file, mime)
|
||||
if (uploadError != null) {
|
||||
errorMessage = uploadError
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.localizedMessage ?: "Upload failed"
|
||||
}
|
||||
}
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
_state.update {
|
||||
it.copy(
|
||||
isUploading = false,
|
||||
error = errorMessage ?: current.error
|
||||
error = errorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteDocument(propertyId: String, guestId: String, documentId: String) {
|
||||
if (propertyId.isBlank() || guestId.isBlank() || documentId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.deleteGuestDocument(propertyId, guestId, documentId)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
isLoading = false,
|
||||
error = null,
|
||||
documents = current.documents.filterNot { it.id == documentId }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Delete failed: ${response.code()}"
|
||||
error = refreshResult.exceptionOrNull()?.localizedMessage
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = e.localizedMessage ?: "Delete failed"
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,15 +211,12 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isUploading = true, error = null) }
|
||||
try {
|
||||
uploadFile(propertyId, guestId, bookingId, file, mimeType)
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isUploading = false,
|
||||
error = e.localizedMessage ?: "Upload failed"
|
||||
)
|
||||
}
|
||||
val uploadError = uploadFile(propertyId, guestId, bookingId, file, mimeType)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isUploading = false,
|
||||
error = uploadError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +227,7 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
bookingId: String,
|
||||
file: File,
|
||||
mimeType: String
|
||||
) {
|
||||
): String? {
|
||||
val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
val api = ApiClient.create()
|
||||
@@ -251,22 +237,29 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
bookingId = bookingId,
|
||||
file = part
|
||||
)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
_state.update { it.copy(isUploading = false, error = null) }
|
||||
} else if (response.code() == 409) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isUploading = false,
|
||||
error = "Duplicate document"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isUploading = false,
|
||||
error = "Upload failed: ${response.code()}"
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId)
|
||||
return refreshResult.exceptionOrNull()?.localizedMessage
|
||||
}
|
||||
if (response.code() == 409) {
|
||||
return "Duplicate document"
|
||||
}
|
||||
return "Upload failed: ${response.code()}"
|
||||
}
|
||||
|
||||
private fun observeCache(propertyId: String, guestId: String) {
|
||||
val key = "$propertyId:$guestId"
|
||||
if (observeKey == key && observeJob?.isActive == true) return
|
||||
observeJob?.cancel()
|
||||
observeKey = key
|
||||
observeJob = viewModelScope.launch {
|
||||
repository.observeByGuest(propertyId = propertyId, guestId = guestId).collect { docs ->
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
documents = docs,
|
||||
isLoading = if (docs.isNotEmpty()) false else current.isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,6 +289,7 @@ class GuestDocumentsViewModel : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
observeJob?.cancel()
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -31,17 +36,17 @@ sealed interface AppRoute {
|
||||
val fromAt: String,
|
||||
val toAt: String?
|
||||
) : AppRoute
|
||||
data class BookingRoomRequestFromBooking(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val guestId: String,
|
||||
val fromAt: String,
|
||||
val toAt: String
|
||||
) : AppRoute
|
||||
data class BookingRoomStays(
|
||||
val propertyId: String,
|
||||
val bookingId: String
|
||||
) : AppRoute
|
||||
data class BookingExpectedDates(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val status: String?,
|
||||
val expectedCheckInAt: String?,
|
||||
val expectedCheckOutAt: String?
|
||||
) : AppRoute
|
||||
data class BookingDetailsTabs(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
|
||||
@@ -50,7 +50,13 @@ internal fun handleBackNavigation(
|
||||
null
|
||||
)
|
||||
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,
|
||||
@@ -72,7 +78,6 @@ internal fun handleBackNavigation(
|
||||
currentRoute.toAt
|
||||
)
|
||||
is AppRoute.BookingRoomStays -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.BookingExpectedDates -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.BookingDetailsTabs -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.BookingPayments -> refs.route.value = AppRoute.BookingDetailsTabs(
|
||||
currentRoute.propertyId,
|
||||
|
||||
@@ -2,8 +2,8 @@ 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.booking.BookingExpectedDatesScreen
|
||||
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
|
||||
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
|
||||
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
|
||||
@@ -17,31 +17,20 @@ internal fun renderBookingRoutes(
|
||||
is AppRoute.BookingRoomStays -> BookingRoomStaysScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId),
|
||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }
|
||||
)
|
||||
|
||||
is AppRoute.BookingExpectedDates -> BookingExpectedDatesScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
status = currentRoute.status,
|
||||
expectedCheckInAt = currentRoute.expectedCheckInAt,
|
||||
expectedCheckOutAt = currentRoute.expectedCheckOutAt,
|
||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
||||
onDone = { refs.openActiveRoomStays(currentRoute.propertyId) }
|
||||
)
|
||||
|
||||
is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId,
|
||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
||||
onEditCheckout = { expectedCheckInAt, expectedCheckOutAt ->
|
||||
refs.route.value = AppRoute.BookingExpectedDates(
|
||||
onEditGuestInfo = { targetGuestId ->
|
||||
refs.route.value = AppRoute.GuestInfoFromBookingDetails(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
status = "CHECKED_IN",
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt
|
||||
guestId = targetGuestId
|
||||
)
|
||||
},
|
||||
onEditSignature = { guestId ->
|
||||
@@ -65,7 +54,31 @@ internal fun renderBookingRoutes(
|
||||
bookingId = currentRoute.bookingId
|
||||
)
|
||||
},
|
||||
canManageDocuments = authz.canManageGuestDocuments(currentRoute.propertyId)
|
||||
canManageDocuments = authz.canManageGuestDocuments(currentRoute.propertyId),
|
||||
canCheckOutRoomStay = authz.canCheckOutRoomStay(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(
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.android.trisolarispms.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.android.trisolarispms.core.auth.Role
|
||||
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
|
||||
import com.android.trisolarispms.ui.navigation.AppRoute
|
||||
import com.android.trisolarispms.ui.auth.AuthUiState
|
||||
import com.android.trisolarispms.ui.auth.AuthViewModel
|
||||
import com.android.trisolarispms.ui.booking.BookingCreateScreen
|
||||
import com.android.trisolarispms.ui.booking.BookingRoomRequestScreen
|
||||
import com.android.trisolarispms.ui.guest.GuestInfoScreen
|
||||
import com.android.trisolarispms.ui.guest.GuestSignatureScreen
|
||||
import com.android.trisolarispms.ui.home.HomeScreen
|
||||
@@ -70,21 +72,55 @@ internal fun renderHomeGuestRoutes(
|
||||
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
|
||||
?: response.expectedCheckInAt.orEmpty()
|
||||
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
|
||||
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
if (isFutureBookingCheckIn(response.expectedCheckInAt)) {
|
||||
if (fromAt.isNotBlank() && !toAt.isNullOrBlank()) {
|
||||
refs.route.value = AppRoute.BookingRoomRequestFromBooking(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
} else {
|
||||
refs.route.value = AppRoute.GuestInfo(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
}
|
||||
} else {
|
||||
refs.route.value = AppRoute.Home
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
is AppRoute.BookingRoomRequestFromBooking -> BookingRoomRequestScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
fromAt = currentRoute.fromAt,
|
||||
toAt = currentRoute.toAt,
|
||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
||||
onDone = {
|
||||
refs.route.value = AppRoute.GuestInfo(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
is AppRoute.GuestInfo -> GuestInfoScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId,
|
||||
initialGuest = refs.selectedGuest.value,
|
||||
initialPhone = refs.selectedGuestPhone.value,
|
||||
@@ -100,6 +136,7 @@ internal fun renderHomeGuestRoutes(
|
||||
|
||||
is AppRoute.GuestSignature -> GuestSignatureScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId,
|
||||
onBack = {
|
||||
refs.route.value = AppRoute.GuestInfo(
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
package com.android.trisolarispms.ui.payment
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRefundRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.payment.PaymentRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Response
|
||||
|
||||
class BookingPaymentsViewModel : ViewModel() {
|
||||
class BookingPaymentsViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(BookingPaymentsState())
|
||||
val state: StateFlow<BookingPaymentsState> = _state
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
private val paymentRepository = PaymentRepository(
|
||||
dao = LocalDatabaseProvider.get(application).paymentCacheDao()
|
||||
)
|
||||
private var observeKey: String? = null
|
||||
private var observePaymentsJob: Job? = null
|
||||
private var observeBookingJob: Job? = null
|
||||
|
||||
fun load(propertyId: String, bookingId: String) {
|
||||
runPaymentAction(defaultError = "Load failed") { api ->
|
||||
val paymentsResponse = api.listPayments(propertyId, bookingId)
|
||||
val payments = paymentsResponse.body()
|
||||
if (paymentsResponse.isSuccessful && payments != null) {
|
||||
val pending = api.getBookingBalance(propertyId, bookingId)
|
||||
.body()
|
||||
?.pending
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
payments = payments,
|
||||
pendingBalance = pending,
|
||||
error = null,
|
||||
message = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setActionFailure("Load", paymentsResponse)
|
||||
}
|
||||
if (propertyId.isBlank() || bookingId.isBlank()) return
|
||||
observeCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
runPaymentAction(defaultError = "Load failed") {
|
||||
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update { it.copy(isLoading = false, error = null, message = null) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +61,10 @@ class BookingPaymentsViewModel : ViewModel() {
|
||||
)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
_state.update { current ->
|
||||
val nextPending = latestPending?.let { (it - amount).coerceAtLeast(0L) }
|
||||
current.copy(
|
||||
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
payments = listOf(body) + current.payments,
|
||||
pendingBalance = nextPending ?: current.pendingBalance,
|
||||
error = null,
|
||||
message = "Cash payment added"
|
||||
)
|
||||
@@ -76,24 +77,16 @@ class BookingPaymentsViewModel : ViewModel() {
|
||||
|
||||
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
|
||||
runPaymentAction(defaultError = "Delete failed") { api ->
|
||||
val payment = _state.value.payments.firstOrNull { it.id == paymentId }
|
||||
val response = api.deletePayment(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
paymentId = paymentId
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { current ->
|
||||
val restoredPending = if (payment?.method == "CASH") {
|
||||
val amount = payment.amount ?: 0L
|
||||
current.pendingBalance?.plus(amount)
|
||||
} else {
|
||||
current.pendingBalance
|
||||
}
|
||||
current.copy(
|
||||
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
payments = current.payments.filterNot { it.id == paymentId },
|
||||
pendingBalance = restoredPending,
|
||||
error = null,
|
||||
message = "Cash payment deleted"
|
||||
)
|
||||
@@ -133,7 +126,7 @@ class BookingPaymentsViewModel : ViewModel() {
|
||||
)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
load(propertyId, bookingId)
|
||||
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
@@ -183,4 +176,46 @@ class BookingPaymentsViewModel : ViewModel() {
|
||||
if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)"
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
observePaymentsJob?.cancel()
|
||||
observeBookingJob?.cancel()
|
||||
}
|
||||
|
||||
private fun observeCaches(propertyId: String, bookingId: String) {
|
||||
val key = "$propertyId:$bookingId"
|
||||
if (observeKey == key &&
|
||||
observePaymentsJob?.isActive == true &&
|
||||
observeBookingJob?.isActive == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
observePaymentsJob?.cancel()
|
||||
observeBookingJob?.cancel()
|
||||
observeKey = key
|
||||
observePaymentsJob = viewModelScope.launch {
|
||||
paymentRepository.observeByBooking(propertyId = propertyId, bookingId = bookingId).collect { items ->
|
||||
_state.update { current -> current.copy(payments = items) }
|
||||
}
|
||||
}
|
||||
observeBookingJob = viewModelScope.launch {
|
||||
bookingRepository.observeBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
).collect { details ->
|
||||
_state.update { current ->
|
||||
current.copy(pendingBalance = details?.pending ?: current.pendingBalance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshPaymentCaches(propertyId: String, bookingId: String) {
|
||||
paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId).getOrThrow()
|
||||
bookingRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.android.trisolarispms.ui.razorpay
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiConstants
|
||||
@@ -9,37 +10,60 @@ import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||
import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.payment.PaymentRepository
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayCacheRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Request
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class RazorpayQrViewModel : ViewModel() {
|
||||
class RazorpayQrViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val gson = Gson()
|
||||
private val _state = MutableStateFlow(
|
||||
RazorpayQrState(deviceInfo = buildDeviceInfo())
|
||||
)
|
||||
val state: StateFlow<RazorpayQrState> = _state
|
||||
private val razorpayRepository = RazorpayCacheRepository(
|
||||
dao = LocalDatabaseProvider.get(application).razorpayCacheDao()
|
||||
)
|
||||
private val paymentRepository = PaymentRepository(
|
||||
dao = LocalDatabaseProvider.get(application).paymentCacheDao()
|
||||
)
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
private var qrEventSource: EventSource? = null
|
||||
private var lastQrId: String? = null
|
||||
private var qrPollJob: Job? = null
|
||||
private var requestObserveKey: String? = null
|
||||
private var requestObserveJob: Job? = null
|
||||
private var eventObserveKey: String? = null
|
||||
private var eventObserveJob: Job? = null
|
||||
|
||||
fun reset() {
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
stopRequestObservation()
|
||||
stopQrEventObservation()
|
||||
_state.value = RazorpayQrState(deviceInfo = buildDeviceInfo())
|
||||
}
|
||||
|
||||
fun exitQrView() {
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
stopQrEventObservation()
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrId = null,
|
||||
@@ -96,10 +120,11 @@ class RazorpayQrViewModel : ViewModel() {
|
||||
currency = body.currency,
|
||||
imageUrl = body.imageUrl,
|
||||
isClosed = false,
|
||||
isCredited = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
loadQrList(propertyId, bookingId)
|
||||
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
|
||||
startQrEventStream(propertyId, bookingId, body.qrId)
|
||||
} else {
|
||||
_state.update {
|
||||
@@ -120,127 +145,11 @@ class RazorpayQrViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) {
|
||||
if (qrId.isNullOrBlank()) return
|
||||
if (lastQrId == qrId && qrEventSource != null) return
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
lastQrId = qrId
|
||||
val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
|
||||
val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
qrEventSource = EventSources.createFactory(client).newEventSource(
|
||||
request,
|
||||
object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
if (data.isBlank() || type == "ping") return
|
||||
val event = runCatching {
|
||||
gson.fromJson(data, RazorpayQrEventDto::class.java)
|
||||
}.getOrNull() ?: return
|
||||
val status = event.status?.lowercase()
|
||||
val eventName = event.event?.lowercase()
|
||||
if (eventName == "qr_code.credited" || status == "credited") {
|
||||
_state.update { it.copy(isCredited = true, imageUrl = null) }
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
return
|
||||
}
|
||||
if (isClosedStatus(status)) {
|
||||
_state.update { it.copy(isClosed = true, imageUrl = null) }
|
||||
stopQrEventStream()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: okhttp3.Response?
|
||||
) {
|
||||
stopQrEventStream()
|
||||
startQrEventPolling(propertyId, bookingId, qrId)
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
stopQrEventStream()
|
||||
startQrEventPolling(propertyId, bookingId, qrId)
|
||||
}
|
||||
}
|
||||
)
|
||||
// Keep polling as a fallback in case SSE is buffered or never delivers events.
|
||||
startQrEventPolling(propertyId, bookingId, qrId)
|
||||
}
|
||||
|
||||
private fun stopQrEventStream() {
|
||||
qrEventSource?.cancel()
|
||||
qrEventSource = null
|
||||
lastQrId = null
|
||||
}
|
||||
|
||||
private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) {
|
||||
if (qrPollJob?.isActive == true) return
|
||||
qrPollJob = viewModelScope.launch {
|
||||
var delayMs = 4000L
|
||||
while (true) {
|
||||
val currentQrId = state.value.qrId
|
||||
if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) {
|
||||
break
|
||||
}
|
||||
try {
|
||||
val response = ApiClient.create().listRazorpayQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
if (body.any { it.event?.lowercase() == "qr_code.credited" || it.status?.lowercase() == "credited" }) {
|
||||
_state.update { it.copy(isCredited = true, imageUrl = null) }
|
||||
stopQrEventStream()
|
||||
break
|
||||
}
|
||||
if (body.any { isClosedStatus(it.status) }) {
|
||||
_state.update { it.copy(isClosed = true, imageUrl = null) }
|
||||
stopQrEventStream()
|
||||
break
|
||||
}
|
||||
}
|
||||
delayMs = 4000L
|
||||
} catch (e: Exception) {
|
||||
delayMs = (delayMs * 1.5).toLong().coerceAtMost(15000L)
|
||||
}
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopQrEventPolling() {
|
||||
qrPollJob?.cancel()
|
||||
qrPollJob = null
|
||||
}
|
||||
|
||||
private fun isClosedStatus(status: String?): Boolean {
|
||||
return when (status?.lowercase()) {
|
||||
"credited", "closed", "expired" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun loadQrList(propertyId: String, bookingId: String) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank()) return
|
||||
observeRequestCache(propertyId = propertyId, bookingId = bookingId)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.create().listRazorpayRequests(propertyId, bookingId)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
_state.update { it.copy(qrList = body) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore list load errors
|
||||
}
|
||||
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,10 +165,7 @@ class RazorpayQrViewModel : ViewModel() {
|
||||
body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { current ->
|
||||
current.copy(qrList = current.qrList.filterNot { it.requestId == item.requestId })
|
||||
}
|
||||
loadQrList(propertyId, bookingId)
|
||||
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore close errors
|
||||
@@ -313,7 +219,7 @@ class RazorpayQrViewModel : ViewModel() {
|
||||
error = null
|
||||
)
|
||||
}
|
||||
loadQrList(propertyId, bookingId)
|
||||
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
@@ -333,6 +239,200 @@ class RazorpayQrViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) {
|
||||
if (qrId.isNullOrBlank()) return
|
||||
observeQrEventCache(propertyId = propertyId, bookingId = bookingId, qrId = qrId)
|
||||
if (lastQrId == qrId && qrEventSource != null) return
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
lastQrId = qrId
|
||||
val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
|
||||
val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
qrEventSource = EventSources.createFactory(client).newEventSource(
|
||||
request,
|
||||
object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
if (data.isBlank() || type == "ping") return
|
||||
val event = runCatching { gson.fromJson(data, RazorpayQrEventDto::class.java) }
|
||||
.getOrNull() ?: return
|
||||
val eventQrId = event.qrId?.takeIf { it.isNotBlank() } ?: qrId
|
||||
viewModelScope.launch {
|
||||
refreshQrEventCache(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = eventQrId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: okhttp3.Response?
|
||||
) {
|
||||
stopQrEventStream()
|
||||
startQrEventPolling(propertyId, bookingId, qrId)
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
stopQrEventStream()
|
||||
startQrEventPolling(propertyId, bookingId, qrId)
|
||||
}
|
||||
}
|
||||
)
|
||||
// Keep polling as a fallback in case SSE is buffered or never delivers events.
|
||||
startQrEventPolling(propertyId, bookingId, qrId)
|
||||
}
|
||||
|
||||
private fun stopQrEventStream() {
|
||||
qrEventSource?.cancel()
|
||||
qrEventSource = null
|
||||
lastQrId = null
|
||||
}
|
||||
|
||||
private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) {
|
||||
if (qrPollJob?.isActive == true) return
|
||||
qrPollJob = viewModelScope.launch {
|
||||
var delayMs = 4000L
|
||||
while (true) {
|
||||
val currentQrId = state.value.qrId
|
||||
if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) {
|
||||
break
|
||||
}
|
||||
try {
|
||||
refreshQrEventCache(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
)
|
||||
delayMs = 4000L
|
||||
} catch (_: Exception) {
|
||||
delayMs = (delayMs * 1.5).toLong().coerceAtMost(15000L)
|
||||
}
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopQrEventPolling() {
|
||||
qrPollJob?.cancel()
|
||||
qrPollJob = null
|
||||
}
|
||||
|
||||
private fun observeRequestCache(propertyId: String, bookingId: String) {
|
||||
val key = "$propertyId:$bookingId"
|
||||
if (requestObserveKey == key && requestObserveJob?.isActive == true) return
|
||||
stopRequestObservation()
|
||||
requestObserveKey = key
|
||||
requestObserveJob = viewModelScope.launch {
|
||||
razorpayRepository.observeRequests(propertyId = propertyId, bookingId = bookingId).collect { items ->
|
||||
_state.update { current -> current.copy(qrList = items) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeQrEventCache(propertyId: String, bookingId: String, qrId: String) {
|
||||
val key = "$propertyId:$bookingId:$qrId"
|
||||
if (eventObserveKey == key && eventObserveJob?.isActive == true) return
|
||||
stopQrEventObservation()
|
||||
eventObserveKey = key
|
||||
eventObserveJob = viewModelScope.launch {
|
||||
razorpayRepository.observeQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
).collect { events ->
|
||||
applyQrEventState(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
events = events
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRequestObservation() {
|
||||
requestObserveJob?.cancel()
|
||||
requestObserveJob = null
|
||||
requestObserveKey = null
|
||||
}
|
||||
|
||||
private fun stopQrEventObservation() {
|
||||
eventObserveJob?.cancel()
|
||||
eventObserveJob = null
|
||||
eventObserveKey = null
|
||||
}
|
||||
|
||||
private suspend fun refreshRequestCache(propertyId: String, bookingId: String) {
|
||||
val result = razorpayRepository.refreshRequests(propertyId = propertyId, bookingId = bookingId)
|
||||
result.exceptionOrNull()?.let { throwable ->
|
||||
_state.update { it.copy(error = throwable.localizedMessage ?: "Load failed") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshQrEventCache(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Result<List<RazorpayQrEventDto>> =
|
||||
razorpayRepository.refreshQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
)
|
||||
|
||||
private fun applyQrEventState(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
events: List<RazorpayQrEventDto>
|
||||
) {
|
||||
if (events.isEmpty()) return
|
||||
val isCredited = events.any {
|
||||
it.event?.lowercase() == "qr_code.credited" || it.status?.lowercase() == "credited"
|
||||
}
|
||||
val isClosed = events.any { event ->
|
||||
when (event.status?.lowercase()) {
|
||||
"credited", "closed", "expired" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
var becameCredited = false
|
||||
_state.update { current ->
|
||||
val nextCredited = current.isCredited || isCredited
|
||||
val nextClosed = current.isClosed || isClosed || nextCredited
|
||||
becameCredited = !current.isCredited && nextCredited
|
||||
if (nextCredited == current.isCredited && nextClosed == current.isClosed) {
|
||||
current
|
||||
} else {
|
||||
current.copy(
|
||||
isCredited = nextCredited,
|
||||
isClosed = nextClosed,
|
||||
imageUrl = if (nextClosed) null else current.imageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
if (becameCredited) {
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
viewModelScope.launch {
|
||||
syncPaymentCachesAfterCredit(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
} else if (isClosed) {
|
||||
stopQrEventStream()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncPaymentCachesAfterCredit(propertyId: String, bookingId: String) {
|
||||
paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId)
|
||||
bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
|
||||
private fun buildDeviceInfo(): String {
|
||||
val release = android.os.Build.VERSION.RELEASE
|
||||
val model = android.os.Build.MODEL
|
||||
@@ -342,5 +442,8 @@ class RazorpayQrViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopQrEventStream()
|
||||
stopQrEventPolling()
|
||||
stopRequestObservation()
|
||||
stopQrEventObservation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -12,6 +13,8 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.MeetingRoom
|
||||
import androidx.compose.material.icons.filled.Payment
|
||||
@@ -21,6 +24,8 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -63,6 +68,11 @@ fun ActiveRoomStaysScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
|
||||
val menuExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = state.showOpenBookings) {
|
||||
viewModel.hideOpenBookings()
|
||||
}
|
||||
|
||||
LaunchedEffect(propertyId) {
|
||||
viewModel.load(propertyId)
|
||||
@@ -70,23 +80,63 @@ fun ActiveRoomStaysScreen(
|
||||
|
||||
BackTopBarScaffold(
|
||||
title = propertyName,
|
||||
onBack = onBack,
|
||||
onBack = {
|
||||
if (state.showOpenBookings) {
|
||||
viewModel.hideOpenBookings()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
showBack = showBack,
|
||||
actions = {
|
||||
IconButton(onClick = onViewRooms) {
|
||||
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
IconButton(onClick = viewModel::toggleShowOpenBookings) {
|
||||
Icon(
|
||||
Icons.Default.CalendarMonth,
|
||||
contentDescription = "Show Open Bookings",
|
||||
tint = if (state.showOpenBookings) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showRazorpaySettings) {
|
||||
IconButton(onClick = onRazorpaySettings) {
|
||||
Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings")
|
||||
IconButton(onClick = { menuExpanded.value = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded.value,
|
||||
onDismissRequest = { menuExpanded.value = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Settings") },
|
||||
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
onClick = {
|
||||
menuExpanded.value = false
|
||||
onOpenSettings()
|
||||
}
|
||||
)
|
||||
if (showRazorpaySettings) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Razorpay Settings") },
|
||||
leadingIcon = { Icon(Icons.Default.Payment, contentDescription = null) },
|
||||
onClick = {
|
||||
menuExpanded.value = false
|
||||
onRazorpaySettings()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showUserAdmin) {
|
||||
IconButton(onClick = onUserAdmin) {
|
||||
Icon(Icons.Default.People, contentDescription = "Property Users")
|
||||
if (showUserAdmin) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Property Users") },
|
||||
leadingIcon = { Icon(Icons.Default.People, contentDescription = null) },
|
||||
onClick = {
|
||||
menuExpanded.value = false
|
||||
onUserAdmin()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -106,14 +156,24 @@ fun ActiveRoomStaysScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (!state.isLoading && state.error == null) {
|
||||
if (state.checkedInBookings.isNotEmpty()) {
|
||||
Text(text = "Checked-in bookings", style = MaterialTheme.typography.titleMedium)
|
||||
val shownBookings = if (state.showOpenBookings) {
|
||||
state.openBookings
|
||||
} else {
|
||||
state.checkedInBookings
|
||||
}
|
||||
if (shownBookings.isNotEmpty()) {
|
||||
val sectionTitle = if (state.showOpenBookings) {
|
||||
"Open bookings"
|
||||
} else {
|
||||
"Checked-in bookings"
|
||||
}
|
||||
Text(text = sectionTitle, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
@@ -121,7 +181,7 @@ fun ActiveRoomStaysScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(state.checkedInBookings) { booking ->
|
||||
items(shownBookings) { booking ->
|
||||
CheckedInBookingCard(
|
||||
booking = booking,
|
||||
onClick = { onOpenBookingDetails(booking) })
|
||||
@@ -129,7 +189,12 @@ fun ActiveRoomStaysScreen(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
Text(text = "No checked-in bookings")
|
||||
val emptyLabel = if (state.showOpenBookings) {
|
||||
"No open bookings"
|
||||
} else {
|
||||
"No checked-in bookings"
|
||||
}
|
||||
Text(text = emptyLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,10 +253,6 @@ private fun CheckedInBookingCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
val source = booking.source?.takeIf { it.isNotBlank() }
|
||||
if (source != null) {
|
||||
Text(text = source, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
val expectedCount = booking.expectedGuestCount
|
||||
val totalCount = booking.totalGuestCount
|
||||
val countLine = when {
|
||||
@@ -216,6 +277,14 @@ private fun CheckedInBookingCard(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
val vehicleNumbers = booking.vehicleNumbers.filter { it.isNotBlank() }
|
||||
if (vehicleNumbers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = vehicleNumbers.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
val checkInAt = booking.checkInAt?.takeIf { it.isNotBlank() }
|
||||
val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() }
|
||||
if (checkInAt != null && checkOutAt != null) {
|
||||
|
||||
@@ -7,5 +7,7 @@ data class ActiveRoomStaysState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val items: List<ActiveRoomStayDto> = emptyList(),
|
||||
val checkedInBookings: List<BookingListItem> = emptyList()
|
||||
val checkedInBookings: List<BookingListItem> = emptyList(),
|
||||
val openBookings: List<BookingListItem> = emptyList(),
|
||||
val showOpenBookings: Boolean = false
|
||||
)
|
||||
|
||||
@@ -1,38 +1,117 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ActiveRoomStaysViewModel : ViewModel() {
|
||||
class ActiveRoomStaysViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(ActiveRoomStaysState())
|
||||
val state: StateFlow<ActiveRoomStaysState> = _state
|
||||
private val repository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private val bookingListRepository = BookingListRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
|
||||
)
|
||||
private var observeJob: Job? = null
|
||||
private var observePropertyId: String? = null
|
||||
private var observeBookingListPropertyId: String? = null
|
||||
private var observeCheckedInJob: Job? = null
|
||||
private var observeOpenJob: Job? = null
|
||||
|
||||
fun toggleShowOpenBookings() {
|
||||
_state.update { it.copy(showOpenBookings = !it.showOpenBookings) }
|
||||
}
|
||||
|
||||
fun hideOpenBookings() {
|
||||
_state.update { it.copy(showOpenBookings = false) }
|
||||
}
|
||||
|
||||
fun load(propertyId: String) {
|
||||
if (propertyId.isBlank()) return
|
||||
launchRequest(
|
||||
state = _state,
|
||||
setLoading = { it.copy(isLoading = true, error = null) },
|
||||
setError = { current, message -> current.copy(isLoading = false, error = message) },
|
||||
defaultError = "Load failed"
|
||||
) {
|
||||
val api = ApiClient.create()
|
||||
val activeResponse = api.listActiveRoomStays(propertyId)
|
||||
val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN")
|
||||
if (activeResponse.isSuccessful) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
items = activeResponse.body().orEmpty(),
|
||||
checkedInBookings = bookingsResponse.body().orEmpty(),
|
||||
error = null
|
||||
observeCache(propertyId = propertyId)
|
||||
observeBookingLists(propertyId = propertyId)
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val activeResult = repository.refresh(propertyId = propertyId)
|
||||
val checkedInResult = bookingListRepository.refreshByStatus(
|
||||
propertyId = propertyId,
|
||||
status = "CHECKED_IN"
|
||||
)
|
||||
val openResult = bookingListRepository.refreshByStatus(
|
||||
propertyId = propertyId,
|
||||
status = "OPEN"
|
||||
)
|
||||
val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
|
||||
?: checkedInResult.exceptionOrNull()?.localizedMessage
|
||||
?: openResult.exceptionOrNull()?.localizedMessage
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = errorMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
observeJob?.cancel()
|
||||
observeCheckedInJob?.cancel()
|
||||
observeOpenJob?.cancel()
|
||||
}
|
||||
|
||||
private fun observeCache(propertyId: String) {
|
||||
if (observePropertyId == propertyId && observeJob?.isActive == true) return
|
||||
observeJob?.cancel()
|
||||
observePropertyId = propertyId
|
||||
observeJob = viewModelScope.launch {
|
||||
repository.observeByProperty(propertyId = propertyId).collect { items ->
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
items = items,
|
||||
isLoading = if (items.isNotEmpty()) false else current.isLoading
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${activeResponse.code()}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeBookingLists(propertyId: String) {
|
||||
if (observeBookingListPropertyId == propertyId &&
|
||||
observeCheckedInJob?.isActive == true &&
|
||||
observeOpenJob?.isActive == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
observeCheckedInJob?.cancel()
|
||||
observeOpenJob?.cancel()
|
||||
observeBookingListPropertyId = propertyId
|
||||
observeCheckedInJob = viewModelScope.launch {
|
||||
bookingListRepository.observeByStatus(
|
||||
propertyId = propertyId,
|
||||
status = "CHECKED_IN"
|
||||
).collect { bookings ->
|
||||
_state.update { current -> current.copy(checkedInBookings = bookings) }
|
||||
}
|
||||
}
|
||||
observeOpenJob = viewModelScope.launch {
|
||||
bookingListRepository.observeByStatus(
|
||||
propertyId = propertyId,
|
||||
status = "OPEN"
|
||||
).collect { bookings ->
|
||||
_state.update { current -> current.copy(openBookings = bookings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import retrofit2.Response
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
private const val CONFLICT_BOOKING_NOT_CHECKED_IN = "Booking not checked in"
|
||||
private const val CONFLICT_PRIMARY_GUEST_MISSING = "Primary guest missing"
|
||||
private const val CONFLICT_GUEST_NAME_MISSING = "Guest name missing"
|
||||
private const val CONFLICT_GUEST_PHONE_MISSING = "Guest phone missing"
|
||||
private const val CONFLICT_GUEST_SIGNATURE_MISSING = "Guest signature missing"
|
||||
private const val CONFLICT_INVALID_CHECKOUT_TIME = "checkOutAt must be after checkInAt"
|
||||
|
||||
internal fun deriveBookingCheckoutBlockedReason(
|
||||
details: BookingDetailsResponse?,
|
||||
now: OffsetDateTime = OffsetDateTime.now()
|
||||
): String? {
|
||||
details ?: return null
|
||||
if (!details.status.equals("CHECKED_IN", ignoreCase = true)) {
|
||||
return CONFLICT_BOOKING_NOT_CHECKED_IN
|
||||
}
|
||||
if (details.guestId.isNullOrBlank()) return CONFLICT_PRIMARY_GUEST_MISSING
|
||||
if (details.guestName.isNullOrBlank()) return CONFLICT_GUEST_NAME_MISSING
|
||||
if (details.guestPhone.isNullOrBlank()) return CONFLICT_GUEST_PHONE_MISSING
|
||||
if (details.guestSignatureUrl.isNullOrBlank()) return CONFLICT_GUEST_SIGNATURE_MISSING
|
||||
|
||||
val checkInAt = details.checkInAt?.takeIf { it.isNotBlank() }
|
||||
?: details.expectedCheckInAt?.takeIf { it.isNotBlank() }
|
||||
val checkIn = checkInAt?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() }
|
||||
if (checkIn != null && !now.isAfter(checkIn)) {
|
||||
return CONFLICT_INVALID_CHECKOUT_TIME
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun isBookingLevelCheckoutConflict(message: String): Boolean {
|
||||
val normalized = message.lowercase()
|
||||
return normalized.contains("booking not checked in") ||
|
||||
normalized.contains("primary guest missing") ||
|
||||
normalized.contains("primary guest required before checkout") ||
|
||||
normalized.contains("guest name missing") ||
|
||||
normalized.contains("guest name required before checkout") ||
|
||||
normalized.contains("guest phone missing") ||
|
||||
normalized.contains("guest phone required before checkout") ||
|
||||
normalized.contains("guest signature missing") ||
|
||||
normalized.contains("guest signature required before checkout") ||
|
||||
normalized.contains("checkoutat must be after checkinat") ||
|
||||
normalized.contains("room stay amount outside allowed range") ||
|
||||
normalized.contains("ledger mismatch")
|
||||
}
|
||||
|
||||
internal fun extractApiErrorMessage(response: Response<*>): String? {
|
||||
val raw = runCatching { response.errorBody()?.string() }.getOrNull()?.trim().orEmpty()
|
||||
if (raw.isBlank()) return null
|
||||
val match = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"").find(raw)
|
||||
return match?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotBlank() } ?: raw
|
||||
}
|
||||
@@ -18,12 +18,17 @@ import androidx.compose.foundation.pager.rememberPagerState
|
||||
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
|
||||
import androidx.compose.material.icons.filled.ReceiptLong
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -32,11 +37,13 @@ import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -53,6 +60,11 @@ import com.android.trisolarispms.data.api.core.ApiConstants
|
||||
import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
|
||||
import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard
|
||||
import com.android.trisolarispms.ui.booking.BookingTimePickerDialog
|
||||
import com.android.trisolarispms.ui.booking.formatBookingIso
|
||||
import com.android.trisolarispms.ui.booking.formatBookingDurationText
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
@@ -72,11 +84,13 @@ fun BookingDetailsTabsScreen(
|
||||
bookingId: String,
|
||||
guestId: String?,
|
||||
onBack: () -> Unit,
|
||||
onEditCheckout: (String?, String?) -> Unit,
|
||||
onEditGuestInfo: (String) -> Unit,
|
||||
onEditSignature: (String) -> Unit,
|
||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||
onOpenPayments: () -> Unit,
|
||||
canManageDocuments: Boolean,
|
||||
canCheckOutRoomStay: Boolean,
|
||||
canCheckOutBooking: Boolean,
|
||||
staysViewModel: BookingRoomStaysViewModel = viewModel(),
|
||||
detailsViewModel: BookingDetailsViewModel = viewModel()
|
||||
) {
|
||||
@@ -85,10 +99,22 @@ fun BookingDetailsTabsScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val staysState by staysViewModel.state.collectAsState()
|
||||
val detailsState by detailsViewModel.state.collectAsState()
|
||||
val actionsMenuExpanded = remember { mutableStateOf(false) }
|
||||
val showCancelConfirm = remember { mutableStateOf(false) }
|
||||
val cancelLoading = remember { mutableStateOf(false) }
|
||||
val cancelError = remember { mutableStateOf<String?>(null) }
|
||||
val showCheckoutConfirm = remember { mutableStateOf(false) }
|
||||
val checkoutLoading = remember { mutableStateOf(false) }
|
||||
val checkoutError = remember { mutableStateOf<String?>(null) }
|
||||
val canModifyDocuments = canManageDocuments && when (detailsState.details?.status) {
|
||||
"OPEN", "CHECKED_IN" -> true
|
||||
else -> false
|
||||
}
|
||||
val checkoutBlockedReason = deriveBookingCheckoutBlockedReason(detailsState.details)
|
||||
val canCancelBooking = detailsState.details?.status?.uppercase() == "OPEN"
|
||||
val canShowCheckoutIcon = canCheckOutBooking &&
|
||||
detailsState.details?.status?.equals("CHECKED_IN", ignoreCase = true) == true &&
|
||||
checkoutBlockedReason == null
|
||||
|
||||
LaunchedEffect(propertyId, bookingId, guestId) {
|
||||
staysViewModel.load(propertyId, bookingId)
|
||||
@@ -101,7 +127,48 @@ fun BookingDetailsTabsScreen(
|
||||
|
||||
BackTopBarScaffold(
|
||||
title = "Details",
|
||||
onBack = onBack
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
if (canShowCheckoutIcon) {
|
||||
IconButton(
|
||||
enabled = !checkoutLoading.value,
|
||||
onClick = {
|
||||
checkoutError.value = null
|
||||
showCheckoutConfirm.value = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Check out stay"
|
||||
)
|
||||
}
|
||||
}
|
||||
if (canCancelBooking) {
|
||||
IconButton(onClick = { actionsMenuExpanded.value = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "More options")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = actionsMenuExpanded.value,
|
||||
onDismissRequest = { actionsMenuExpanded.value = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cancel Booking") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
actionsMenuExpanded.value = false
|
||||
cancelError.value = null
|
||||
showCancelConfirm.value = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -144,12 +211,27 @@ fun BookingDetailsTabsScreen(
|
||||
guestId = guestId,
|
||||
isLoading = detailsState.isLoading,
|
||||
error = detailsState.error,
|
||||
onEditCheckout = onEditCheckout,
|
||||
onEditGuestInfo = onEditGuestInfo,
|
||||
onEditSignature = onEditSignature,
|
||||
onOpenRazorpayQr = onOpenRazorpayQr,
|
||||
onOpenPayments = onOpenPayments
|
||||
onOpenPayments = onOpenPayments,
|
||||
onUpdateExpectedDates = { status, updatedCheckInAt, updatedCheckOutAt ->
|
||||
detailsViewModel.updateExpectedDates(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
status = status,
|
||||
updatedCheckInAt = updatedCheckInAt,
|
||||
updatedCheckOutAt = updatedCheckOutAt
|
||||
)
|
||||
}
|
||||
)
|
||||
1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
|
||||
1 -> BookingRoomStaysTabContent(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
state = staysState,
|
||||
viewModel = staysViewModel,
|
||||
canCheckOutRoomStay = canCheckOutRoomStay
|
||||
)
|
||||
2 -> if (canManageDocuments) {
|
||||
val resolvedGuestId = detailsState.details?.guestId ?: guestId
|
||||
if (!resolvedGuestId.isNullOrBlank()) {
|
||||
@@ -173,6 +255,141 @@ fun BookingDetailsTabsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCheckoutConfirm.value && canShowCheckoutIcon) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
if (!checkoutLoading.value) {
|
||||
showCheckoutConfirm.value = false
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
title = { Text("Check out booking?") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("This will check out all active room stays for this booking.")
|
||||
checkoutError.value?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !checkoutLoading.value,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
checkoutLoading.value = true
|
||||
checkoutError.value = null
|
||||
try {
|
||||
val result = detailsViewModel.checkoutBooking(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
showCheckoutConfirm.value = false
|
||||
onBack()
|
||||
} else {
|
||||
val message = result.exceptionOrNull()?.localizedMessage
|
||||
checkoutError.value = message ?: "Checkout failed"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
checkoutError.value = e.localizedMessage ?: "Checkout failed"
|
||||
} finally {
|
||||
checkoutLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(if (checkoutLoading.value) "Checking out..." else "Check out")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
enabled = !checkoutLoading.value,
|
||||
onClick = { showCheckoutConfirm.value = false }
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCancelConfirm.value && canCancelBooking) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
if (!cancelLoading.value) {
|
||||
showCancelConfirm.value = false
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = { Text("Cancel booking?") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("This will cancel the OPEN booking.")
|
||||
cancelError.value?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !cancelLoading.value,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
cancelLoading.value = true
|
||||
cancelError.value = null
|
||||
try {
|
||||
val result = detailsViewModel.cancelBooking(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
showCancelConfirm.value = false
|
||||
onBack()
|
||||
} else {
|
||||
val message = result.exceptionOrNull()?.localizedMessage
|
||||
cancelError.value = message ?: "Cancel failed"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
cancelError.value = e.localizedMessage ?: "Cancel failed"
|
||||
} finally {
|
||||
cancelLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(if (cancelLoading.value) "Cancelling..." else "Cancel Booking")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
enabled = !cancelLoading.value,
|
||||
onClick = { showCancelConfirm.value = false }
|
||||
) {
|
||||
Text("Keep Booking")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -182,14 +399,58 @@ private fun GuestInfoTabContent(
|
||||
guestId: String?,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onEditCheckout: (String?, String?) -> Unit,
|
||||
onEditGuestInfo: (String) -> Unit,
|
||||
onEditSignature: (String) -> Unit,
|
||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||
onOpenPayments: () -> Unit
|
||||
onOpenPayments: () -> Unit,
|
||||
onUpdateExpectedDates: suspend (String?, OffsetDateTime?, OffsetDateTime?) -> Result<Unit>
|
||||
) {
|
||||
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
|
||||
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
|
||||
val pickerTimeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
|
||||
val scope = rememberCoroutineScope()
|
||||
val showCheckInDatePicker = remember { mutableStateOf(false) }
|
||||
val showCheckInTimePicker = remember { mutableStateOf(false) }
|
||||
val showCheckOutDatePicker = remember { mutableStateOf(false) }
|
||||
val showCheckOutTimePicker = remember { mutableStateOf(false) }
|
||||
val isUpdatingDates = remember { mutableStateOf(false) }
|
||||
val updateDatesError = remember { mutableStateOf<String?>(null) }
|
||||
val draftCheckInAt = remember { mutableStateOf<OffsetDateTime?>(null) }
|
||||
val draftCheckOutAt = remember { mutableStateOf<OffsetDateTime?>(null) }
|
||||
val today = remember(displayZone) { LocalDate.now(displayZone) }
|
||||
|
||||
val checkInFromDetails = details?.checkInAt ?: details?.expectedCheckInAt
|
||||
val checkOutFromDetails = details?.expectedCheckOutAt ?: details?.checkOutAt
|
||||
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
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() }
|
||||
draftCheckOutAt.value = checkOutFromDetails
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() }
|
||||
}
|
||||
|
||||
fun submitExpectedDatesUpdate(updatedCheckInAt: OffsetDateTime?, updatedCheckOutAt: OffsetDateTime?) {
|
||||
if (isUpdatingDates.value) return
|
||||
val currentStatus = details?.status ?: return
|
||||
val bookingStatus = currentStatus.uppercase()
|
||||
if (bookingStatus != "OPEN" && bookingStatus != "CHECKED_IN") return
|
||||
scope.launch {
|
||||
isUpdatingDates.value = true
|
||||
updateDatesError.value = null
|
||||
val result = onUpdateExpectedDates(currentStatus, updatedCheckInAt, updatedCheckOutAt)
|
||||
result.exceptionOrNull()?.let { throwable ->
|
||||
updateDatesError.value = throwable.localizedMessage ?: "Update failed"
|
||||
}
|
||||
isUpdatingDates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -205,7 +466,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) })
|
||||
@@ -221,37 +498,42 @@ private fun GuestInfoTabContent(
|
||||
GuestDetailRow(label = "Mode of transport", value = details?.transportMode)
|
||||
}
|
||||
|
||||
val checkIn = details?.checkInAt ?: details?.expectedCheckInAt
|
||||
val checkOut = details?.expectedCheckOutAt ?: details?.checkOutAt
|
||||
SectionCard(title = "Stay") {
|
||||
if (!checkIn.isNullOrBlank()) {
|
||||
val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull()
|
||||
GuestDetailRow(
|
||||
label = "Check In Time",
|
||||
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }
|
||||
val parsedCheckIn = draftCheckInAt.value
|
||||
val parsedCheckOut = draftCheckOutAt.value
|
||||
val zonedCheckIn = parsedCheckIn?.atZoneSameInstant(displayZone)
|
||||
val zonedCheckOut = parsedCheckOut?.atZoneSameInstant(displayZone)
|
||||
if (zonedCheckIn != null || zonedCheckOut != null) {
|
||||
BookingDateTimeQuickEditorCard(
|
||||
checkInDateText = zonedCheckIn?.format(dateFormatter) ?: "--/--/----",
|
||||
checkInTimeText = zonedCheckIn?.format(timeFormatter) ?: "--:--",
|
||||
checkOutDateText = zonedCheckOut?.format(dateFormatter) ?: "--/--/----",
|
||||
checkOutTimeText = zonedCheckOut?.format(timeFormatter) ?: "--:--",
|
||||
totalTimeText = formatBookingDurationText(parsedCheckIn, parsedCheckOut),
|
||||
checkInEditable = canEditCheckIn && !isUpdatingDates.value,
|
||||
checkOutEditable = canEditCheckOut && !isUpdatingDates.value,
|
||||
onCheckInDateClick = {
|
||||
if (canEditCheckIn && !isUpdatingDates.value) showCheckInDatePicker.value = true
|
||||
},
|
||||
onCheckInTimeClick = {
|
||||
if (canEditCheckIn && !isUpdatingDates.value) showCheckInTimePicker.value = true
|
||||
},
|
||||
onCheckOutDateClick = {
|
||||
if (canEditCheckOut && !isUpdatingDates.value) showCheckOutDatePicker.value = true
|
||||
},
|
||||
onCheckOutTimeClick = {
|
||||
if (canEditCheckOut && !isUpdatingDates.value) showCheckOutTimePicker.value = true
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!checkOut.isNullOrBlank()) {
|
||||
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
GuestDetailRow(
|
||||
label = "Estimated Check Out Time",
|
||||
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
onEditCheckout(details?.expectedCheckInAt, details?.expectedCheckOutAt)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit checkout")
|
||||
}
|
||||
updateDatesError.value?.let { message ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
val billingMode = BookingBillingMode.from(details?.billingMode)
|
||||
GuestDetailRow(label = "Billing Mode", value = details?.billingMode)
|
||||
@@ -317,7 +599,6 @@ private fun GuestInfoTabContent(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val resolvedGuestId = details?.guestId ?: guestId
|
||||
SignaturePreview(
|
||||
propertyId = propertyId,
|
||||
guestId = resolvedGuestId,
|
||||
@@ -325,6 +606,106 @@ private fun GuestInfoTabContent(
|
||||
onEditSignature = onEditSignature
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckInDatePicker.value && canEditCheckIn) {
|
||||
BookingDatePickerDialog(
|
||||
initialDate = draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate() ?: today,
|
||||
minDate = today,
|
||||
onDismiss = { showCheckInDatePicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
val selectedTime = draftCheckInAt.value
|
||||
?.atZoneSameInstant(displayZone)
|
||||
?.format(pickerTimeFormatter)
|
||||
?: "12:00"
|
||||
val updatedCheckIn = runCatching {
|
||||
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
|
||||
}.getOrNull() ?: return@BookingDatePickerDialog
|
||||
val currentCheckOut = draftCheckOutAt.value
|
||||
val adjustedCheckOut = if (currentCheckOut != null && !currentCheckOut.isAfter(updatedCheckIn)) {
|
||||
runCatching {
|
||||
OffsetDateTime.parse(formatBookingIso(selectedDate.plusDays(1), selectedTime))
|
||||
}.getOrNull()
|
||||
} else {
|
||||
currentCheckOut
|
||||
}
|
||||
submitExpectedDatesUpdate(updatedCheckIn, adjustedCheckOut)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckInTimePicker.value && canEditCheckIn) {
|
||||
BookingTimePickerDialog(
|
||||
initialTime = draftCheckInAt.value
|
||||
?.atZoneSameInstant(displayZone)
|
||||
?.format(pickerTimeFormatter)
|
||||
?: "12:00",
|
||||
onDismiss = { showCheckInTimePicker.value = false },
|
||||
onTimeSelected = { selectedTime ->
|
||||
val selectedDate = draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate() ?: today
|
||||
val updatedCheckIn = runCatching {
|
||||
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
|
||||
}.getOrNull() ?: return@BookingTimePickerDialog
|
||||
val currentCheckOut = draftCheckOutAt.value
|
||||
val adjustedCheckOut = if (currentCheckOut != null && !currentCheckOut.isAfter(updatedCheckIn)) {
|
||||
runCatching {
|
||||
OffsetDateTime.parse(formatBookingIso(selectedDate.plusDays(1), selectedTime))
|
||||
}.getOrNull()
|
||||
} else {
|
||||
currentCheckOut
|
||||
}
|
||||
submitExpectedDatesUpdate(updatedCheckIn, adjustedCheckOut)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckOutDatePicker.value && canEditCheckOut) {
|
||||
val checkInDate = draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate()
|
||||
val checkoutMinDate = maxOf(checkInDate ?: today, today)
|
||||
BookingDatePickerDialog(
|
||||
initialDate = draftCheckOutAt.value?.atZoneSameInstant(displayZone)?.toLocalDate() ?: checkoutMinDate,
|
||||
minDate = checkoutMinDate,
|
||||
onDismiss = { showCheckOutDatePicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
val selectedTime = draftCheckOutAt.value
|
||||
?.atZoneSameInstant(displayZone)
|
||||
?.format(pickerTimeFormatter)
|
||||
?: "11:00"
|
||||
val updatedCheckOut = runCatching {
|
||||
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
|
||||
}.getOrNull() ?: return@BookingDatePickerDialog
|
||||
val currentCheckIn = draftCheckInAt.value
|
||||
if (currentCheckIn != null && !updatedCheckOut.isAfter(currentCheckIn)) {
|
||||
updateDatesError.value = "Check-out must be after check-in"
|
||||
return@BookingDatePickerDialog
|
||||
}
|
||||
submitExpectedDatesUpdate(currentCheckIn, updatedCheckOut)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCheckOutTimePicker.value && canEditCheckOut) {
|
||||
BookingTimePickerDialog(
|
||||
initialTime = draftCheckOutAt.value
|
||||
?.atZoneSameInstant(displayZone)
|
||||
?.format(pickerTimeFormatter)
|
||||
?: "11:00",
|
||||
onDismiss = { showCheckOutTimePicker.value = false },
|
||||
onTimeSelected = { selectedTime ->
|
||||
val selectedDate = draftCheckOutAt.value?.atZoneSameInstant(displayZone)?.toLocalDate()
|
||||
?: draftCheckInAt.value?.atZoneSameInstant(displayZone)?.toLocalDate()
|
||||
?: today
|
||||
val updatedCheckOut = runCatching {
|
||||
OffsetDateTime.parse(formatBookingIso(selectedDate, selectedTime))
|
||||
}.getOrNull() ?: return@BookingTimePickerDialog
|
||||
val currentCheckIn = draftCheckInAt.value
|
||||
if (currentCheckIn != null && !updatedCheckOut.isAfter(currentCheckIn)) {
|
||||
updateDatesError.value = "Check-out must be after check-in"
|
||||
return@BookingTimePickerDialog
|
||||
}
|
||||
submitExpectedDatesUpdate(currentCheckIn, updatedCheckOut)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -457,8 +838,11 @@ private fun SignaturePreview(
|
||||
|
||||
@Composable
|
||||
private fun BookingRoomStaysTabContent(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
state: BookingRoomStaysState,
|
||||
viewModel: BookingRoomStaysViewModel
|
||||
viewModel: BookingRoomStaysViewModel,
|
||||
canCheckOutRoomStay: Boolean
|
||||
) {
|
||||
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
|
||||
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") }
|
||||
@@ -467,50 +851,31 @@ private fun BookingRoomStaysTabContent(
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Show all (including checkout)")
|
||||
androidx.compose.material3.Switch(
|
||||
checked = state.showAll,
|
||||
onCheckedChange = viewModel::toggleShowAll
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (!state.isLoading && state.error == null) {
|
||||
if (state.stays.isEmpty()) {
|
||||
Text(text = "No stays found")
|
||||
} else {
|
||||
state.stays.forEach { stay ->
|
||||
val roomLine = "Room ${stay.roomNumber ?: "-"} • ${stay.roomTypeName ?: ""}".trim()
|
||||
Text(text = roomLine, style = MaterialTheme.typography.titleMedium)
|
||||
val fromAt = stay.fromAt?.let {
|
||||
runCatching {
|
||||
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
|
||||
}.getOrNull()
|
||||
}
|
||||
val toAt = stay.expectedCheckoutAt?.let {
|
||||
runCatching {
|
||||
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
|
||||
}.getOrNull()
|
||||
}
|
||||
val timeLine = listOfNotNull(fromAt, toAt).joinToString(" → ")
|
||||
if (timeLine.isNotBlank()) {
|
||||
Text(text = timeLine, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
RoomStayListSection(
|
||||
state = state,
|
||||
canCheckOutRoomStay = canCheckOutRoomStay,
|
||||
onToggleShowAll = viewModel::toggleShowAll,
|
||||
onCheckoutRoomStay = { roomStayId ->
|
||||
viewModel.checkoutRoomStay(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
roomStayId = roomStayId
|
||||
)
|
||||
},
|
||||
formatTimeLine = { stay ->
|
||||
val fromAt = stay.fromAt?.let {
|
||||
runCatching {
|
||||
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
val toAt = stay.expectedCheckoutAt?.let {
|
||||
runCatching {
|
||||
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
|
||||
}.getOrNull()
|
||||
}
|
||||
listOfNotNull(fromAt, toAt).joinToString(" → ").ifBlank { null }
|
||||
},
|
||||
showGuestLine = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiConstants
|
||||
import com.android.trisolarispms.data.api.model.BookingCancelRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -17,13 +24,25 @@ import okhttp3.Request
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class BookingDetailsViewModel : ViewModel() {
|
||||
class BookingDetailsViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(BookingDetailsState())
|
||||
val state: StateFlow<BookingDetailsState> = _state
|
||||
private val gson = Gson()
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
private val roomStayRepository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private var eventSource: EventSource? = null
|
||||
private var streamKey: String? = null
|
||||
private var observeKey: String? = null
|
||||
private var observeJob: Job? = null
|
||||
private var lastPropertyId: String? = null
|
||||
private var lastBookingId: String? = null
|
||||
private var retryJob: Job? = null
|
||||
@@ -31,25 +50,19 @@ class BookingDetailsViewModel : ViewModel() {
|
||||
|
||||
fun load(propertyId: String, bookingId: String) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank()) return
|
||||
launchRequest(
|
||||
state = _state,
|
||||
setLoading = { it.copy(isLoading = true, error = null) },
|
||||
setError = { current, message -> current.copy(isLoading = false, error = message) },
|
||||
defaultError = "Load failed"
|
||||
) {
|
||||
val api = ApiClient.create()
|
||||
val response = api.getBookingDetails(propertyId, bookingId)
|
||||
if (response.isSuccessful) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
details = response.body(),
|
||||
error = null
|
||||
)
|
||||
observeCache(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val result = bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_state.update { current -> current.copy(isLoading = false, error = null) }
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
val message = throwable.localizedMessage ?: "Load failed"
|
||||
_state.update { current -> current.copy(isLoading = false, error = message) }
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +93,15 @@ class BookingDetailsViewModel : ViewModel() {
|
||||
null
|
||||
}
|
||||
if (details != null) {
|
||||
_state.update { it.copy(isLoading = false, details = details, error = null) }
|
||||
retryDelayMs = 2000
|
||||
viewModelScope.launch {
|
||||
bookingRepository.storeSnapshot(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
details = details
|
||||
)
|
||||
_state.update { current -> current.copy(isLoading = false, error = null) }
|
||||
retryDelayMs = 2000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +130,99 @@ class BookingDetailsViewModel : ViewModel() {
|
||||
retryJob = null
|
||||
}
|
||||
|
||||
suspend fun updateExpectedDates(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
status: String?,
|
||||
updatedCheckInAt: OffsetDateTime?,
|
||||
updatedCheckOutAt: OffsetDateTime?
|
||||
): Result<Unit> {
|
||||
val bookingStatus = status?.uppercase()
|
||||
val body = when (bookingStatus) {
|
||||
"OPEN" -> BookingExpectedDatesRequest(
|
||||
expectedCheckInAt = updatedCheckInAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
|
||||
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
)
|
||||
|
||||
"CHECKED_IN" -> BookingExpectedDatesRequest(
|
||||
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
)
|
||||
|
||||
else -> return Result.failure(IllegalStateException("Expected dates are not editable"))
|
||||
}
|
||||
val result = bookingRepository.updateExpectedDates(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = body
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
roomStayRepository.patchExpectedCheckoutForBooking(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
expectedCheckoutAt = body.expectedCheckOutAt
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun checkoutBooking(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Result<Unit> = runCatching {
|
||||
val response = ApiClient.create().checkOut(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingCheckOutRequest()
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
val message = if (response.code() == 409) {
|
||||
extractApiErrorMessage(response) ?: "Checkout conflict"
|
||||
} else {
|
||||
"Checkout failed: ${response.code()}"
|
||||
}
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
syncBookingCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
|
||||
suspend fun cancelBooking(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Result<Unit> = runCatching {
|
||||
val response = ApiClient.create().cancelBooking(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingCancelRequest()
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Cancel failed: ${response.code()}")
|
||||
}
|
||||
syncBookingCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
observeJob?.cancel()
|
||||
stopStream()
|
||||
}
|
||||
|
||||
private fun observeCache(propertyId: String, bookingId: String) {
|
||||
val key = "$propertyId:$bookingId"
|
||||
if (observeKey == key && observeJob?.isActive == true) return
|
||||
observeJob?.cancel()
|
||||
observeKey = key
|
||||
observeJob = viewModelScope.launch {
|
||||
bookingRepository.observeBookingDetails(propertyId = propertyId, bookingId = bookingId).collect { details ->
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
details = details,
|
||||
isLoading = if (details != null) false else current.isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
val propertyId = lastPropertyId ?: return
|
||||
val bookingId = lastBookingId ?: return
|
||||
@@ -125,4 +233,12 @@ class BookingDetailsViewModel : ViewModel() {
|
||||
startStream(propertyId, bookingId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncBookingCaches(propertyId: String, bookingId: String) {
|
||||
roomStayRepository.refresh(propertyId = propertyId).getOrThrow()
|
||||
bookingRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -26,6 +16,7 @@ import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
fun BookingRoomStaysScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
canCheckOutRoomStay: Boolean,
|
||||
onBack: () -> Unit,
|
||||
viewModel: BookingRoomStaysViewModel = viewModel()
|
||||
) {
|
||||
@@ -45,45 +36,24 @@ fun BookingRoomStaysScreen(
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Show all (including checkout)")
|
||||
Switch(
|
||||
checked = state.showAll,
|
||||
onCheckedChange = viewModel::toggleShowAll
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (!state.isLoading && state.error == null) {
|
||||
if (state.stays.isEmpty()) {
|
||||
Text(text = "No stays found")
|
||||
} else {
|
||||
state.stays.forEach { stay ->
|
||||
val roomLine = "Room ${stay.roomNumber ?: "-"} • ${stay.roomTypeName ?: ""}".trim()
|
||||
Text(text = roomLine, style = MaterialTheme.typography.titleMedium)
|
||||
val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString(" • ")
|
||||
if (guestLine.isNotBlank()) {
|
||||
Text(text = guestLine, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
val timeLine = listOfNotNull(stay.fromAt, stay.expectedCheckoutAt).joinToString(" → ")
|
||||
if (timeLine.isNotBlank()) {
|
||||
Text(text = timeLine, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
RoomStayListSection(
|
||||
state = state,
|
||||
canCheckOutRoomStay = canCheckOutRoomStay,
|
||||
onToggleShowAll = viewModel::toggleShowAll,
|
||||
onCheckoutRoomStay = { roomStayId ->
|
||||
viewModel.checkoutRoomStay(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
roomStayId = roomStayId
|
||||
)
|
||||
},
|
||||
formatTimeLine = { stay ->
|
||||
listOfNotNull(stay.fromAt, stay.expectedCheckoutAt)
|
||||
.joinToString(" → ")
|
||||
.ifBlank { null }
|
||||
},
|
||||
showGuestLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@ data class BookingRoomStaysState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val stays: List<ActiveRoomStayDto> = emptyList(),
|
||||
val showAll: Boolean = false
|
||||
val showAll: Boolean = false,
|
||||
val checkingOutRoomStayId: String? = null,
|
||||
val checkoutError: String? = null,
|
||||
val checkoutBlockedReason: String? = null,
|
||||
val conflictRoomStayIds: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BookingRoomStaysViewModel : ViewModel() {
|
||||
class BookingRoomStaysViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(BookingRoomStaysState())
|
||||
val state: StateFlow<BookingRoomStaysState> = _state
|
||||
private val repository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
private var observeJob: Job? = null
|
||||
private var observeKey: String? = null
|
||||
|
||||
fun toggleShowAll(value: Boolean) {
|
||||
_state.update { it.copy(showAll = value) }
|
||||
@@ -17,26 +35,147 @@ class BookingRoomStaysViewModel : ViewModel() {
|
||||
|
||||
fun load(propertyId: String, bookingId: String) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank()) return
|
||||
launchRequest(
|
||||
state = _state,
|
||||
setLoading = { it.copy(isLoading = true, error = null) },
|
||||
setError = { current, message -> current.copy(isLoading = false, error = message) },
|
||||
defaultError = "Load failed"
|
||||
) {
|
||||
val api = ApiClient.create()
|
||||
val response = api.listActiveRoomStays(propertyId)
|
||||
if (response.isSuccessful) {
|
||||
val filtered = response.body().orEmpty().filter { it.bookingId == bookingId }
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
stays = filtered,
|
||||
error = null
|
||||
observeBookingCache(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
refreshForBooking(propertyId = propertyId, bookingId = bookingId)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkoutRoomStay(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
roomStayId: String
|
||||
) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank() || roomStayId.isBlank()) return
|
||||
if (_state.value.checkingOutRoomStayId != null) return
|
||||
viewModelScope.launch {
|
||||
_state.update {
|
||||
it.copy(
|
||||
checkingOutRoomStayId = roomStayId,
|
||||
checkoutError = null
|
||||
)
|
||||
}
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.checkOutRoomStay(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
roomStayId = roomStayId,
|
||||
body = BookingRoomStayCheckOutRequest()
|
||||
)
|
||||
when {
|
||||
response.isSuccessful -> {
|
||||
val refreshResult = repository.refresh(propertyId = propertyId)
|
||||
if (refreshResult.isFailure) {
|
||||
repository.removeFromCache(propertyId = propertyId, roomStayId = roomStayId)
|
||||
}
|
||||
bookingRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
checkingOutRoomStayId = null,
|
||||
checkoutError = null,
|
||||
checkoutBlockedReason = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
response.code() == 409 -> {
|
||||
val message = extractApiErrorMessage(response) ?: "Checkout conflict"
|
||||
_state.update { current -> handleCheckoutConflict(current, roomStayId, message) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
checkingOutRoomStayId = null,
|
||||
checkoutError = "Checkout failed: ${response.code()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
checkingOutRoomStayId = null,
|
||||
checkoutError = e.localizedMessage ?: "Checkout failed"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
observeJob?.cancel()
|
||||
}
|
||||
|
||||
private fun observeBookingCache(propertyId: String, bookingId: String) {
|
||||
val key = "$propertyId:$bookingId"
|
||||
if (observeKey == key && observeJob?.isActive == true) return
|
||||
observeJob?.cancel()
|
||||
observeKey = key
|
||||
observeJob = viewModelScope.launch {
|
||||
repository.observeByBooking(propertyId = propertyId, bookingId = bookingId).collect { stays ->
|
||||
val stayIds = stays.mapNotNull { it.roomStayId }.toSet()
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
stays = stays,
|
||||
isLoading = if (stays.isNotEmpty()) false else current.isLoading,
|
||||
conflictRoomStayIds = current.conflictRoomStayIds.intersect(stayIds)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Result<Unit> {
|
||||
val activeResult = repository.refresh(propertyId = propertyId)
|
||||
val detailsResponse = runCatching {
|
||||
ApiClient.create().getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
}.getOrNull()
|
||||
val details = detailsResponse?.body().takeIf { detailsResponse?.isSuccessful == true }
|
||||
val blockedReason = deriveBookingCheckoutBlockedReason(details)
|
||||
val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
|
||||
?: if (detailsResponse != null && !detailsResponse.isSuccessful) {
|
||||
"Load failed: ${detailsResponse.code()}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
isLoading = false,
|
||||
error = errorMessage,
|
||||
checkoutBlockedReason = blockedReason,
|
||||
checkoutError = blockedReason
|
||||
)
|
||||
}
|
||||
return activeResult
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCheckoutConflict(
|
||||
current: BookingRoomStaysState,
|
||||
roomStayId: String,
|
||||
message: String
|
||||
): BookingRoomStaysState {
|
||||
return if (isBookingLevelCheckoutConflict(message)) {
|
||||
current.copy(
|
||||
checkingOutRoomStayId = null,
|
||||
checkoutError = message,
|
||||
checkoutBlockedReason = message
|
||||
)
|
||||
} else {
|
||||
current.copy(
|
||||
checkingOutRoomStayId = null,
|
||||
checkoutError = message,
|
||||
conflictRoomStayIds = current.conflictRoomStayIds + roomStayId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingBulkCheckInStayRequest
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class ManageRoomStayRatesViewModel : ViewModel() {
|
||||
class ManageRoomStayRatesViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(ManageRoomStayRatesState())
|
||||
val state: StateFlow<ManageRoomStayRatesState> = _state
|
||||
private val roomStayRepository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private val bookingRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
|
||||
fun setItems(items: List<ManageRoomStaySelection>) {
|
||||
val mapped = items.map { item ->
|
||||
@@ -102,6 +114,11 @@ class ManageRoomStayRatesViewModel : ViewModel() {
|
||||
body = BookingBulkCheckInRequest(stays = stays)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
roomStayRepository.refresh(propertyId = propertyId)
|
||||
bookingRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
_state.update { it.copy(isLoading = false, error = null) }
|
||||
onDone()
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import com.android.trisolarispms.ui.common.LoadingAndError
|
||||
import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
@@ -113,16 +112,15 @@ fun ManageRoomStaySelectScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(state.rooms) { room ->
|
||||
val selection = room.toSelection() ?: return@items
|
||||
val isSelected = selectedRooms.any { it.roomId == selection.roomId }
|
||||
val isSelected = selectedRooms.any { it.roomId == room.roomId }
|
||||
RoomSelectCard(
|
||||
item = selection,
|
||||
item = room,
|
||||
isSelected = isSelected,
|
||||
onToggle = {
|
||||
if (isSelected) {
|
||||
selectedRooms.removeAll { it.roomId == selection.roomId }
|
||||
selectedRooms.removeAll { it.roomId == room.roomId }
|
||||
} else {
|
||||
selectedRooms.add(selection)
|
||||
selectedRooms.add(room)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -187,16 +185,3 @@ private fun String.toDateOnly(): String? {
|
||||
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun RoomAvailableRateResponse.toSelection(): ManageRoomStaySelection? {
|
||||
val id = roomId ?: return null
|
||||
val number = roomNumber ?: return null
|
||||
return ManageRoomStaySelection(
|
||||
roomId = id,
|
||||
roomNumber = number,
|
||||
roomTypeName = roomTypeName ?: roomTypeCode ?: "Room",
|
||||
averageRate = averageRate,
|
||||
currency = currency,
|
||||
ratePlanCode = ratePlanCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
|
||||
|
||||
data class ManageRoomStaySelectState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val rooms: List<RoomAvailableRateResponse> = emptyList()
|
||||
val rooms: List<ManageRoomStaySelection> = emptyList()
|
||||
)
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.android.trisolarispms.ui.roomstay
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -20,17 +23,57 @@ class ManageRoomStaySelectViewModel : ViewModel() {
|
||||
defaultError = "Load failed"
|
||||
) {
|
||||
val api = ApiClient.create()
|
||||
val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to)
|
||||
if (response.isSuccessful) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
rooms = response.body().orEmpty(),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val response = api.getRoomAvailabilityRange(propertyId, from = from, to = to)
|
||||
if (!response.isSuccessful) {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
||||
return@launchRequest
|
||||
}
|
||||
|
||||
val rooms = coroutineScope {
|
||||
response.body().orEmpty()
|
||||
.mapNotNull { entry ->
|
||||
val roomTypeCode = entry.roomTypeCode?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val hasFreeRooms = (entry.freeCount ?: entry.freeRoomNumbers.size) > 0
|
||||
if (!hasFreeRooms) return@mapNotNull null
|
||||
async {
|
||||
val byTypeResponse = api.listRoomsByType(
|
||||
propertyId = propertyId,
|
||||
roomTypeCode = roomTypeCode,
|
||||
availableOnly = true
|
||||
)
|
||||
if (!byTypeResponse.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${byTypeResponse.code()}")
|
||||
}
|
||||
byTypeResponse.body().orEmpty()
|
||||
.mapNotNull { room ->
|
||||
val roomId = room.id ?: return@mapNotNull null
|
||||
val roomNumber = room.roomNumber ?: return@mapNotNull null
|
||||
ManageRoomStaySelection(
|
||||
roomId = roomId,
|
||||
roomNumber = roomNumber,
|
||||
roomTypeName = room.roomTypeName
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: entry.roomTypeName
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: roomTypeCode,
|
||||
averageRate = entry.averageRate,
|
||||
currency = entry.currency,
|
||||
ratePlanCode = entry.ratePlanCode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.flatten()
|
||||
.sortedBy { it.roomNumber }
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
rooms = rooms,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
internal fun canShowRoomStayCheckoutButton(
|
||||
canCheckOutRoomStay: Boolean,
|
||||
stay: ActiveRoomStayDto,
|
||||
state: BookingRoomStaysState,
|
||||
now: OffsetDateTime = OffsetDateTime.now()
|
||||
): Boolean {
|
||||
if (!canCheckOutRoomStay) return false
|
||||
if (!state.checkoutBlockedReason.isNullOrBlank()) return false
|
||||
val roomStayId = stay.roomStayId?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (roomStayId in state.conflictRoomStayIds) return false
|
||||
if (stay.guestName.isNullOrBlank()) return false
|
||||
if (!hasMinimumCheckoutDuration(stay.fromAt, now)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasMinimumCheckoutDuration(fromAt: String?, now: OffsetDateTime): Boolean {
|
||||
val parsedFromAt = fromAt?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() } ?: return false
|
||||
return Duration.between(parsedFromAt, now).toMinutes() >= 60
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
|
||||
@Composable
|
||||
internal fun RoomStayListSection(
|
||||
state: BookingRoomStaysState,
|
||||
canCheckOutRoomStay: Boolean,
|
||||
onToggleShowAll: (Boolean) -> Unit,
|
||||
onCheckoutRoomStay: (String) -> Unit,
|
||||
formatTimeLine: (ActiveRoomStayDto) -> String?,
|
||||
showGuestLine: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Show all (including checkout)")
|
||||
Switch(
|
||||
checked = state.showAll,
|
||||
onCheckedChange = onToggleShowAll
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.checkoutError?.takeIf { it != state.checkoutBlockedReason }?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.checkoutBlockedReason?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (!state.isLoading && state.error == null) {
|
||||
if (state.stays.isEmpty()) {
|
||||
Text(text = "No stays found")
|
||||
} else {
|
||||
state.stays.forEach { stay ->
|
||||
val roomLine = "Room ${stay.roomNumber ?: "-"} • ${stay.roomTypeName ?: ""}".trim()
|
||||
Text(text = roomLine, style = MaterialTheme.typography.titleMedium)
|
||||
if (showGuestLine) {
|
||||
val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString(" • ")
|
||||
if (guestLine.isNotBlank()) {
|
||||
Text(text = guestLine, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
val timeLine = formatTimeLine(stay)
|
||||
if (!timeLine.isNullOrBlank()) {
|
||||
Text(text = timeLine, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
stay.nightlyRate?.let { nightlyRate ->
|
||||
val currency = stay.currency?.takeIf { it.isNotBlank() } ?: "INR"
|
||||
Text(
|
||||
text = "Nightly rate: $currency $nightlyRate",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
val roomStayId = stay.roomStayId.orEmpty()
|
||||
val canCheckoutThisStay = canShowRoomStayCheckoutButton(
|
||||
canCheckOutRoomStay = canCheckOutRoomStay,
|
||||
stay = stay,
|
||||
state = state
|
||||
)
|
||||
if (canCheckoutThisStay) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { onCheckoutRoomStay(roomStayId) },
|
||||
enabled = state.checkingOutRoomStayId == null
|
||||
) {
|
||||
val label = if (state.checkingOutRoomStayId == roomStayId) {
|
||||
"Checking out..."
|
||||
} else {
|
||||
"Check-out this room"
|
||||
}
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.google.services) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
@@ -21,6 +21,8 @@ kotlin.code.style=official
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
systemProp.java.net.preferIPv4Stack=true
|
||||
org.gradle.internal.http.connectionTimeout=600000
|
||||
org.gradle.internal.http.socketTimeout=600000
|
||||
|
||||
@@ -23,6 +23,8 @@ lottieCompose = "6.7.1"
|
||||
calendarCompose = "2.6.0"
|
||||
libphonenumber = "8.13.34"
|
||||
zxingCore = "3.5.3"
|
||||
room = "2.8.4"
|
||||
ksp = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -57,8 +59,13 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
|
||||
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
|
||||
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
|
||||
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
Reference in New Issue
Block a user