Compare commits

...

12 Commits

Author SHA1 Message Date
androidlover5842
f69a01a460 ai added more room db stuff 2026-02-08 19:54:35 +05:30
androidlover5842
1000f2411c add room db 2026-02-08 19:21:07 +05:30
androidlover5842
e9c3b4f669 booking: ability to edit more guest info 2026-02-07 22:33:43 +05:30
androidlover5842
90c2b6fb9f roomStays: show rates 2026-02-05 13:05:43 +05:30
androidlover5842
1e5f412f82 ablity to checkout stay 2026-02-05 10:31:15 +05:30
androidlover5842
a67eacd77f createbooking: improve logic for future bookings 2026-02-04 17:55:35 +05:30
androidlover5842
f9b09e2376 ability to cancel future bookings 2026-02-04 16:45:15 +05:30
androidlover5842
e1250a0f32 stay: improve checkin and checkout time editor 2026-02-04 16:28:50 +05:30
androidlover5842
d69ed60a6e agents -_- 2026-02-04 15:32:44 +05:30
androidlover5842
56f13f5e79 ability to see open bookings list 2026-02-04 15:20:17 +05:30
androidlover5842
9555ae2e40 activeScreen:improve menu 2026-02-04 15:07:27 +05:30
androidlover5842
9d942d6411 createBooking: change checkout date based on property policy while editing checking date 2026-02-04 14:58:16 +05:30
81 changed files with 5634 additions and 1226 deletions

View File

@@ -538,6 +538,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
### Non-negotiable coding rules ### Non-negotiable coding rules
- Duplicate code is forbidden.
- Never add duplicate business logic in multiple files. - Never add duplicate business logic in multiple files.
- Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns. - Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns.
- If similar logic appears 2+ times, extract shared function/class immediately. - 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. - Imports/packages follow the structure above.
- Build passes: `./gradlew :app:compileDebugKotlin`. - 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) ### Guest Documents Authorization (mandatory)
- View access: `ADMIN`, `MANAGER` (and super admin). - View access: `ADMIN`, `MANAGER` (and super admin).

View File

