ai added more room db stuff

This commit is contained in:
androidlover5842
2026-02-08 19:54:35 +05:30
parent 1000f2411c
commit f69a01a460
29 changed files with 1625 additions and 319 deletions

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

@@ -6,6 +6,15 @@ import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheDao import com.android.trisolarispms.data.local.booking.BookingDetailsCacheDao
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheEntity 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.ActiveRoomStayCacheDao
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheEntity import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheEntity
import androidx.room.migration.Migration import androidx.room.migration.Migration
@@ -13,15 +22,24 @@ import androidx.room.migration.Migration
@Database( @Database(
entities = [ entities = [
BookingDetailsCacheEntity::class, BookingDetailsCacheEntity::class,
ActiveRoomStayCacheEntity::class ActiveRoomStayCacheEntity::class,
BookingListCacheEntity::class,
PaymentCacheEntity::class,
RazorpayRequestCacheEntity::class,
RazorpayQrEventCacheEntity::class,
GuestDocumentCacheEntity::class
], ],
version = 2, version = 5,
exportSchema = false exportSchema = false
) )
@TypeConverters(RoomConverters::class) @TypeConverters(RoomConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun bookingDetailsCacheDao(): BookingDetailsCacheDao abstract fun bookingDetailsCacheDao(): BookingDetailsCacheDao
abstract fun activeRoomStayCacheDao(): ActiveRoomStayCacheDao abstract fun activeRoomStayCacheDao(): ActiveRoomStayCacheDao
abstract fun bookingListCacheDao(): BookingListCacheDao
abstract fun paymentCacheDao(): PaymentCacheDao
abstract fun razorpayCacheDao(): RazorpayCacheDao
abstract fun guestDocumentCacheDao(): GuestDocumentCacheDao
companion object { companion object {
val MIGRATION_1_2 = object : Migration(1, 2) { val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -63,5 +81,180 @@ abstract class AppDatabase : RoomDatabase() {
) )
} }
} }
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

@@ -15,6 +15,9 @@ object LocalDatabaseProvider {
"trisolaris_pms_local.db" "trisolaris_pms_local.db"
) )
.addMigrations(AppDatabase.MIGRATION_1_2) .addMigrations(AppDatabase.MIGRATION_1_2)
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_4)
.addMigrations(AppDatabase.MIGRATION_4_5)
.build() .build()
.also { built -> .also { built ->
instance = built instance = built

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

@@ -1,6 +1,7 @@
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.booking.isFutureBookingCheckIn
import com.android.trisolarispms.core.viewmodel.CitySearchController import com.android.trisolarispms.core.viewmodel.CitySearchController
@@ -11,19 +12,34 @@ 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.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 { private companion object {
const val DEFAULT_PREVIEW_BILLABLE_NIGHTS = 1 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 var expectedCheckoutPreviewRequestId: Long = 0
private val fromCitySearch = CitySearchController( private val fromCitySearch = CitySearchController(
scope = viewModelScope, scope = viewModelScope,
@@ -358,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 {
@@ -368,6 +388,19 @@ 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 { private fun BookingCreateState.withDefaultTransportModeForCheckIn(expectedCheckInAt: String): BookingCreateState {

View File

@@ -1,9 +1,14 @@
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.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -11,9 +16,20 @@ import kotlinx.coroutines.launch
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
class BookingRoomRequestViewModel : ViewModel() { class BookingRoomRequestViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingRoomRequestState()) private val _state = MutableStateFlow(BookingRoomRequestState())
val state: StateFlow<BookingRoomRequestState> = _state 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) { fun load(propertyId: String, fromAt: String, toAt: String) {
if (propertyId.isBlank()) return if (propertyId.isBlank()) return
@@ -125,6 +141,7 @@ class BookingRoomRequestViewModel : ViewModel() {
return@launch return@launch
} }
} }
syncRoomRequestCaches(propertyId = propertyId, bookingId = bookingId)
_state.update { it.copy(isSubmitting = false, error = null) } _state.update { it.copy(isSubmitting = false, error = null) }
onDone() onDone()
} catch (e: Exception) { } catch (e: Exception) {
@@ -148,6 +165,16 @@ class BookingRoomRequestViewModel : ViewModel() {
) )
} }
} }
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? = private fun String.toDateOnly(): String? =

View File

@@ -1,6 +1,7 @@
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.core.viewmodel.CitySearchController
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
@@ -8,6 +9,9 @@ import com.android.trisolarispms.data.api.core.GeoSearchRepository
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest 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.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.google.gson.JsonNull import com.google.gson.JsonNull
import com.google.gson.JsonObject import com.google.gson.JsonObject
@@ -20,9 +24,17 @@ 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 nationalitySearchJob: Job? = null
private var phoneAutofillJob: Job? = null private var phoneAutofillJob: Job? = null
private var lastAutofilledPhoneE164: String? = null private var lastAutofilledPhoneE164: String? = null
@@ -350,6 +362,11 @@ class GuestInfoViewModel : ViewModel() {
} }
if (submitError == null) { if (submitError == null) {
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

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

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

@@ -3,7 +3,7 @@ package com.android.trisolarispms.ui.roomstay
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel 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.local.bookinglist.BookingListRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -21,8 +21,14 @@ class ActiveRoomStaysViewModel(
private val repository = ActiveRoomStayRepository( private val repository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao() dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
) )
private val bookingListRepository = BookingListRepository(
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
)
private var observeJob: Job? = null private var observeJob: Job? = null
private var observePropertyId: String? = null private var observePropertyId: String? = null
private var observeBookingListPropertyId: String? = null
private var observeCheckedInJob: Job? = null
private var observeOpenJob: Job? = null
fun toggleShowOpenBookings() { fun toggleShowOpenBookings() {
_state.update { it.copy(showOpenBookings = !it.showOpenBookings) } _state.update { it.copy(showOpenBookings = !it.showOpenBookings) }
@@ -35,41 +41,24 @@ class ActiveRoomStaysViewModel(
fun load(propertyId: String) { fun load(propertyId: String) {
if (propertyId.isBlank()) return if (propertyId.isBlank()) return
observeCache(propertyId = propertyId) observeCache(propertyId = propertyId)
observeBookingLists(propertyId = propertyId)
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
val activeResult = repository.refresh(propertyId = propertyId) val activeResult = repository.refresh(propertyId = propertyId)
val api = ApiClient.create() val checkedInResult = bookingListRepository.refreshByStatus(
val checkedInBookingsResponse = runCatching { propertyId = propertyId,
api.listBookings(propertyId = propertyId, status = "CHECKED_IN") status = "CHECKED_IN"
}.getOrNull() )
val openBookingsResponse = runCatching { val openResult = bookingListRepository.refreshByStatus(
api.listBookings(propertyId = propertyId, status = "OPEN") propertyId = propertyId,
}.getOrNull() status = "OPEN"
val checkedInBookings = checkedInBookingsResponse )
?.body()
.orEmpty()
.takeIf { checkedInBookingsResponse?.isSuccessful == true }
.orEmpty()
val openBookings = openBookingsResponse
?.body()
.orEmpty()
.takeIf { openBookingsResponse?.isSuccessful == true }
.orEmpty()
val errorMessage = activeResult.exceptionOrNull()?.localizedMessage val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
?: when { ?: checkedInResult.exceptionOrNull()?.localizedMessage
checkedInBookingsResponse != null && !checkedInBookingsResponse.isSuccessful -> ?: openResult.exceptionOrNull()?.localizedMessage
"Load failed: ${checkedInBookingsResponse.code()}"
openBookingsResponse != null && !openBookingsResponse.isSuccessful ->
"Load failed: ${openBookingsResponse.code()}"
else -> null
}
_state.update { _state.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
checkedInBookings = checkedInBookings,
openBookings = openBookings,
error = errorMessage error = errorMessage
) )
} }
@@ -79,6 +68,8 @@ class ActiveRoomStaysViewModel(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
observeJob?.cancel() observeJob?.cancel()
observeCheckedInJob?.cancel()
observeOpenJob?.cancel()
} }
private fun observeCache(propertyId: String) { private fun observeCache(propertyId: String) {
@@ -96,4 +87,32 @@ class ActiveRoomStaysViewModel(
} }
} }
} }
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

@@ -56,12 +56,9 @@ import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.request.ImageRequest import coil.request.ImageRequest
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.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.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.ui.booking.BookingDatePickerDialog import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard
@@ -293,19 +290,16 @@ fun BookingDetailsTabsScreen(
checkoutLoading.value = true checkoutLoading.value = true
checkoutError.value = null checkoutError.value = null
try { try {
val response = ApiClient.create().checkOut( val result = detailsViewModel.checkoutBooking(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId, bookingId = bookingId
body = BookingCheckOutRequest()
) )
if (response.isSuccessful) { if (result.isSuccess) {
showCheckoutConfirm.value = false showCheckoutConfirm.value = false
onBack() onBack()
} else if (response.code() == 409) {
checkoutError.value =
extractApiErrorMessage(response) ?: "Checkout conflict"
} else { } else {
checkoutError.value = "Checkout failed: ${response.code()}" val message = result.exceptionOrNull()?.localizedMessage
checkoutError.value = message ?: "Checkout failed"
} }
} catch (e: Exception) { } catch (e: Exception) {
checkoutError.value = e.localizedMessage ?: "Checkout failed" checkoutError.value = e.localizedMessage ?: "Checkout failed"
@@ -364,16 +358,16 @@ fun BookingDetailsTabsScreen(
cancelLoading.value = true cancelLoading.value = true
cancelError.value = null cancelError.value = null
try { try {
val response = ApiClient.create().cancelBooking( val result = detailsViewModel.cancelBooking(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId, bookingId = bookingId
body = BookingCancelRequest()
) )
if (response.isSuccessful) { if (result.isSuccess) {
showCancelConfirm.value = false showCancelConfirm.value = false
onBack() onBack()
} else { } else {
cancelError.value = "Cancel failed: ${response.code()}" val message = result.exceptionOrNull()?.localizedMessage
cancelError.value = message ?: "Cancel failed"
} }
} catch (e: Exception) { } catch (e: Exception) {
cancelError.value = e.localizedMessage ?: "Cancel failed" cancelError.value = e.localizedMessage ?: "Cancel failed"

View File

@@ -5,6 +5,8 @@ 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.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
@@ -163,6 +165,41 @@ class BookingDetailsViewModel(
return result 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() observeJob?.cancel()
@@ -196,4 +233,12 @@ class BookingDetailsViewModel(
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

@@ -5,6 +5,7 @@ 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.model.BookingRoomStayCheckOutRequest 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.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -22,6 +23,9 @@ class BookingRoomStaysViewModel(
private val repository = ActiveRoomStayRepository( private val repository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao() dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
) )
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private var observeJob: Job? = null private var observeJob: Job? = null
private var observeKey: String? = null private var observeKey: String? = null
@@ -62,7 +66,14 @@ class BookingRoomStaysViewModel(
) )
when { when {
response.isSuccessful -> { response.isSuccessful -> {
repository.removeFromCache(propertyId = propertyId, roomStayId = roomStayId) val refreshResult = repository.refresh(propertyId = propertyId)
if (refreshResult.isFailure) {
repository.removeFromCache(propertyId = propertyId, roomStayId = roomStayId)
}
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
)
_state.update { current -> _state.update { current ->
current.copy( current.copy(
checkingOutRoomStayId = null, checkingOutRoomStayId = null,

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 {