@@ -1,14 +1,17 @@
import com.android.build.api.dsl.ApplicationExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
id("com.google.gms.google-services") id("com.google.gms.google-services")
} }
android { extensions.configure<ApplicationExtension>("android") {
namespace = "com.android.trisolarispms" namespace = "com.android.trisolarispms"
compileSdk { compileSdk = 36
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "com.android.trisolarispms" applicationId = "com.android.trisolarispms"
@@ -29,16 +32,24 @@ android {
) )
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -63,6 +74,9 @@ dependencies {
implementation(libs.calendar.compose) implementation(libs.calendar.compose)
implementation(libs.libphonenumber) implementation(libs.libphonenumber)
implementation(libs.zxing.core) 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(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx) implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services) implementation(libs.kotlinx.coroutines.play.services)

View File

@@ -38,6 +38,12 @@ class AuthzPolicy(
fun canCreateBookingFor(propertyId: String): Boolean = fun canCreateBookingFor(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF) 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 canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER) fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER)

View File

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

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.core.booking
object BookingProfileOptions {
val memberRelations: List<String> = listOf(
"FRIENDS",
"FAMILY",
"GROUP",
"ALONE"
)
val transportModes: List<String> = listOf(
"",
"CAR",
"BIKE",
"TRAIN",
"PLANE",
"BUS",
"FOOT",
"CYCLE",
"OTHER"
)
}

View File

@@ -0,0 +1,51 @@
package com.android.trisolarispms.core.viewmodel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class CitySearchController(
private val scope: CoroutineScope,
private val onUpdate: (isLoading: Boolean, suggestions: List<String>) -> Unit,
private val search: suspend (query: String, limit: Int) -> List<String>,
private val minQueryLength: Int = 2,
private val defaultLimit: Int = 20,
private val debounceMs: Long = 300L
) {
private var job: Job? = null
fun onQueryChanged(rawQuery: String) {
val query = rawQuery.trim()
job?.cancel()
if (query.length < minQueryLength) {
onUpdate(false, emptyList())
return
}
job = scope.launch {
delay(debounceMs)
if (!isActive) return@launch
onUpdate(true, emptyList())
try {
val suggestions = search(query, defaultLimit)
if (isActive) {
onUpdate(false, suggestions)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
if (isActive) {
onUpdate(false, emptyList())
}
}
}
}
fun cancel() {
job?.cancel()
job = null
}
}

View File

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

View File

@@ -0,0 +1,30 @@
package com.android.trisolarispms.data.api.core
import com.google.gson.JsonElement
object GeoSearchRepository {
suspend fun searchCityDisplayValues(
query: String,
limit: Int = 20
): List<String> {
val response = ApiClient.create().searchCities(query = query, limit = limit)
if (!response.isSuccessful) return emptyList()
return response.body()
.orEmpty()
.mapNotNull(::extractCityDisplayValue)
.distinct()
}
}
private fun extractCityDisplayValue(element: JsonElement): String? {
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
return element.asString.trim().ifBlank { null }
}
if (!element.isJsonObject) return null
val obj = element.asJsonObject
val city = obj.get("city")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
?: return null
val state = obj.get("state")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
return if (state == null) city else "$city, $state"
}

View File

@@ -56,6 +56,7 @@ data class BookingListItem(
val guestId: String? = null, val guestId: String? = null,
val guestName: String? = null, val guestName: String? = null,
val guestPhone: String? = null, val guestPhone: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(), val roomNumbers: List<Int> = emptyList(),
val source: String? = null, val source: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
@@ -101,6 +102,13 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null val expectedCheckOutAt: String? = null
) )
data class BookingRoomRequestCreateRequest(
val roomTypeCode: String,
val quantity: Int,
val fromAt: String,
val toAt: String
)
data class BookingBillableNightsRequest( data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null, val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null val expectedCheckOutAt: String? = null
@@ -112,6 +120,22 @@ data class BookingBillableNightsResponse(
val billableNights: Long? = null 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( data class BookingDetailsResponse(
val id: String? = null, val id: String? = null,
val status: String? = null, val status: String? = null,

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class RoomCreateRequest( data class RoomCreateRequest(
val roomNumber: Int, val roomNumber: Int,
val floor: Int? = null, val floor: Int? = null,
@@ -47,16 +49,11 @@ data class RoomAvailabilityResponse(
) )
data class RoomAvailabilityRangeResponse( data class RoomAvailabilityRangeResponse(
val roomTypeName: String? = null, @SerializedName(value = "roomTypeCode", alternate = ["code"])
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null
)
data class RoomAvailableRateResponse(
val roomId: String? = null,
val roomNumber: Int? = null,
val roomTypeCode: String? = null, val roomTypeCode: String? = null,
val roomTypeName: String? = null, val roomTypeName: String? = null,
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null,
val averageRate: Double? = null, val averageRate: Double? = null,
val currency: String? = null, val currency: String? = null,
val ratePlanCode: String? = null val ratePlanCode: String? = null

View File

@@ -12,5 +12,7 @@ data class ActiveRoomStayDto(
val roomTypeName: String? = null, val roomTypeName: String? = null,
val fromAt: String? = null, val fromAt: String? = null,
val checkinAt: String? = null, val checkinAt: String? = null,
val expectedCheckoutAt: String? = null val expectedCheckoutAt: String? = null,
val nightlyRate: Long? = null,
val currency: String? = null
) )

View File

@@ -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.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsResponse 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.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingBalanceResponse import com.android.trisolarispms.data.api.model.BookingBalanceResponse
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
import com.google.gson.JsonObject
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest import com.android.trisolarispms.data.api.model.RazorpayQrRequest
@@ -62,6 +66,20 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest @Body body: BookingExpectedDatesRequest
): Response<Unit> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/profile")
suspend fun updateBookingProfile(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: JsonObject
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
suspend fun createRoomRequest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingRoomRequestCreateRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/billable-nights") @POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
suspend fun previewBillableNights( suspend fun previewBillableNights(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -69,6 +87,12 @@ interface BookingApi {
@Body body: BookingBillableNightsRequest @Body body: BookingBillableNightsRequest
): Response<BookingBillableNightsResponse> ): 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") @POST("properties/{propertyId}/bookings/{bookingId}/billing-policy")
suspend fun updateBookingBillingPolicy( suspend fun updateBookingBillingPolicy(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -0,0 +1,20 @@
package com.android.trisolarispms.data.api.service
import com.google.gson.JsonElement
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface GeoApi {
@GET("geo/cities/search")
suspend fun searchCities(
@Query("q") query: String,
@Query("limit") limit: Int = 20
): Response<List<JsonElement>>
@GET("geo/countries/search")
suspend fun searchCountries(
@Query("q") query: String,
@Query("limit") limit: Int = 20
): Response<List<String>>
}

View File

@@ -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.RoomAvailabilityRangeResponse
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse 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.RoomBoardDto
import com.android.trisolarispms.data.api.model.RoomCreateRequest import com.android.trisolarispms.data.api.model.RoomCreateRequest
import com.android.trisolarispms.data.api.model.RoomDto import com.android.trisolarispms.data.api.model.RoomDto
@@ -55,7 +54,8 @@ interface RoomApi {
suspend fun getRoomAvailabilityRange( suspend fun getRoomAvailabilityRange(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Query("from") from: String, @Query("from") from: String,
@Query("to") to: String @Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailabilityRangeResponse>> ): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available") @GET("properties/{propertyId}/rooms/available")
@@ -63,14 +63,6 @@ interface RoomApi {
@Path("propertyId") propertyId: String @Path("propertyId") propertyId: String
): Response<List<RoomDto>> ): 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}") @GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType( suspend fun listRoomsByType(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,18 +11,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -36,7 +31,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import com.android.trisolarispms.ui.common.SaveTopBarScaffold import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -51,39 +49,50 @@ fun BookingCreateScreen(
viewModel: BookingCreateViewModel = viewModel() viewModel: BookingCreateViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val showCheckInPicker = remember { mutableStateOf(false) } val showCheckInDatePicker = remember { mutableStateOf(false) }
val showCheckOutPicker = 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 checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) } val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") } val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") } val checkOutTime = remember { mutableStateOf("11:00") }
val checkInNow = remember { mutableStateOf(true) }
val sourceMenuExpanded = remember { mutableStateOf(false) }
val sourceOptions = listOf("WALKIN", "OTA", "AGENT")
val relationMenuExpanded = remember { mutableStateOf(false) } val relationMenuExpanded = remember { mutableStateOf(false) }
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) } val transportMenuExpanded = remember { mutableStateOf(false) }
val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
val billingModeMenuExpanded = remember { mutableStateOf(false) } val billingModeMenuExpanded = remember { mutableStateOf(false) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") } val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val phoneCountryMenuExpanded = remember { mutableStateOf(false) } val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") } 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) { LaunchedEffect(propertyId) {
viewModel.reset() viewModel.reset()
viewModel.loadBillingPolicy(propertyId) viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now() 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) val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate checkOutDate.value = defaultCheckoutDate
checkOutTime.value = "11:00" checkOutTime.value = "11:00"
viewModel.onExpectedCheckOutAtChange(formatBookingIso(defaultCheckoutDate, checkOutTime.value)) 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( SaveTopBarScaffold(
@@ -99,60 +108,30 @@ fun BookingCreateScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
Row( val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
modifier = Modifier.fillMaxWidth(), runCatching { OffsetDateTime.parse(it) }.getOrNull()
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("")
}
}
)
} }
if (!checkInNow.value) { val checkOutDisplay = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(8.dp)) runCatching { OffsetDateTime.parse(it) }.getOrNull()
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()
)
} }
Spacer(modifier = Modifier.height(12.dp)) val cardDateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
OutlinedTextField( val cardTimeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
value = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let { val totalTimeText = remember(state.expectedCheckInAt, state.expectedCheckOutAt) {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it) val start = runCatching { OffsetDateTime.parse(state.expectedCheckInAt) }.getOrNull()
}.orEmpty(), val end = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull()
onValueChange = {}, formatBookingDurationText(start, end)
readOnly = true, }
label = { Text("Expected Check-out") }, BookingDateTimeQuickEditorCard(
trailingIcon = { checkInDateText = checkInDisplay?.format(cardDateFormatter) ?: "--/--/----",
IconButton(onClick = { showCheckOutPicker.value = true }) { checkInTimeText = checkInDisplay?.format(cardTimeFormatter) ?: "--:--",
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date") checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
} checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
}, totalTimeText = totalTimeText,
modifier = Modifier.fillMaxWidth() checkInEditable = true,
onCheckInDateClick = { showCheckInDatePicker.value = true },
onCheckInTimeClick = { showCheckInTimePicker.value = true },
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
onCheckOutTimeClick = { showCheckOutTimePicker.value = true }
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
@@ -181,6 +160,7 @@ fun BookingCreateScreen(
onClick = { onClick = {
billingModeMenuExpanded.value = false billingModeMenuExpanded.value = false
viewModel.onBillingModeChange(mode) viewModel.onBillingModeChange(mode)
viewModel.refreshExpectedCheckoutPreview(propertyId)
} }
) )
} }
@@ -191,7 +171,10 @@ fun BookingCreateScreen(
BookingTimePickerTextField( BookingTimePickerTextField(
value = state.billingCheckoutTime, value = state.billingCheckoutTime,
label = { Text("Billing check-out (HH:mm)") }, label = { Text("Billing check-out (HH:mm)") },
onTimeSelected = viewModel::onBillingCheckoutTimeChange, onTimeSelected = { selectedTime ->
viewModel.onBillingCheckoutTimeChange(selectedTime)
viewModel.refreshExpectedCheckoutPreview(propertyId)
},
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -241,115 +224,31 @@ fun BookingCreateScreen(
} }
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( PhoneNumberCountryField(
modifier = Modifier.fillMaxWidth(), phoneCountryCode = state.phoneCountryCode,
horizontalArrangement = Arrangement.spacedBy(8.dp), onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
verticalAlignment = Alignment.Top phoneNationalNumber = state.phoneNationalNumber,
) { onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
ExposedDropdownMenuBox( countryWeight = 0.3f,
expanded = phoneCountryMenuExpanded.value, numberWeight = 0.7f
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(0.3f)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
val filteredCountries = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) {
true
} else {
option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
}
filteredCountries.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
viewModel.onPhoneCountryChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = state.phoneNationalNumber,
onValueChange = viewModel::onPhoneNationalNumberChange,
label = { Text("Number") },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(0.7f)
)
}
Spacer(modifier = Modifier.height(12.dp))
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()
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( CityAutocompleteField(
value = state.fromCity,
onValueChange = viewModel::onFromCityChange,
label = "From City (optional)",
suggestions = state.fromCitySuggestions,
isLoading = state.isFromCitySearchLoading,
onSuggestionSelected = viewModel::onFromCitySuggestionSelected
)
Spacer(modifier = Modifier.height(12.dp))
CityAutocompleteField(
value = state.toCity, value = state.toCity,
onValueChange = viewModel::onToCityChange, onValueChange = viewModel::onToCityChange,
label = { Text("To City (optional)") }, label = "To City (optional)",
modifier = Modifier.fillMaxWidth() suggestions = state.toCitySuggestions,
isLoading = state.isToCitySearchLoading,
onSuggestionSelected = viewModel::onToCitySuggestionSelected
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
@@ -372,7 +271,7 @@ fun BookingCreateScreen(
expanded = relationMenuExpanded.value, expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false } onDismissRequest = { relationMenuExpanded.value = false }
) { ) {
relationOptions.forEach { option -> BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(option) }, text = { Text(option) },
onClick = { onClick = {
@@ -389,7 +288,7 @@ fun BookingCreateScreen(
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value } onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.transportMode, value = state.transportMode.ifBlank { "Not set" },
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Transport Mode") }, label = { Text("Transport Mode") },
@@ -404,9 +303,10 @@ fun BookingCreateScreen(
expanded = transportMenuExpanded.value, expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false } onDismissRequest = { transportMenuExpanded.value = false }
) { ) {
transportOptions.forEach { option -> BookingProfileOptions.transportModes.forEach { option ->
val optionLabel = option.ifBlank { "Not set" }
DropdownMenuItem( DropdownMenuItem(
text = { Text(option) }, text = { Text(optionLabel) },
onClick = { onClick = {
transportMenuExpanded.value = false transportMenuExpanded.value = false
viewModel.onTransportModeChange(option) viewModel.onTransportModeChange(option)
@@ -473,36 +373,50 @@ fun BookingCreateScreen(
} }
} }
if (showCheckInPicker.value) { if (showCheckInDatePicker.value) {
BookingDateTimePickerDialog( BookingDatePickerDialog(
title = "Select check-in", initialDate = checkInDate.value ?: LocalDate.now(),
initialDate = checkInDate.value,
initialTime = checkInTime.value,
minDate = LocalDate.now(), minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false }, onDismiss = { showCheckInDatePicker.value = false },
onConfirm = { date, time -> onDateSelected = { selectedDate ->
checkInDate.value = date applyCheckInSelection(selectedDate, checkInTime.value)
checkInTime.value = time
val formatted = formatBookingIso(date, time)
viewModel.onExpectedCheckInAtChange(formatted)
showCheckInPicker.value = false
} }
) )
} }
if (showCheckOutPicker.value) { if (showCheckInTimePicker.value) {
BookingDateTimePickerDialog( BookingTimePickerDialog(
title = "Select check-out", initialTime = checkInTime.value,
initialDate = checkOutDate.value, onDismiss = { showCheckInTimePicker.value = false },
initialTime = checkOutTime.value, 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(), minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false }, onDismiss = { showCheckOutDatePicker.value = false },
onConfirm = { date, time -> onDateSelected = { selectedDate ->
checkOutDate.value = date checkOutDate.value = selectedDate
checkOutTime.value = time val formatted = formatBookingIso(selectedDate, checkOutTime.value)
val formatted = formatBookingIso(date, time) 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) viewModel.onExpectedCheckOutAtChange(formatted)
showCheckOutPicker.value = false
} }
) )
} }

View File

@@ -12,11 +12,16 @@ data class BookingCreateState(
val expectedCheckOutAt: String = "", val expectedCheckOutAt: String = "",
val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY, val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
val billingCheckoutTime: String = "", val billingCheckoutTime: String = "",
val source: String = "WALKIN", val source: String = "",
val fromCity: String = "", val fromCity: String = "",
val fromCitySuggestions: List<String> = emptyList(),
val isFromCitySearchLoading: Boolean = false,
val toCity: String = "", val toCity: String = "",
val toCitySuggestions: List<String> = emptyList(),
val isToCitySearchLoading: Boolean = false,
val memberRelation: String = "", val memberRelation: String = "",
val transportMode: String = "CAR", val transportMode: String = "CAR",
val isTransportModeAuto: Boolean = true,
val childCount: String = "", val childCount: String = "",
val maleCount: String = "", val maleCount: String = "",
val femaleCount: String = "", val femaleCount: String = "",

View File

@@ -1,28 +1,87 @@
package com.android.trisolarispms.ui.booking package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.ApiClient
import com.android.trisolarispms.data.api.core.GeoSearchRepository
import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingCreateRequest import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
import com.android.trisolarispms.data.api.model.GuestDto 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime 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()) private val _state = MutableStateFlow(BookingCreateState())
val state: StateFlow<BookingCreateState> = _state 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() { fun reset() {
expectedCheckoutPreviewRequestId = 0
fromCitySearch.cancel()
toCitySearch.cancel()
_state.value = BookingCreateState() _state.value = BookingCreateState()
} }
fun onExpectedCheckInAtChange(value: String) { 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) { fun onExpectedCheckOutAtChange(value: String) {
@@ -76,6 +135,50 @@ class BookingCreateViewModel : ViewModel() {
_state.update { it.copy(billingCheckoutTime = value, error = null) } _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) { fun onPhoneCountryChange(value: String) {
val option = findPhoneCountryOption(value) val option = findPhoneCountryOption(value)
_state.update { current -> _state.update { current ->
@@ -139,10 +242,36 @@ class BookingCreateViewModel : ViewModel() {
fun onFromCityChange(value: String) { fun onFromCityChange(value: String) {
_state.update { it.copy(fromCity = value, error = null) } _state.update { it.copy(fromCity = value, error = null) }
fromCitySearch.onQueryChanged(value)
} }
fun onToCityChange(value: String) { fun onToCityChange(value: String) {
_state.update { it.copy(toCity = value, error = null) } _state.update { it.copy(toCity = value, error = null) }
toCitySearch.onQueryChanged(value)
}
fun onFromCitySuggestionSelected(value: String) {
fromCitySearch.cancel()
_state.update {
it.copy(
fromCity = value,
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
error = null
)
}
}
fun onToCitySuggestionSelected(value: String) {
toCitySearch.cancel()
_state.update {
it.copy(
toCity = value,
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
error = null
)
}
} }
fun onMemberRelationChange(value: String) { fun onMemberRelationChange(value: String) {
@@ -150,19 +279,34 @@ class BookingCreateViewModel : ViewModel() {
} }
fun onTransportModeChange(value: String) { 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) { 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) { 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) { 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) { fun onExpectedGuestCountChange(value: String) {
@@ -230,6 +374,10 @@ class BookingCreateViewModel : ViewModel() {
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
syncCreateBookingCaches(
propertyId = propertyId,
bookingId = body.id
)
_state.update { it.copy(isLoading = false, error = null) } _state.update { it.copy(isLoading = false, error = null) }
onDone(body, null, phone) onDone(body, null, phone)
} else { } 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")
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
package com.android.trisolarispms.ui.common
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CityAutocompleteField(
value: String,
onValueChange: (String) -> Unit,
label: String,
suggestions: List<String>,
isLoading: Boolean,
onSuggestionSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
val expanded = remember { mutableStateOf(false) }
val query = value.trim()
val canShowMenu = expanded.value && (isLoading || suggestions.isNotEmpty() || query.length >= 2)
ExposedDropdownMenuBox(
expanded = canShowMenu,
onExpandedChange = { expanded.value = it }
) {
OutlinedTextField(
value = value,
onValueChange = { input ->
onValueChange(input)
expanded.value = input.trim().length >= 2
},
label = { Text(label) },
supportingText = {
if (query.length < 2) {
Text("Type at least 2 letters")
}
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded.value && (isLoading || suggestions.isNotEmpty())
)
},
modifier = modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
enabled = true
),
singleLine = true
)
ExposedDropdownMenu(
expanded = canShowMenu,
onDismissRequest = { expanded.value = false }
) {
if (isLoading) {
DropdownMenuItem(
text = { Text("Searching...") },
onClick = {},
enabled = false
)
} else if (query.length >= 2 && suggestions.isEmpty()) {
DropdownMenuItem(
text = { Text("No cities found") },
onClick = {},
enabled = false
)
} else {
suggestions.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
expanded.value = false
onSuggestionSelected(option)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package com.android.trisolarispms.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.android.trisolarispms.ui.booking.phoneCountryOptions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhoneNumberCountryField(
phoneCountryCode: String,
onPhoneCountryCodeChange: (String) -> Unit,
phoneNationalNumber: String,
onPhoneNationalNumberChange: (String) -> Unit,
modifier: Modifier = Modifier,
countryLabel: String = "Country",
numberLabel: String = "Number",
countryWeight: Float = 0.35f,
numberWeight: Float = 0.65f
) {
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountrySearch = remember { mutableStateOf("") }
val phoneCountries = remember { phoneCountryOptions() }
val selectedCountry = findPhoneCountryOption(phoneCountryCode)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(countryWeight)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text(countryLabel) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
),
singleLine = true
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
val filtered = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) {
true
} else {
option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
}
filtered.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
onPhoneCountryCodeChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = phoneNationalNumber,
onValueChange = onPhoneNationalNumberChange,
label = { Text(numberLabel) },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(numberWeight),
singleLine = true
)
}
}

View File

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

View File

@@ -0,0 +1,396 @@
package com.android.trisolarispms.ui.guest
import android.app.DatePickerDialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GuestInfoFormFields(
phoneCountryCode: String,
onPhoneCountryCodeChange: (String) -> Unit,
phoneNationalNumber: String,
onPhoneNationalNumberChange: (String) -> Unit,
name: String,
onNameChange: (String) -> Unit,
nationality: String,
onNationalityChange: (String) -> Unit,
nationalitySuggestions: List<String>,
isNationalitySearchLoading: Boolean,
onNationalitySuggestionSelected: (String) -> Unit,
age: String,
onAgeChange: (String) -> Unit,
addressText: String,
onAddressChange: (String) -> Unit,
fromCity: String,
onFromCityChange: (String) -> Unit,
fromCitySuggestions: List<String>,
isFromCitySearchLoading: Boolean,
onFromCitySuggestionSelected: (String) -> Unit,
toCity: String,
onToCityChange: (String) -> Unit,
toCitySuggestions: List<String>,
isToCitySearchLoading: Boolean,
onToCitySuggestionSelected: (String) -> Unit,
memberRelation: String,
onMemberRelationChange: (String) -> Unit,
transportMode: String,
onTransportModeChange: (String) -> Unit,
childCount: String,
onChildCountChange: (String) -> Unit,
maleCount: String,
onMaleCountChange: (String) -> Unit,
femaleCount: String,
onFemaleCountChange: (String) -> Unit,
vehicleNumbers: List<String>
) {
val showDobPicker = remember { mutableStateOf(false) }
val nationalityMenuExpanded = remember { mutableStateOf(false) }
val relationMenuExpanded = remember { mutableStateOf(false) }
val transportMenuExpanded = remember { mutableStateOf(false) }
val dobFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val transportOptions = remember(vehicleNumbers) {
if (vehicleNumbers.isNotEmpty()) {
listOf("", "CAR", "BIKE")
} else {
BookingProfileOptions.transportModes
}
}
var dobFieldValue by remember {
mutableStateOf(TextFieldValue(text = age, selection = TextRange(age.length)))
}
LaunchedEffect(age) {
if (age != dobFieldValue.text) {
dobFieldValue = TextFieldValue(text = age, selection = TextRange(age.length))
}
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PhoneNumberCountryField(
phoneCountryCode = phoneCountryCode,
onPhoneCountryCodeChange = onPhoneCountryCodeChange,
phoneNationalNumber = phoneNationalNumber,
onPhoneNationalNumberChange = onPhoneNationalNumberChange,
numberLabel = "Phone (optional)"
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
ExposedDropdownMenuBox(
expanded = nationalityMenuExpanded.value &&
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
onExpandedChange = { nationalityMenuExpanded.value = it }
) {
OutlinedTextField(
value = nationality,
onValueChange = { value ->
onNationalityChange(value)
nationalityMenuExpanded.value = value.trim().length >= 3
},
label = { Text("Nationality (optional)") },
supportingText = {
if (nationality.trim().length < 3) {
Text("Type at least 3 letters")
}
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = nationalityMenuExpanded.value &&
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty())
)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
enabled = true
),
singleLine = true
)
ExposedDropdownMenu(
expanded = nationalityMenuExpanded.value &&
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
onDismissRequest = { nationalityMenuExpanded.value = false }
) {
if (isNationalitySearchLoading) {
DropdownMenuItem(
text = { Text("Searching...") },
onClick = {},
enabled = false
)
} else if (nationality.trim().length >= 3 && nationalitySuggestions.isEmpty()) {
DropdownMenuItem(
text = { Text("No countries found") },
onClick = {},
enabled = false
)
} else {
nationalitySuggestions.forEach { suggestion ->
DropdownMenuItem(
text = {
Text(suggestion)
},
onClick = {
nationalityMenuExpanded.value = false
onNationalitySuggestionSelected(suggestion)
}
)
}
}
}
}
OutlinedTextField(
value = dobFieldValue,
onValueChange = { input ->
val formatted = formatDobInput(input.text)
dobFieldValue = TextFieldValue(
text = formatted,
selection = TextRange(formatted.length)
)
if (formatted != age) onAgeChange(formatted)
},
label = { Text("DOB (dd/MM/yyyy)") },
trailingIcon = {
Icon(
imageVector = Icons.Default.CalendarMonth,
contentDescription = "Pick DOB",
modifier = Modifier.clickable { showDobPicker.value = true }
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = addressText,
onValueChange = onAddressChange,
label = { Text("Address (optional)") },
modifier = Modifier.fillMaxWidth()
)
CityAutocompleteField(
value = fromCity,
onValueChange = onFromCityChange,
label = "From City (optional)",
suggestions = fromCitySuggestions,
isLoading = isFromCitySearchLoading,
onSuggestionSelected = onFromCitySuggestionSelected
)
CityAutocompleteField(
value = toCity,
onValueChange = onToCityChange,
label = "To City (optional)",
suggestions = toCitySuggestions,
isLoading = isToCitySearchLoading,
onSuggestionSelected = onToCitySuggestionSelected
)
ExposedDropdownMenuBox(
expanded = relationMenuExpanded.value,
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
) {
OutlinedTextField(
value = memberRelation.ifBlank { "Not set" },
onValueChange = {},
readOnly = true,
label = { Text("Member Relation") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
)
)
ExposedDropdownMenu(
expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false }
) {
DropdownMenuItem(
text = { Text("Not set") },
onClick = {
relationMenuExpanded.value = false
onMemberRelationChange("")
}
)
BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
relationMenuExpanded.value = false
onMemberRelationChange(option)
}
)
}
}
}
ExposedDropdownMenuBox(
expanded = transportMenuExpanded.value,
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) {
OutlinedTextField(
value = transportMode.ifBlank { "Not set" },
onValueChange = {},
readOnly = true,
label = { Text("Transport Mode") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = transportMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
)
)
ExposedDropdownMenu(
expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false }
) {
transportOptions.forEach { option ->
DropdownMenuItem(
text = { Text(option.ifBlank { "Not set" }) },
onClick = {
transportMenuExpanded.value = false
onTransportModeChange(option)
}
)
}
}
}
OutlinedTextField(
value = childCount,
onValueChange = onChildCountChange,
label = { Text("Child Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = maleCount,
onValueChange = onMaleCountChange,
label = { Text("Male Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
singleLine = true
)
OutlinedTextField(
value = femaleCount,
onValueChange = onFemaleCountChange,
label = { Text("Female Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
singleLine = true
)
}
}
if (showDobPicker.value) {
val initialDate = age.toLocalDateOrNull(dobFormatter) ?: LocalDate.now().minusYears(18)
GuestDobDatePickerDialog(
initialDate = initialDate,
onDismiss = { showDobPicker.value = false },
onDateSelected = { selectedDate ->
onAgeChange(selectedDate.format(dobFormatter))
}
)
}
}
@Composable
private fun GuestDobDatePickerDialog(
initialDate: LocalDate,
onDismiss: () -> Unit,
onDateSelected: (LocalDate) -> Unit
) {
val context = LocalContext.current
val dismissState by rememberUpdatedState(onDismiss)
val selectState by rememberUpdatedState(onDateSelected)
DisposableEffect(context, initialDate) {
val dialog = DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
selectState(LocalDate.of(year, month + 1, dayOfMonth))
},
initialDate.year,
initialDate.monthValue - 1,
initialDate.dayOfMonth
)
val todayMillis = LocalDate.now()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val minDateMillis = LocalDate.of(1900, 1, 1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
dialog.datePicker.maxDate = todayMillis
dialog.datePicker.minDate = minDateMillis
dialog.setOnDismissListener { dismissState() }
dialog.show()
onDispose {
dialog.setOnDismissListener(null)
dialog.dismiss()
}
}
}
private fun String.toLocalDateOrNull(formatter: DateTimeFormatter): LocalDate? =
runCatching { LocalDate.parse(this, formatter) }.getOrNull()
private fun formatDobInput(raw: String): String {
val digits = raw.filter { it.isDigit() }.take(8)
if (digits.isEmpty()) return ""
val builder = StringBuilder(digits.length + 2)
digits.forEachIndexed { index, char ->
builder.append(char)
if ((index == 1 || index == 3) && index != digits.lastIndex) {
builder.append('/')
}
}
return builder.toString()
}

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable @Composable
fun GuestSignatureScreen( fun GuestSignatureScreen(
propertyId: String, propertyId: String,
bookingId: String,
guestId: String, guestId: String,
onBack: () -> Unit, onBack: () -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
@@ -68,7 +69,7 @@ fun GuestSignatureScreen(
onClick = { onClick = {
val svg = buildSignatureSvg(strokes, canvasSize.value) val svg = buildSignatureSvg(strokes, canvasSize.value)
if (!svg.isNullOrBlank()) { if (!svg.isNullOrBlank()) {
viewModel.uploadSignature(propertyId, guestId, svg, onDone) viewModel.uploadSignature(propertyId, bookingId, guestId, svg, onDone)
} }
}, },
enabled = strokes.isNotEmpty() && !state.isLoading enabled = strokes.isNotEmpty() && !state.isLoading

View File

@@ -1,8 +1,11 @@
package com.android.trisolarispms.ui.guest 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.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -10,15 +13,26 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
class GuestSignatureViewModel : ViewModel() { class GuestSignatureViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(GuestSignatureState()) private val _state = MutableStateFlow(GuestSignatureState())
val state: StateFlow<GuestSignatureState> = _state val state: StateFlow<GuestSignatureState> = _state
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
fun reset() { fun reset() {
_state.value = GuestSignatureState() _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 if (propertyId.isBlank() || guestId.isBlank()) return
launchRequest( launchRequest(
state = _state, state = _state,
@@ -35,6 +49,12 @@ class GuestSignatureViewModel : ViewModel() {
) )
val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part) val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part)
if (response.isSuccessful) { if (response.isSuccessful) {
if (bookingId.isNotBlank()) {
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
)
}
_state.update { it.copy(isLoading = false, error = null) } _state.update { it.copy(isLoading = false, error = null) }
onDone() onDone()
} else { } else {

View File

@@ -1,16 +1,21 @@
package com.android.trisolarispms.ui.guestdocs package com.android.trisolarispms.ui.guestdocs
import android.app.Application
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants import com.android.trisolarispms.data.api.core.ApiConstants
import com.android.trisolarispms.data.api.model.GuestDocumentDto 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 com.google.gson.Gson
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -24,53 +29,47 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.Locale import java.util.Locale
class GuestDocumentsViewModel : ViewModel() { class GuestDocumentsViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(GuestDocumentsState()) private val _state = MutableStateFlow(GuestDocumentsState())
val state: StateFlow<GuestDocumentsState> = _state val state: StateFlow<GuestDocumentsState> = _state
private val gson = Gson() private val gson = Gson()
private val repository = GuestDocumentRepository(
dao = LocalDatabaseProvider.get(application).guestDocumentCacheDao()
)
private var eventSource: EventSource? = null private var eventSource: EventSource? = null
private var streamKey: String? = null private var streamKey: String? = null
private var observeKey: String? = null
private var observeJob: Job? = null
fun load(propertyId: String, guestId: String) { fun load(propertyId: String, guestId: String) {
if (propertyId.isBlank() || guestId.isBlank()) return
observeCache(propertyId = propertyId, guestId = guestId)
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
try { val result = repository.refresh(propertyId = propertyId, guestId = guestId)
val api = ApiClient.create() _state.update {
val response = api.listGuestDocuments(propertyId, guestId) it.copy(
val body = response.body() isLoading = false,
if (response.isSuccessful && body != null) { error = result.exceptionOrNull()?.localizedMessage
_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"
)
}
} }
} }
} }
fun startStream(propertyId: String, guestId: String) { fun startStream(propertyId: String, guestId: String) {
if (propertyId.isBlank() || guestId.isBlank()) return
observeCache(propertyId = propertyId, guestId = guestId)
val key = "$propertyId:$guestId" val key = "$propertyId:$guestId"
if (streamKey == key && eventSource != null) return if (streamKey == key && eventSource != null) return
stopStream() stopStream()
streamKey = key 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 client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
val url = "${ApiConstants.BASE_URL}properties/$propertyId/guests/$guestId/documents/stream" val url = "${ApiConstants.BASE_URL}properties/$propertyId/guests/$guestId/documents/stream"
val request = Request.Builder().url(url).get().build() val request = Request.Builder().url(url).get().build()
@@ -91,19 +90,16 @@ class GuestDocumentsViewModel : ViewModel() {
data: String data: String
) { ) {
if (data.isBlank() || type == "ping") return if (data.isBlank() || type == "ping") return
val docs = try { val docs = runCatching {
gson.fromJson(data, Array<GuestDocumentDto>::class.java)?.toList() gson.fromJson(data, Array<GuestDocumentDto>::class.java)?.toList()
} catch (_: Exception) { }.getOrNull() ?: return
null viewModelScope.launch {
} repository.storeSnapshot(
if (docs != null) { propertyId = propertyId,
_state.update { guestId = guestId,
it.copy( documents = docs
isLoading = false, )
documents = docs, _state.update { current -> current.copy(isLoading = false, error = null) }
error = null
)
}
} }
} }
@@ -126,7 +122,6 @@ class GuestDocumentsViewModel : ViewModel() {
} }
} }
) )
_state.update { it.copy(isLoading = false) }
} }
fun stopStream() { fun stopStream() {
@@ -166,49 +161,43 @@ class GuestDocumentsViewModel : ViewModel() {
} }
val filename = resolveFileName(resolver, uri) ?: "document" val filename = resolveFileName(resolver, uri) ?: "document"
val file = copyToCache(resolver, uri, context.cacheDir, filename) 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) { } catch (e: Exception) {
errorMessage = e.localizedMessage ?: "Upload failed" errorMessage = e.localizedMessage ?: "Upload failed"
} }
} }
_state.update { current -> _state.update {
current.copy( it.copy(
isUploading = false, isUploading = false,
error = errorMessage ?: current.error error = errorMessage
) )
} }
} }
} }
fun deleteDocument(propertyId: String, guestId: String, documentId: String) { fun deleteDocument(propertyId: String, guestId: String, documentId: String) {
if (propertyId.isBlank() || guestId.isBlank() || documentId.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
try { try {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.deleteGuestDocument(propertyId, guestId, documentId) val response = api.deleteGuestDocument(propertyId, guestId, documentId)
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { current -> val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId)
current.copy(
isLoading = false,
error = null,
documents = current.documents.filterNot { it.id == documentId }
)
}
} else {
_state.update { _state.update {
it.copy( it.copy(
isLoading = false, 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) { } catch (e: Exception) {
_state.update { _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Delete failed"
)
}
} }
} }
} }
@@ -222,15 +211,12 @@ class GuestDocumentsViewModel : ViewModel() {
) { ) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) } _state.update { it.copy(isUploading = true, error = null) }
try { val uploadError = uploadFile(propertyId, guestId, bookingId, file, mimeType)
uploadFile(propertyId, guestId, bookingId, file, mimeType) _state.update {
} catch (e: Exception) { it.copy(
_state.update { isUploading = false,
it.copy( error = uploadError
isUploading = false, )
error = e.localizedMessage ?: "Upload failed"
)
}
} }
} }
} }
@@ -241,7 +227,7 @@ class GuestDocumentsViewModel : ViewModel() {
bookingId: String, bookingId: String,
file: File, file: File,
mimeType: String mimeType: String
) { ): String? {
val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody) val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val api = ApiClient.create() val api = ApiClient.create()
@@ -251,22 +237,29 @@ class GuestDocumentsViewModel : ViewModel() {
bookingId = bookingId, bookingId = bookingId,
file = part file = part
) )
val body = response.body() if (response.isSuccessful && response.body() != null) {
if (response.isSuccessful && body != null) { val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId)
_state.update { it.copy(isUploading = false, error = null) } return refreshResult.exceptionOrNull()?.localizedMessage
} else if (response.code() == 409) { }
_state.update { if (response.code() == 409) {
it.copy( return "Duplicate document"
isUploading = false, }
error = "Duplicate document" return "Upload failed: ${response.code()}"
) }
}
} else { private fun observeCache(propertyId: String, guestId: String) {
_state.update { val key = "$propertyId:$guestId"
it.copy( if (observeKey == key && observeJob?.isActive == true) return
isUploading = false, observeJob?.cancel()
error = "Upload failed: ${response.code()}" 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() { override fun onCleared() {
super.onCleared() super.onCleared()
observeJob?.cancel()
stopStream() stopStream()
} }
} }

View File

@@ -4,6 +4,11 @@ sealed interface AppRoute {
data object Home : AppRoute data object Home : AppRoute
data class CreateBooking(val propertyId: String) : AppRoute data class CreateBooking(val propertyId: String) : AppRoute
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class GuestInfoFromBookingDetails(
val propertyId: String,
val bookingId: String,
val guestId: String
) : AppRoute
data class GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute data class GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class ManageRoomStaySelect( data class ManageRoomStaySelect(
val propertyId: String, val propertyId: String,
@@ -31,17 +36,17 @@ sealed interface AppRoute {
val fromAt: String, val fromAt: String,
val toAt: String? val toAt: String?
) : AppRoute ) : AppRoute
data class BookingRoomRequestFromBooking(
val propertyId: String,
val bookingId: String,
val guestId: String,
val fromAt: String,
val toAt: String
) : AppRoute
data class BookingRoomStays( data class BookingRoomStays(
val propertyId: String, val propertyId: String,
val bookingId: String val bookingId: String
) : AppRoute ) : AppRoute
data class BookingExpectedDates(
val propertyId: String,
val bookingId: String,
val status: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
) : AppRoute
data class BookingDetailsTabs( data class BookingDetailsTabs(
val propertyId: String, val propertyId: String,
val bookingId: String, val bookingId: String,

View File

@@ -50,7 +50,13 @@ internal fun handleBackNavigation(
null null
) )
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId) is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
is AppRoute.GuestInfoFromBookingDetails -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo( is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
currentRoute.propertyId, currentRoute.propertyId,
currentRoute.bookingId, currentRoute.bookingId,
@@ -72,7 +78,6 @@ internal fun handleBackNavigation(
currentRoute.toAt currentRoute.toAt
) )
is AppRoute.BookingRoomStays -> refs.openActiveRoomStays(currentRoute.propertyId) is AppRoute.BookingRoomStays -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingExpectedDates -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingDetailsTabs -> refs.openActiveRoomStays(currentRoute.propertyId) is AppRoute.BookingDetailsTabs -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingPayments -> refs.route.value = AppRoute.BookingDetailsTabs( is AppRoute.BookingPayments -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId, currentRoute.propertyId,

View File

@@ -2,8 +2,8 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.AuthzPolicy import com.android.trisolarispms.core.auth.AuthzPolicy
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.navigation.AppRoute import com.android.trisolarispms.ui.navigation.AppRoute
import com.android.trisolarispms.ui.booking.BookingExpectedDatesScreen
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
@@ -17,31 +17,20 @@ internal fun renderBookingRoutes(
is AppRoute.BookingRoomStays -> BookingRoomStaysScreen( is AppRoute.BookingRoomStays -> BookingRoomStaysScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId),
onBack = { refs.openActiveRoomStays(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( is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }, onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onEditCheckout = { expectedCheckInAt, expectedCheckOutAt -> onEditGuestInfo = { targetGuestId ->
refs.route.value = AppRoute.BookingExpectedDates( refs.route.value = AppRoute.GuestInfoFromBookingDetails(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
status = "CHECKED_IN", guestId = targetGuestId
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt
) )
}, },
onEditSignature = { guestId -> onEditSignature = { guestId ->
@@ -65,7 +54,31 @@ internal fun renderBookingRoutes(
bookingId = currentRoute.bookingId 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( is AppRoute.BookingPayments -> BookingPaymentsScreen(

View File

@@ -2,10 +2,12 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.Role 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.navigation.AppRoute
import com.android.trisolarispms.ui.auth.AuthUiState import com.android.trisolarispms.ui.auth.AuthUiState
import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.booking.BookingCreateScreen 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.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.home.HomeScreen
@@ -70,21 +72,55 @@ internal fun renderHomeGuestRoutes(
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() } val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
?: response.expectedCheckInAt.orEmpty() ?: response.expectedCheckInAt.orEmpty()
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() } val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking( if (isFutureBookingCheckIn(response.expectedCheckInAt)) {
propertyId = currentRoute.propertyId, if (fromAt.isNotBlank() && !toAt.isNullOrBlank()) {
bookingId = bookingId, refs.route.value = AppRoute.BookingRoomRequestFromBooking(
guestId = guestId, propertyId = currentRoute.propertyId,
fromAt = fromAt, bookingId = bookingId,
toAt = toAt 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 { } else {
refs.route.value = AppRoute.Home 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( is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,
initialGuest = refs.selectedGuest.value, initialGuest = refs.selectedGuest.value,
initialPhone = refs.selectedGuestPhone.value, initialPhone = refs.selectedGuestPhone.value,
@@ -100,6 +136,7 @@ internal fun renderHomeGuestRoutes(
is AppRoute.GuestSignature -> GuestSignatureScreen( is AppRoute.GuestSignature -> GuestSignatureScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,
onBack = { onBack = {
refs.route.value = AppRoute.GuestInfo( refs.route.value = AppRoute.GuestInfo(

View File

@@ -1,40 +1,43 @@
package com.android.trisolarispms.ui.payment package com.android.trisolarispms.ui.payment
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RazorpayRefundRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.Response import retrofit2.Response
class BookingPaymentsViewModel : ViewModel() { class BookingPaymentsViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingPaymentsState()) private val _state = MutableStateFlow(BookingPaymentsState())
val state: StateFlow<BookingPaymentsState> = _state 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) { fun load(propertyId: String, bookingId: String) {
runPaymentAction(defaultError = "Load failed") { api -> if (propertyId.isBlank() || bookingId.isBlank()) return
val paymentsResponse = api.listPayments(propertyId, bookingId) observeCaches(propertyId = propertyId, bookingId = bookingId)
val payments = paymentsResponse.body() runPaymentAction(defaultError = "Load failed") {
if (paymentsResponse.isSuccessful && payments != null) { refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
val pending = api.getBookingBalance(propertyId, bookingId) _state.update { it.copy(isLoading = false, error = null, message = null) }
.body()
?.pending
_state.update {
it.copy(
isLoading = false,
payments = payments,
pendingBalance = pending,
error = null,
message = null
)
}
} else {
setActionFailure("Load", paymentsResponse)
}
} }
} }
@@ -58,12 +61,10 @@ class BookingPaymentsViewModel : ViewModel() {
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
_state.update { current -> refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
val nextPending = latestPending?.let { (it - amount).coerceAtLeast(0L) } _state.update {
current.copy( it.copy(
isLoading = false, isLoading = false,
payments = listOf(body) + current.payments,
pendingBalance = nextPending ?: current.pendingBalance,
error = null, error = null,
message = "Cash payment added" message = "Cash payment added"
) )
@@ -76,24 +77,16 @@ class BookingPaymentsViewModel : ViewModel() {
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) { fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
runPaymentAction(defaultError = "Delete failed") { api -> runPaymentAction(defaultError = "Delete failed") { api ->
val payment = _state.value.payments.firstOrNull { it.id == paymentId }
val response = api.deletePayment( val response = api.deletePayment(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId, bookingId = bookingId,
paymentId = paymentId paymentId = paymentId
) )
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { current -> refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
val restoredPending = if (payment?.method == "CASH") { _state.update {
val amount = payment.amount ?: 0L it.copy(
current.pendingBalance?.plus(amount)
} else {
current.pendingBalance
}
current.copy(
isLoading = false, isLoading = false,
payments = current.payments.filterNot { it.id == paymentId },
pendingBalance = restoredPending,
error = null, error = null,
message = "Cash payment deleted" message = "Cash payment deleted"
) )
@@ -133,7 +126,7 @@ class BookingPaymentsViewModel : ViewModel() {
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
load(propertyId, bookingId) refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update { _state.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@@ -183,4 +176,46 @@ class BookingPaymentsViewModel : ViewModel() {
if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)" if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)"
return null 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()
}
} }

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.ui.razorpay package com.android.trisolarispms.ui.razorpay
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants 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.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest
import com.android.trisolarispms.data.api.model.RazorpayQrRequest 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 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.Request
import okhttp3.sse.EventSource import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources 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 gson = Gson()
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
RazorpayQrState(deviceInfo = buildDeviceInfo()) RazorpayQrState(deviceInfo = buildDeviceInfo())
) )
val state: StateFlow<RazorpayQrState> = _state 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 qrEventSource: EventSource? = null
private var lastQrId: String? = null private var lastQrId: String? = null
private var qrPollJob: Job? = 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() { fun reset() {
stopQrEventStream() stopQrEventStream()
stopQrEventPolling() stopQrEventPolling()
stopRequestObservation()
stopQrEventObservation()
_state.value = RazorpayQrState(deviceInfo = buildDeviceInfo()) _state.value = RazorpayQrState(deviceInfo = buildDeviceInfo())
} }
fun exitQrView() { fun exitQrView() {
stopQrEventStream() stopQrEventStream()
stopQrEventPolling() stopQrEventPolling()
stopQrEventObservation()
_state.update { _state.update {
it.copy( it.copy(
qrId = null, qrId = null,
@@ -96,10 +120,11 @@ class RazorpayQrViewModel : ViewModel() {
currency = body.currency, currency = body.currency,
imageUrl = body.imageUrl, imageUrl = body.imageUrl,
isClosed = false, isClosed = false,
isCredited = false,
error = null error = null
) )
} }
loadQrList(propertyId, bookingId) refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
startQrEventStream(propertyId, bookingId, body.qrId) startQrEventStream(propertyId, bookingId, body.qrId)
} else { } else {
_state.update { _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) { fun loadQrList(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
observeRequestCache(propertyId = propertyId, bookingId = bookingId)
viewModelScope.launch { viewModelScope.launch {
try { refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
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
}
} }
} }
@@ -256,10 +165,7 @@ class RazorpayQrViewModel : ViewModel() {
body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId) body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId)
) )
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { current -> refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
current.copy(qrList = current.qrList.filterNot { it.requestId == item.requestId })
}
loadQrList(propertyId, bookingId)
} }
} catch (_: Exception) { } catch (_: Exception) {
// ignore close errors // ignore close errors
@@ -313,7 +219,7 @@ class RazorpayQrViewModel : ViewModel() {
error = null error = null
) )
} }
loadQrList(propertyId, bookingId) refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
} else { } else {
_state.update { _state.update {
it.copy( 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 { private fun buildDeviceInfo(): String {
val release = android.os.Build.VERSION.RELEASE val release = android.os.Build.VERSION.RELEASE
val model = android.os.Build.MODEL val model = android.os.Build.MODEL
@@ -342,5 +442,8 @@ class RazorpayQrViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
stopQrEventStream() stopQrEventStream()
stopQrEventPolling()
stopRequestObservation()
stopQrEventObservation()
} }
} }

View File

@@ -1,5 +1,6 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.foundation.combinedClickable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.People
import androidx.compose.material.icons.filled.MeetingRoom import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Payment 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.CardDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -63,6 +68,11 @@ fun ActiveRoomStaysScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) } val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
val menuExpanded = remember { mutableStateOf(false) }
BackHandler(enabled = state.showOpenBookings) {
viewModel.hideOpenBookings()
}
LaunchedEffect(propertyId) { LaunchedEffect(propertyId) {
viewModel.load(propertyId) viewModel.load(propertyId)
@@ -70,23 +80,63 @@ fun ActiveRoomStaysScreen(
BackTopBarScaffold( BackTopBarScaffold(
title = propertyName, title = propertyName,
onBack = onBack, onBack = {
if (state.showOpenBookings) {
viewModel.hideOpenBookings()
} else {
onBack()
}
},
showBack = showBack, showBack = showBack,
actions = { actions = {
IconButton(onClick = onViewRooms) { IconButton(onClick = onViewRooms) {
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
} }
IconButton(onClick = onOpenSettings) { IconButton(onClick = viewModel::toggleShowOpenBookings) {
Icon(Icons.Default.Settings, contentDescription = "Settings") Icon(
Icons.Default.CalendarMonth,
contentDescription = "Show Open Bookings",
tint = if (state.showOpenBookings) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
} }
if (showRazorpaySettings) { IconButton(onClick = { menuExpanded.value = true }) {
IconButton(onClick = onRazorpaySettings) { Icon(Icons.Default.MoreVert, contentDescription = "Menu")
Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings") }
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) {
if (showUserAdmin) { DropdownMenuItem(
IconButton(onClick = onUserAdmin) { text = { Text("Property Users") },
Icon(Icons.Default.People, contentDescription = "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)) Spacer(modifier = Modifier.height(8.dp))
} }
state.error?.let { state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error) Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
if (!state.isLoading && state.error == null) { if (!state.isLoading && state.error == null) {
if (state.checkedInBookings.isNotEmpty()) { val shownBookings = if (state.showOpenBookings) {
Text(text = "Checked-in bookings", style = MaterialTheme.typography.titleMedium) 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)) Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
@@ -121,7 +181,7 @@ fun ActiveRoomStaysScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(state.checkedInBookings) { booking -> items(shownBookings) { booking ->
CheckedInBookingCard( CheckedInBookingCard(
booking = booking, booking = booking,
onClick = { onOpenBookingDetails(booking) }) onClick = { onOpenBookingDetails(booking) })
@@ -129,7 +189,12 @@ fun ActiveRoomStaysScreen(
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} else { } 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 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 expectedCount = booking.expectedGuestCount
val totalCount = booking.totalGuestCount val totalCount = booking.totalGuestCount
val countLine = when { val countLine = when {
@@ -216,6 +277,14 @@ private fun CheckedInBookingCard(
color = MaterialTheme.colorScheme.error 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 checkInAt = booking.checkInAt?.takeIf { it.isNotBlank() }
val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() } val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() }
if (checkInAt != null && checkOutAt != null) { if (checkInAt != null && checkOutAt != null) {

View File

@@ -7,5 +7,7 @@ data class ActiveRoomStaysState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val items: List<ActiveRoomStayDto> = emptyList(), val items: List<ActiveRoomStayDto> = emptyList(),
val checkedInBookings: List<BookingListItem> = emptyList() val checkedInBookings: List<BookingListItem> = emptyList(),
val openBookings: List<BookingListItem> = emptyList(),
val showOpenBookings: Boolean = false
) )

View File

@@ -1,38 +1,117 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel import android.app.Application
import com.android.trisolarispms.data.api.core.ApiClient import androidx.lifecycle.AndroidViewModel
import com.android.trisolarispms.core.viewmodel.launchRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ActiveRoomStaysViewModel : ViewModel() { class ActiveRoomStaysViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(ActiveRoomStaysState()) private val _state = MutableStateFlow(ActiveRoomStaysState())
val state: StateFlow<ActiveRoomStaysState> = _state 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) { fun load(propertyId: String) {
if (propertyId.isBlank()) return if (propertyId.isBlank()) return
launchRequest( observeCache(propertyId = propertyId)
state = _state, observeBookingLists(propertyId = propertyId)
setLoading = { it.copy(isLoading = true, error = null) }, _state.update { it.copy(isLoading = true, error = null) }
setError = { current, message -> current.copy(isLoading = false, error = message) }, viewModelScope.launch {
defaultError = "Load failed" val activeResult = repository.refresh(propertyId = propertyId)
) { val checkedInResult = bookingListRepository.refreshByStatus(
val api = ApiClient.create() propertyId = propertyId,
val activeResponse = api.listActiveRoomStays(propertyId) status = "CHECKED_IN"
val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN") )
if (activeResponse.isSuccessful) { val openResult = bookingListRepository.refreshByStatus(
_state.update { propertyId = propertyId,
it.copy( status = "OPEN"
isLoading = false, )
items = activeResponse.body().orEmpty(), val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
checkedInBookings = bookingsResponse.body().orEmpty(), ?: checkedInResult.exceptionOrNull()?.localizedMessage
error = null ?: 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) }
} }
} }
} }

View File

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

View File

@@ -18,12 +18,17 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Edit 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.QrCode
import androidx.compose.material.icons.filled.ReceiptLong import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -32,11 +37,13 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment 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.core.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingDetailsResponse 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.common.BackTopBarScaffold
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
@@ -72,11 +84,13 @@ fun BookingDetailsTabsScreen(
bookingId: String, bookingId: String,
guestId: String?, guestId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onEditCheckout: (String?, String?) -> Unit, onEditGuestInfo: (String) -> Unit,
onEditSignature: (String) -> Unit, onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit, onOpenPayments: () -> Unit,
canManageDocuments: Boolean, canManageDocuments: Boolean,
canCheckOutRoomStay: Boolean,
canCheckOutBooking: Boolean,
staysViewModel: BookingRoomStaysViewModel = viewModel(), staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel() detailsViewModel: BookingDetailsViewModel = viewModel()
) { ) {
@@ -85,10 +99,22 @@ fun BookingDetailsTabsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val staysState by staysViewModel.state.collectAsState() val staysState by staysViewModel.state.collectAsState()
val detailsState by detailsViewModel.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) { val canModifyDocuments = canManageDocuments && when (detailsState.details?.status) {
"OPEN", "CHECKED_IN" -> true "OPEN", "CHECKED_IN" -> true
else -> false 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) { LaunchedEffect(propertyId, bookingId, guestId) {
staysViewModel.load(propertyId, bookingId) staysViewModel.load(propertyId, bookingId)
@@ -101,7 +127,48 @@ fun BookingDetailsTabsScreen(
BackTopBarScaffold( BackTopBarScaffold(
title = "Details", 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 -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -144,12 +211,27 @@ fun BookingDetailsTabsScreen(
guestId = guestId, guestId = guestId,
isLoading = detailsState.isLoading, isLoading = detailsState.isLoading,
error = detailsState.error, error = detailsState.error,
onEditCheckout = onEditCheckout, onEditGuestInfo = onEditGuestInfo,
onEditSignature = onEditSignature, onEditSignature = onEditSignature,
onOpenRazorpayQr = onOpenRazorpayQr, 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) { 2 -> if (canManageDocuments) {
val resolvedGuestId = detailsState.details?.guestId ?: guestId val resolvedGuestId = detailsState.details?.guestId ?: guestId
if (!resolvedGuestId.isNullOrBlank()) { 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 @Composable
@@ -182,14 +399,58 @@ private fun GuestInfoTabContent(
guestId: String?, guestId: String?,
isLoading: Boolean, isLoading: Boolean,
error: String?, error: String?,
onEditCheckout: (String?, String?) -> Unit, onEditGuestInfo: (String) -> Unit,
onEditSignature: (String) -> Unit, onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, 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 displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") } 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( Column(
modifier = Modifier modifier = Modifier
@@ -205,7 +466,23 @@ private fun GuestInfoTabContent(
Text(text = it, color = MaterialTheme.colorScheme.error) Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
SectionCard(title = "Details") { SectionCard(
title = "Details",
headerContent = {
IconButton(
enabled = !resolvedGuestId.isNullOrBlank(),
onClick = {
resolvedGuestId?.let(onEditGuestInfo)
}
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit guest details",
tint = MaterialTheme.colorScheme.primary
)
}
}
) {
GuestDetailRow(label = "Name", value = details?.guestName) GuestDetailRow(label = "Name", value = details?.guestName)
GuestDetailRow(label = "Nationality", value = details?.guestNationality) GuestDetailRow(label = "Nationality", value = details?.guestNationality)
GuestDetailRow(label = "Age", value = details?.guestAge?.let { formatAge(it) }) GuestDetailRow(label = "Age", value = details?.guestAge?.let { formatAge(it) })
@@ -221,37 +498,42 @@ private fun GuestInfoTabContent(
GuestDetailRow(label = "Mode of transport", value = details?.transportMode) GuestDetailRow(label = "Mode of transport", value = details?.transportMode)
} }
val checkIn = details?.checkInAt ?: details?.expectedCheckInAt
val checkOut = details?.expectedCheckOutAt ?: details?.checkOutAt
SectionCard(title = "Stay") { SectionCard(title = "Stay") {
if (!checkIn.isNullOrBlank()) { val parsedCheckIn = draftCheckInAt.value
val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull() val parsedCheckOut = draftCheckOutAt.value
GuestDetailRow( val zonedCheckIn = parsedCheckIn?.atZoneSameInstant(displayZone)
label = "Check In Time", val zonedCheckOut = parsedCheckOut?.atZoneSameInstant(displayZone)
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" } 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
}
) )
} updateDatesError.value?.let { message ->
if (!checkOut.isNullOrBlank()) { Spacer(modifier = Modifier.height(8.dp))
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull() Text(
Row( text = message,
modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.error,
horizontalArrangement = Arrangement.SpaceBetween, style = MaterialTheme.typography.bodySmall
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")
}
} }
Spacer(modifier = Modifier.height(8.dp))
} }
val billingMode = BookingBillingMode.from(details?.billingMode) val billingMode = BookingBillingMode.from(details?.billingMode)
GuestDetailRow(label = "Billing Mode", value = details?.billingMode) GuestDetailRow(label = "Billing Mode", value = details?.billingMode)
@@ -317,7 +599,6 @@ private fun GuestInfoTabContent(
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val resolvedGuestId = details?.guestId ?: guestId
SignaturePreview( SignaturePreview(
propertyId = propertyId, propertyId = propertyId,
guestId = resolvedGuestId, guestId = resolvedGuestId,
@@ -325,6 +606,106 @@ private fun GuestInfoTabContent(
onEditSignature = onEditSignature 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 @Composable
@@ -457,8 +838,11 @@ private fun SignaturePreview(
@Composable @Composable
private fun BookingRoomStaysTabContent( private fun BookingRoomStaysTabContent(
propertyId: String,
bookingId: String,
state: BookingRoomStaysState, state: BookingRoomStaysState,
viewModel: BookingRoomStaysViewModel viewModel: BookingRoomStaysViewModel,
canCheckOutRoomStay: Boolean
) { ) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") } val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") }
@@ -467,50 +851,31 @@ private fun BookingRoomStaysTabContent(
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(16.dp)
) { ) {
Row( RoomStayListSection(
modifier = Modifier.fillMaxWidth(), state = state,
horizontalArrangement = Arrangement.SpaceBetween, canCheckOutRoomStay = canCheckOutRoomStay,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically onToggleShowAll = viewModel::toggleShowAll,
) { onCheckoutRoomStay = { roomStayId ->
Text(text = "Show all (including checkout)") viewModel.checkoutRoomStay(
androidx.compose.material3.Switch( propertyId = propertyId,
checked = state.showAll, bookingId = bookingId,
onCheckedChange = viewModel::toggleShowAll roomStayId = roomStayId
) )
} },
Spacer(modifier = Modifier.height(12.dp)) formatTimeLine = { stay ->
if (state.isLoading) { val fromAt = stay.fromAt?.let {
CircularProgressIndicator() runCatching {
Spacer(modifier = Modifier.height(8.dp)) OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
} }.getOrNull()
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))
} }
} val toAt = stay.expectedCheckoutAt?.let {
} runCatching {
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
}.getOrNull()
}
listOfNotNull(fromAt, toAt).joinToString("").ifBlank { null }
},
showGuestLine = false
)
} }
} }

View File

@@ -1,12 +1,19 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants 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.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 com.google.gson.Gson
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -17,13 +24,25 @@ import okhttp3.Request
import okhttp3.sse.EventSource import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources 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()) private val _state = MutableStateFlow(BookingDetailsState())
val state: StateFlow<BookingDetailsState> = _state val state: StateFlow<BookingDetailsState> = _state
private val gson = Gson() 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 eventSource: EventSource? = null
private var streamKey: String? = null private var streamKey: String? = null
private var observeKey: String? = null
private var observeJob: Job? = null
private var lastPropertyId: String? = null private var lastPropertyId: String? = null
private var lastBookingId: String? = null private var lastBookingId: String? = null
private var retryJob: Job? = null private var retryJob: Job? = null
@@ -31,25 +50,19 @@ class BookingDetailsViewModel : ViewModel() {
fun load(propertyId: String, bookingId: String) { fun load(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return if (propertyId.isBlank() || bookingId.isBlank()) return
launchRequest( observeCache(propertyId = propertyId, bookingId = bookingId)
state = _state, _state.update { it.copy(isLoading = true, error = null) }
setLoading = { it.copy(isLoading = true, error = null) }, viewModelScope.launch {
setError = { current, message -> current.copy(isLoading = false, error = message) }, val result = bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId)
defaultError = "Load failed" result.fold(
) { onSuccess = {
val api = ApiClient.create() _state.update { current -> current.copy(isLoading = false, error = null) }
val response = api.getBookingDetails(propertyId, bookingId) },
if (response.isSuccessful) { onFailure = { throwable ->
_state.update { val message = throwable.localizedMessage ?: "Load failed"
it.copy( _state.update { current -> current.copy(isLoading = false, error = message) }
isLoading = false,
details = response.body(),
error = null
)
} }
} else { )
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} }
} }
@@ -80,8 +93,15 @@ class BookingDetailsViewModel : ViewModel() {
null null
} }
if (details != null) { if (details != null) {
_state.update { it.copy(isLoading = false, details = details, error = null) } viewModelScope.launch {
retryDelayMs = 2000 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 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() { override fun onCleared() {
super.onCleared() super.onCleared()
observeJob?.cancel()
stopStream() 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() { private fun scheduleReconnect() {
val propertyId = lastPropertyId ?: return val propertyId = lastPropertyId ?: return
val bookingId = lastBookingId ?: return val bookingId = lastBookingId ?: return
@@ -125,4 +233,12 @@ class BookingDetailsViewModel : ViewModel() {
startStream(propertyId, bookingId) startStream(propertyId, bookingId)
} }
} }
private suspend fun syncBookingCaches(propertyId: String, bookingId: String) {
roomStayRepository.refresh(propertyId = propertyId).getOrThrow()
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
).getOrThrow()
}
} }

View File

@@ -1,22 +1,12 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -26,6 +16,7 @@ import com.android.trisolarispms.ui.common.BackTopBarScaffold
fun BookingRoomStaysScreen( fun BookingRoomStaysScreen(
propertyId: String, propertyId: String,
bookingId: String, bookingId: String,
canCheckOutRoomStay: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: BookingRoomStaysViewModel = viewModel() viewModel: BookingRoomStaysViewModel = viewModel()
) { ) {
@@ -45,45 +36,24 @@ fun BookingRoomStaysScreen(
.padding(padding) .padding(padding)
.padding(16.dp) .padding(16.dp)
) { ) {
Row( RoomStayListSection(
modifier = Modifier.fillMaxWidth(), state = state,
horizontalArrangement = Arrangement.SpaceBetween, canCheckOutRoomStay = canCheckOutRoomStay,
verticalAlignment = Alignment.CenterVertically onToggleShowAll = viewModel::toggleShowAll,
) { onCheckoutRoomStay = { roomStayId ->
Text(text = "Show all (including checkout)") viewModel.checkoutRoomStay(
Switch( propertyId = propertyId,
checked = state.showAll, bookingId = bookingId,
onCheckedChange = viewModel::toggleShowAll roomStayId = roomStayId
) )
} },
Spacer(modifier = Modifier.height(12.dp)) formatTimeLine = { stay ->
if (state.isLoading) { listOfNotNull(stay.fromAt, stay.expectedCheckoutAt)
CircularProgressIndicator() .joinToString("")
Spacer(modifier = Modifier.height(8.dp)) .ifBlank { null }
} },
state.error?.let { showGuestLine = true
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))
}
}
}
} }
} }
} }

View File

@@ -6,5 +6,9 @@ data class BookingRoomStaysState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val stays: List<ActiveRoomStayDto> = emptyList(), 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()
) )

View File

@@ -1,15 +1,33 @@
package com.android.trisolarispms.ui.roomstay 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.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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingRoomStaysViewModel : ViewModel() { class BookingRoomStaysViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingRoomStaysState()) private val _state = MutableStateFlow(BookingRoomStaysState())
val state: StateFlow<BookingRoomStaysState> = _state 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) { fun toggleShowAll(value: Boolean) {
_state.update { it.copy(showAll = value) } _state.update { it.copy(showAll = value) }
@@ -17,26 +35,147 @@ class BookingRoomStaysViewModel : ViewModel() {
fun load(propertyId: String, bookingId: String) { fun load(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return if (propertyId.isBlank() || bookingId.isBlank()) return
launchRequest( observeBookingCache(propertyId = propertyId, bookingId = bookingId)
state = _state, _state.update { it.copy(isLoading = true, error = null) }
setLoading = { it.copy(isLoading = true, error = null) }, viewModelScope.launch {
setError = { current, message -> current.copy(isLoading = false, error = message) }, refreshForBooking(propertyId = propertyId, bookingId = bookingId)
defaultError = "Load failed" }
) { }
val api = ApiClient.create()
val response = api.listActiveRoomStays(propertyId) fun checkoutRoomStay(
if (response.isSuccessful) { propertyId: String,
val filtered = response.body().orEmpty().filter { it.bookingId == bookingId } bookingId: String,
_state.update { roomStayId: String
it.copy( ) {
isLoading = false, if (propertyId.isBlank() || bookingId.isBlank() || roomStayId.isBlank()) return
stays = filtered, if (_state.value.checkingOutRoomStayId != null) return
error = null 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
)
}
} }

View File

@@ -1,17 +1,29 @@
package com.android.trisolarispms.ui.roomstay 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.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingBulkCheckInStayRequest import com.android.trisolarispms.data.api.model.BookingBulkCheckInStayRequest
import com.android.trisolarispms.core.viewmodel.launchRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
class ManageRoomStayRatesViewModel : ViewModel() { class ManageRoomStayRatesViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(ManageRoomStayRatesState()) private val _state = MutableStateFlow(ManageRoomStayRatesState())
val state: StateFlow<ManageRoomStayRatesState> = _state 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>) { fun setItems(items: List<ManageRoomStaySelection>) {
val mapped = items.map { item -> val mapped = items.map { item ->
@@ -102,6 +114,11 @@ class ManageRoomStayRatesViewModel : ViewModel() {
body = BookingBulkCheckInRequest(stays = stays) body = BookingBulkCheckInRequest(stays = stays)
) )
if (response.isSuccessful) { if (response.isSuccessful) {
roomStayRepository.refresh(propertyId = propertyId)
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
)
_state.update { it.copy(isLoading = false, error = null) } _state.update { it.copy(isLoading = false, error = null) }
onDone() onDone()
} else { } else {

View File

@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
import com.android.trisolarispms.ui.common.BackTopBarScaffold import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.LoadingAndError import com.android.trisolarispms.ui.common.LoadingAndError
import com.android.trisolarispms.ui.common.PaddedScreenColumn import com.android.trisolarispms.ui.common.PaddedScreenColumn
@@ -113,16 +112,15 @@ fun ManageRoomStaySelectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(state.rooms) { room -> items(state.rooms) { room ->
val selection = room.toSelection() ?: return@items val isSelected = selectedRooms.any { it.roomId == room.roomId }
val isSelected = selectedRooms.any { it.roomId == selection.roomId }
RoomSelectCard( RoomSelectCard(
item = selection, item = room,
isSelected = isSelected, isSelected = isSelected,
onToggle = { onToggle = {
if (isSelected) { if (isSelected) {
selectedRooms.removeAll { it.roomId == selection.roomId } selectedRooms.removeAll { it.roomId == room.roomId }
} else { } 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) OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
}.getOrNull() }.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
)
}

View File

@@ -1,9 +1,7 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
data class ManageRoomStaySelectState( data class ManageRoomStaySelectState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val rooms: List<RoomAvailableRateResponse> = emptyList() val rooms: List<ManageRoomStaySelection> = emptyList()
) )

View File

@@ -3,6 +3,9 @@ package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -20,17 +23,57 @@ class ManageRoomStaySelectViewModel : ViewModel() {
defaultError = "Load failed" defaultError = "Load failed"
) { ) {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to) val response = api.getRoomAvailabilityRange(propertyId, from = from, to = to)
if (response.isSuccessful) { if (!response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
rooms = response.body().orEmpty(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } _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
)
} }
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false alias(libs.plugins.google.services) apply false
} alias(libs.plugins.ksp) apply false
}

View File

@@ -21,6 +21,8 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.builtInKotlin=false
android.newDsl=false
systemProp.java.net.preferIPv4Stack=true systemProp.java.net.preferIPv4Stack=true
org.gradle.internal.http.connectionTimeout=600000 org.gradle.internal.http.connectionTimeout=600000
org.gradle.internal.http.socketTimeout=600000 org.gradle.internal.http.socketTimeout=600000

View File

@@ -23,6 +23,8 @@ lottieCompose = "6.7.1"
calendarCompose = "2.6.0" calendarCompose = "2.6.0"
libphonenumber = "8.13.34" libphonenumber = "8.13.34"
zxingCore = "3.5.3" zxingCore = "3.5.3"
room = "2.8.4"
ksp = "2.3.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" } libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }