From f69a01a460d9ede8bded8996266a00b0d7fe4d26 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sun, 8 Feb 2026 19:54:35 +0530 Subject: [PATCH] ai added more room db stuff --- .../local/bookinglist/BookingListCacheDao.kt | 43 ++ .../bookinglist/BookingListCacheEntity.kt | 114 ++++++ .../bookinglist/BookingListRepository.kt | 28 ++ .../data/local/core/AppDatabase.kt | 197 +++++++++- .../data/local/core/LocalDatabaseProvider.kt | 3 + .../local/guestdoc/GuestDocumentCacheDao.kt | 43 ++ .../guestdoc/GuestDocumentCacheEntity.kt | 76 ++++ .../local/guestdoc/GuestDocumentRepository.kt | 47 +++ .../data/local/payment/PaymentCacheDao.kt | 43 ++ .../data/local/payment/PaymentCacheEntity.kt | 84 ++++ .../data/local/payment/PaymentRepository.kt | 32 ++ .../data/local/razorpay/RazorpayCacheDao.kt | 80 ++++ .../local/razorpay/RazorpayCacheRepository.kt | 80 ++++ .../razorpay/RazorpayQrEventCacheEntity.kt | 50 +++ .../razorpay/RazorpayRequestCacheEntity.kt | 73 ++++ .../ui/booking/BookingCreateViewModel.kt | 37 +- .../ui/booking/BookingRoomRequestViewModel.kt | 31 +- .../ui/guest/GuestInfoViewModel.kt | 21 +- .../ui/guest/GuestSignatureScreen.kt | 3 +- .../ui/guest/GuestSignatureViewModel.kt | 26 +- .../ui/guestdocs/GuestDocumentsViewModel.kt | 170 ++++---- .../ui/navigation/MainRoutesHomeGuest.kt | 1 + .../ui/payment/BookingPaymentsViewModel.kt | 111 ++++-- .../ui/razorpay/RazorpayQrViewModel.kt | 369 +++++++++++------- .../ui/roomstay/ActiveRoomStaysViewModel.kt | 77 ++-- .../ui/roomstay/BookingDetailsTabsScreen.kt | 26 +- .../ui/roomstay/BookingDetailsViewModel.kt | 45 +++ .../ui/roomstay/BookingRoomStaysViewModel.kt | 13 +- .../roomstay/ManageRoomStayRatesViewModel.kt | 21 +- 29 files changed, 1625 insertions(+), 319 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheDao.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheEntity.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListRepository.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheDao.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheEntity.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentRepository.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheDao.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheEntity.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentRepository.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheDao.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheRepository.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayQrEventCacheEntity.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayRequestCacheEntity.kt diff --git a/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheDao.kt b/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheDao.kt new file mode 100644 index 0000000..412a0b8 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(items: List) + + @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 + ) { + deleteByPropertyAndStatus(propertyId = propertyId, status = status) + if (items.isNotEmpty()) { + upsertAll(items) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheEntity.kt b/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheEntity.kt new file mode 100644 index 0000000..59e4596 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListCacheEntity.kt @@ -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 = emptyList(), + val roomNumbers: List = 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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListRepository.kt b/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListRepository.kt new file mode 100644 index 0000000..d6ffda7 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/bookinglist/BookingListRepository.kt @@ -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> = + dao.observeByStatus(propertyId = propertyId, status = status).map { rows -> + rows.map { it.toApiModel() } + } + + suspend fun refreshByStatus(propertyId: String, status: String): Result = 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) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/core/AppDatabase.kt b/app/src/main/java/com/android/trisolarispms/data/local/core/AppDatabase.kt index 53f9795..e84c28e 100644 --- a/app/src/main/java/com/android/trisolarispms/data/local/core/AppDatabase.kt +++ b/app/src/main/java/com/android/trisolarispms/data/local/core/AppDatabase.kt @@ -6,6 +6,15 @@ 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 @@ -13,15 +22,24 @@ import androidx.room.migration.Migration @Database( entities = [ BookingDetailsCacheEntity::class, - ActiveRoomStayCacheEntity::class + ActiveRoomStayCacheEntity::class, + BookingListCacheEntity::class, + PaymentCacheEntity::class, + RazorpayRequestCacheEntity::class, + RazorpayQrEventCacheEntity::class, + GuestDocumentCacheEntity::class ], - version = 2, + 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) { @@ -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() + ) + } + } } } diff --git a/app/src/main/java/com/android/trisolarispms/data/local/core/LocalDatabaseProvider.kt b/app/src/main/java/com/android/trisolarispms/data/local/core/LocalDatabaseProvider.kt index 2acc77c..71ebcd6 100644 --- a/app/src/main/java/com/android/trisolarispms/data/local/core/LocalDatabaseProvider.kt +++ b/app/src/main/java/com/android/trisolarispms/data/local/core/LocalDatabaseProvider.kt @@ -15,6 +15,9 @@ object LocalDatabaseProvider { "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 diff --git a/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheDao.kt b/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheDao.kt new file mode 100644 index 0000000..cd1d34b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(items: List) + + @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 + ) { + deleteByGuest(propertyId = propertyId, guestId = guestId) + if (items.isNotEmpty()) { + upsertAll(items) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheEntity.kt b/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheEntity.kt new file mode 100644 index 0000000..5329548 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentCacheEntity.kt @@ -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>() {}.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()) } + .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() + else Gson().fromJson>(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 + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentRepository.kt b/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentRepository.kt new file mode 100644 index 0000000..b959165 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/guestdoc/GuestDocumentRepository.kt @@ -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> = + dao.observeByGuest(propertyId = propertyId, guestId = guestId).map { rows -> + rows.map { it.toApiModel() } + } + + suspend fun refresh(propertyId: String, guestId: String): Result = 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 + ) { + val rows = documents.mapIndexedNotNull { index, doc -> + doc.toCacheEntity( + propertyId = propertyId, + guestId = guestId, + sortOrder = index + ) + } + dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheDao.kt b/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheDao.kt new file mode 100644 index 0000000..bbd34b1 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(items: List) + + @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 + ) { + deleteByBooking(propertyId = propertyId, bookingId = bookingId) + if (items.isNotEmpty()) { + upsertAll(items) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheEntity.kt b/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheEntity.kt new file mode 100644 index 0000000..cb05534 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentCacheEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentRepository.kt b/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentRepository.kt new file mode 100644 index 0000000..07c15f7 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/payment/PaymentRepository.kt @@ -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> = + dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows -> + rows.map { it.toApiModel() } + } + + suspend fun refresh(propertyId: String, bookingId: String): Result = 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) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheDao.kt b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheDao.kt new file mode 100644 index 0000000..3e5a163 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertRequests(items: List) + + @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 + ) { + 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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertQrEvents(items: List) + + @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 + ) { + deleteQrEvents(propertyId = propertyId, bookingId = bookingId, qrId = qrId) + if (items.isNotEmpty()) { + upsertQrEvents(items) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheRepository.kt b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheRepository.kt new file mode 100644 index 0000000..4756215 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayCacheRepository.kt @@ -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> = + dao.observeRequests(propertyId = propertyId, bookingId = bookingId).map { rows -> + rows.map { it.toApiModel() } + } + + fun observeQrEvents( + propertyId: String, + bookingId: String, + qrId: String + ): Flow> = + dao.observeQrEvents( + propertyId = propertyId, + bookingId = bookingId, + qrId = qrId + ).map { rows -> + rows.map { it.toApiModel() } + } + + suspend fun refreshRequests(propertyId: String, bookingId: String): Result = 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> = 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 + } +} diff --git a/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayQrEventCacheEntity.kt b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayQrEventCacheEntity.kt new file mode 100644 index 0000000..e9ccf87 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayQrEventCacheEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayRequestCacheEntity.kt b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayRequestCacheEntity.kt new file mode 100644 index 0000000..032c2aa --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/local/razorpay/RazorpayRequestCacheEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt index 2714c76..3333385 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt @@ -1,6 +1,7 @@ package com.android.trisolarispms.ui.booking -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.core.booking.isFutureBookingCheckIn import com.android.trisolarispms.core.viewmodel.CitySearchController @@ -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.BookingExpectedCheckoutPreviewRequest import com.android.trisolarispms.data.api.model.GuestDto +import com.android.trisolarispms.data.local.booking.BookingDetailsRepository +import com.android.trisolarispms.data.local.bookinglist.BookingListRepository +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider +import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.OffsetDateTime -class BookingCreateViewModel : ViewModel() { +class BookingCreateViewModel( + application: Application +) : AndroidViewModel(application) { private companion object { const val DEFAULT_PREVIEW_BILLABLE_NIGHTS = 1 } private val _state = MutableStateFlow(BookingCreateState()) val state: StateFlow = _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, @@ -358,6 +374,10 @@ class BookingCreateViewModel : ViewModel() { ) val body = response.body() if (response.isSuccessful && body != null) { + syncCreateBookingCaches( + propertyId = propertyId, + bookingId = body.id + ) _state.update { it.copy(isLoading = false, error = null) } onDone(body, null, phone) } else { @@ -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 { diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt index 5001bbc..484b99a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt @@ -1,9 +1,14 @@ package com.android.trisolarispms.ui.booking -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.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 @@ -11,9 +16,20 @@ import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.time.format.DateTimeFormatter -class BookingRoomRequestViewModel : ViewModel() { +class BookingRoomRequestViewModel( + application: Application +) : AndroidViewModel(application) { private val _state = MutableStateFlow(BookingRoomRequestState()) val state: StateFlow = _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 @@ -125,6 +141,7 @@ class BookingRoomRequestViewModel : ViewModel() { return@launch } } + syncRoomRequestCaches(propertyId = propertyId, bookingId = bookingId) _state.update { it.copy(isSubmitting = false, error = null) } onDone() } 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? = diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt index 9035d2e..4fd63f3 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt @@ -1,6 +1,7 @@ package com.android.trisolarispms.ui.guest -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.core.viewmodel.CitySearchController import com.android.trisolarispms.data.api.core.ApiClient @@ -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.GuestDto import com.android.trisolarispms.data.api.model.GuestUpdateRequest +import com.android.trisolarispms.data.local.booking.BookingDetailsRepository +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider +import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository import com.android.trisolarispms.ui.booking.findPhoneCountryOption import com.google.gson.JsonNull import com.google.gson.JsonObject @@ -20,9 +24,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class GuestInfoViewModel : ViewModel() { +class GuestInfoViewModel( + application: Application +) : AndroidViewModel(application) { private val _state = MutableStateFlow(GuestInfoState()) val state: StateFlow = _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 @@ -350,6 +362,11 @@ class GuestInfoViewModel : ViewModel() { } if (submitError == null) { + roomStayRepository.refresh(propertyId = propertyId) + bookingRepository.refreshBookingDetails( + propertyId = propertyId, + bookingId = bookingId + ) _state.update { it.copy(isLoading = false, error = null) } onDone() } else { diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt index 0baf0b3..d1b3218 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt @@ -47,6 +47,7 @@ import com.android.trisolarispms.ui.common.PaddedScreenColumn @Composable fun GuestSignatureScreen( propertyId: String, + bookingId: String, guestId: String, onBack: () -> Unit, onDone: () -> Unit, @@ -68,7 +69,7 @@ fun GuestSignatureScreen( onClick = { val svg = buildSignatureSvg(strokes, canvasSize.value) if (!svg.isNullOrBlank()) { - viewModel.uploadSignature(propertyId, guestId, svg, onDone) + viewModel.uploadSignature(propertyId, bookingId, guestId, svg, onDone) } }, enabled = strokes.isNotEmpty() && !state.isLoading diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt index 79e0283..337847f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt @@ -1,8 +1,11 @@ package com.android.trisolarispms.ui.guest -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.core.viewmodel.launchRequest +import com.android.trisolarispms.data.local.booking.BookingDetailsRepository +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -10,15 +13,26 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody -class GuestSignatureViewModel : ViewModel() { +class GuestSignatureViewModel( + application: Application +) : AndroidViewModel(application) { private val _state = MutableStateFlow(GuestSignatureState()) val state: StateFlow = _state + private val bookingRepository = BookingDetailsRepository( + dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao() + ) fun reset() { _state.value = GuestSignatureState() } - fun uploadSignature(propertyId: String, guestId: String, svg: String, onDone: () -> Unit) { + fun uploadSignature( + propertyId: String, + bookingId: String, + guestId: String, + svg: String, + onDone: () -> Unit + ) { if (propertyId.isBlank() || guestId.isBlank()) return launchRequest( state = _state, @@ -35,6 +49,12 @@ class GuestSignatureViewModel : ViewModel() { ) val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part) if (response.isSuccessful) { + if (bookingId.isNotBlank()) { + bookingRepository.refreshBookingDetails( + propertyId = propertyId, + bookingId = bookingId + ) + } _state.update { it.copy(isLoading = false, error = null) } onDone() } else { diff --git a/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt index 645cf61..41df3ac 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt @@ -1,16 +1,21 @@ package com.android.trisolarispms.ui.guestdocs +import android.app.Application import android.content.ContentResolver import android.content.Context import android.net.Uri -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiConstants import com.android.trisolarispms.data.api.model.GuestDocumentDto +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider +import com.android.trisolarispms.data.local.guestdoc.GuestDocumentRepository import com.google.gson.Gson +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -24,53 +29,47 @@ import java.io.File import java.io.FileOutputStream import java.util.Locale -class GuestDocumentsViewModel : ViewModel() { +class GuestDocumentsViewModel( + application: Application +) : AndroidViewModel(application) { private val _state = MutableStateFlow(GuestDocumentsState()) val state: StateFlow = _state private val gson = Gson() + private val repository = GuestDocumentRepository( + dao = LocalDatabaseProvider.get(application).guestDocumentCacheDao() + ) private var eventSource: EventSource? = null private var streamKey: String? = null + private var observeKey: String? = null + private var observeJob: Job? = null fun load(propertyId: String, guestId: String) { + if (propertyId.isBlank() || guestId.isBlank()) return + observeCache(propertyId = propertyId, guestId = guestId) viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } - try { - val api = ApiClient.create() - val response = api.listGuestDocuments(propertyId, guestId) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { - it.copy( - isLoading = false, - documents = body, - error = null - ) - } - } else { - _state.update { - it.copy( - isLoading = false, - error = "Load failed: ${response.code()}" - ) - } - } - } catch (e: Exception) { - _state.update { - it.copy( - isLoading = false, - error = e.localizedMessage ?: "Load failed" - ) - } + val result = repository.refresh(propertyId = propertyId, guestId = guestId) + _state.update { + it.copy( + isLoading = false, + error = result.exceptionOrNull()?.localizedMessage + ) } } } fun startStream(propertyId: String, guestId: String) { + if (propertyId.isBlank() || guestId.isBlank()) return + observeCache(propertyId = propertyId, guestId = guestId) val key = "$propertyId:$guestId" if (streamKey == key && eventSource != null) return stopStream() streamKey = key - _state.update { it.copy(isLoading = true, error = null, documents = emptyList()) } + _state.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + repository.refresh(propertyId = propertyId, guestId = guestId) + _state.update { it.copy(isLoading = false) } + } val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0) val url = "${ApiConstants.BASE_URL}properties/$propertyId/guests/$guestId/documents/stream" val request = Request.Builder().url(url).get().build() @@ -91,19 +90,16 @@ class GuestDocumentsViewModel : ViewModel() { data: String ) { if (data.isBlank() || type == "ping") return - val docs = try { + val docs = runCatching { gson.fromJson(data, Array::class.java)?.toList() - } catch (_: Exception) { - null - } - if (docs != null) { - _state.update { - it.copy( - isLoading = false, - documents = docs, - error = null - ) - } + }.getOrNull() ?: return + viewModelScope.launch { + repository.storeSnapshot( + propertyId = propertyId, + guestId = guestId, + documents = docs + ) + _state.update { current -> current.copy(isLoading = false, error = null) } } } @@ -126,7 +122,6 @@ class GuestDocumentsViewModel : ViewModel() { } } ) - _state.update { it.copy(isLoading = false) } } fun stopStream() { @@ -166,49 +161,43 @@ class GuestDocumentsViewModel : ViewModel() { } val filename = resolveFileName(resolver, uri) ?: "document" val file = copyToCache(resolver, uri, context.cacheDir, filename) - uploadFile(propertyId, guestId, bookingId, file, mime) + val uploadError = uploadFile(propertyId, guestId, bookingId, file, mime) + if (uploadError != null) { + errorMessage = uploadError + } } catch (e: Exception) { errorMessage = e.localizedMessage ?: "Upload failed" } } - _state.update { current -> - current.copy( + _state.update { + it.copy( isUploading = false, - error = errorMessage ?: current.error + error = errorMessage ) } } } fun deleteDocument(propertyId: String, guestId: String, documentId: String) { + if (propertyId.isBlank() || guestId.isBlank() || documentId.isBlank()) return viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } try { val api = ApiClient.create() val response = api.deleteGuestDocument(propertyId, guestId, documentId) if (response.isSuccessful) { - _state.update { current -> - current.copy( - isLoading = false, - error = null, - documents = current.documents.filterNot { it.id == documentId } - ) - } - } else { + val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId) _state.update { it.copy( isLoading = false, - error = "Delete failed: ${response.code()}" + error = refreshResult.exceptionOrNull()?.localizedMessage ) } + } else { + _state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") } } } catch (e: Exception) { - _state.update { - it.copy( - isLoading = false, - error = e.localizedMessage ?: "Delete failed" - ) - } + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") } } } } @@ -222,15 +211,12 @@ class GuestDocumentsViewModel : ViewModel() { ) { viewModelScope.launch { _state.update { it.copy(isUploading = true, error = null) } - try { - uploadFile(propertyId, guestId, bookingId, file, mimeType) - } catch (e: Exception) { - _state.update { - it.copy( - isUploading = false, - error = e.localizedMessage ?: "Upload failed" - ) - } + val uploadError = uploadFile(propertyId, guestId, bookingId, file, mimeType) + _state.update { + it.copy( + isUploading = false, + error = uploadError + ) } } } @@ -241,7 +227,7 @@ class GuestDocumentsViewModel : ViewModel() { bookingId: String, file: File, mimeType: String - ) { + ): String? { val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) val part = MultipartBody.Part.createFormData("file", file.name, requestBody) val api = ApiClient.create() @@ -251,22 +237,29 @@ class GuestDocumentsViewModel : ViewModel() { bookingId = bookingId, file = part ) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { it.copy(isUploading = false, error = null) } - } else if (response.code() == 409) { - _state.update { - it.copy( - isUploading = false, - error = "Duplicate document" - ) - } - } else { - _state.update { - it.copy( - isUploading = false, - error = "Upload failed: ${response.code()}" - ) + if (response.isSuccessful && response.body() != null) { + val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId) + return refreshResult.exceptionOrNull()?.localizedMessage + } + if (response.code() == 409) { + return "Duplicate document" + } + return "Upload failed: ${response.code()}" + } + + private fun observeCache(propertyId: String, guestId: String) { + val key = "$propertyId:$guestId" + if (observeKey == key && observeJob?.isActive == true) return + observeJob?.cancel() + observeKey = key + observeJob = viewModelScope.launch { + repository.observeByGuest(propertyId = propertyId, guestId = guestId).collect { docs -> + _state.update { current -> + current.copy( + documents = docs, + isLoading = if (docs.isNotEmpty()) false else current.isLoading + ) + } } } } @@ -296,6 +289,7 @@ class GuestDocumentsViewModel : ViewModel() { override fun onCleared() { super.onCleared() + observeJob?.cancel() stopStream() } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt index 4c9e0a3..8d94c62 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt @@ -136,6 +136,7 @@ internal fun renderHomeGuestRoutes( is AppRoute.GuestSignature -> GuestSignatureScreen( propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, guestId = currentRoute.guestId, onBack = { refs.route.value = AppRoute.GuestInfo( diff --git a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt index 7e26f05..f6bc3e8 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/payment/BookingPaymentsViewModel.kt @@ -1,40 +1,43 @@ package com.android.trisolarispms.ui.payment -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiService import com.android.trisolarispms.data.api.model.RazorpayRefundRequest +import com.android.trisolarispms.data.local.booking.BookingDetailsRepository +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider +import com.android.trisolarispms.data.local.payment.PaymentRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.Response -class BookingPaymentsViewModel : ViewModel() { +class BookingPaymentsViewModel( + application: Application +) : AndroidViewModel(application) { private val _state = MutableStateFlow(BookingPaymentsState()) val state: StateFlow = _state + private val bookingRepository = BookingDetailsRepository( + dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao() + ) + private val paymentRepository = PaymentRepository( + dao = LocalDatabaseProvider.get(application).paymentCacheDao() + ) + private var observeKey: String? = null + private var observePaymentsJob: Job? = null + private var observeBookingJob: Job? = null fun load(propertyId: String, bookingId: String) { - runPaymentAction(defaultError = "Load failed") { api -> - val paymentsResponse = api.listPayments(propertyId, bookingId) - val payments = paymentsResponse.body() - if (paymentsResponse.isSuccessful && payments != null) { - val pending = api.getBookingBalance(propertyId, bookingId) - .body() - ?.pending - _state.update { - it.copy( - isLoading = false, - payments = payments, - pendingBalance = pending, - error = null, - message = null - ) - } - } else { - setActionFailure("Load", paymentsResponse) - } + if (propertyId.isBlank() || bookingId.isBlank()) return + observeCaches(propertyId = propertyId, bookingId = bookingId) + runPaymentAction(defaultError = "Load failed") { + refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId) + _state.update { it.copy(isLoading = false, error = null, message = null) } } } @@ -58,12 +61,10 @@ class BookingPaymentsViewModel : ViewModel() { ) val body = response.body() if (response.isSuccessful && body != null) { - _state.update { current -> - val nextPending = latestPending?.let { (it - amount).coerceAtLeast(0L) } - current.copy( + refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId) + _state.update { + it.copy( isLoading = false, - payments = listOf(body) + current.payments, - pendingBalance = nextPending ?: current.pendingBalance, error = null, message = "Cash payment added" ) @@ -76,24 +77,16 @@ class BookingPaymentsViewModel : ViewModel() { fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) { runPaymentAction(defaultError = "Delete failed") { api -> - val payment = _state.value.payments.firstOrNull { it.id == paymentId } val response = api.deletePayment( propertyId = propertyId, bookingId = bookingId, paymentId = paymentId ) if (response.isSuccessful) { - _state.update { current -> - val restoredPending = if (payment?.method == "CASH") { - val amount = payment.amount ?: 0L - current.pendingBalance?.plus(amount) - } else { - current.pendingBalance - } - current.copy( + refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId) + _state.update { + it.copy( isLoading = false, - payments = current.payments.filterNot { it.id == paymentId }, - pendingBalance = restoredPending, error = null, message = "Cash payment deleted" ) @@ -133,7 +126,7 @@ class BookingPaymentsViewModel : ViewModel() { ) val body = response.body() if (response.isSuccessful && body != null) { - load(propertyId, bookingId) + refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId) _state.update { it.copy( isLoading = false, @@ -183,4 +176,46 @@ class BookingPaymentsViewModel : ViewModel() { if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)" return null } + + override fun onCleared() { + super.onCleared() + observePaymentsJob?.cancel() + observeBookingJob?.cancel() + } + + private fun observeCaches(propertyId: String, bookingId: String) { + val key = "$propertyId:$bookingId" + if (observeKey == key && + observePaymentsJob?.isActive == true && + observeBookingJob?.isActive == true + ) { + return + } + observePaymentsJob?.cancel() + observeBookingJob?.cancel() + observeKey = key + observePaymentsJob = viewModelScope.launch { + paymentRepository.observeByBooking(propertyId = propertyId, bookingId = bookingId).collect { items -> + _state.update { current -> current.copy(payments = items) } + } + } + observeBookingJob = viewModelScope.launch { + bookingRepository.observeBookingDetails( + propertyId = propertyId, + bookingId = bookingId + ).collect { details -> + _state.update { current -> + current.copy(pendingBalance = details?.pending ?: current.pendingBalance) + } + } + } + } + + private suspend fun refreshPaymentCaches(propertyId: String, bookingId: String) { + paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId).getOrThrow() + bookingRepository.refreshBookingDetails( + propertyId = propertyId, + bookingId = bookingId + ).getOrThrow() + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt index 014baec..9f56778 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/razorpay/RazorpayQrViewModel.kt @@ -1,6 +1,7 @@ package com.android.trisolarispms.ui.razorpay -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiConstants @@ -9,37 +10,60 @@ import com.android.trisolarispms.data.api.model.RazorpayQrEventDto import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest import com.android.trisolarispms.data.api.model.RazorpayQrRequest +import com.android.trisolarispms.data.local.booking.BookingDetailsRepository +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider +import com.android.trisolarispms.data.local.payment.PaymentRepository +import com.android.trisolarispms.data.local.razorpay.RazorpayCacheRepository import com.google.gson.Gson +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import okhttp3.Request import okhttp3.sse.EventSource import okhttp3.sse.EventSourceListener import okhttp3.sse.EventSources -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -class RazorpayQrViewModel : ViewModel() { +class RazorpayQrViewModel( + application: Application +) : AndroidViewModel(application) { private val gson = Gson() private val _state = MutableStateFlow( RazorpayQrState(deviceInfo = buildDeviceInfo()) ) val state: StateFlow = _state + private val razorpayRepository = RazorpayCacheRepository( + dao = LocalDatabaseProvider.get(application).razorpayCacheDao() + ) + private val paymentRepository = PaymentRepository( + dao = LocalDatabaseProvider.get(application).paymentCacheDao() + ) + private val bookingRepository = BookingDetailsRepository( + dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao() + ) private var qrEventSource: EventSource? = null private var lastQrId: String? = null private var qrPollJob: Job? = null + private var requestObserveKey: String? = null + private var requestObserveJob: Job? = null + private var eventObserveKey: String? = null + private var eventObserveJob: Job? = null fun reset() { stopQrEventStream() stopQrEventPolling() + stopRequestObservation() + stopQrEventObservation() _state.value = RazorpayQrState(deviceInfo = buildDeviceInfo()) } fun exitQrView() { stopQrEventStream() stopQrEventPolling() + stopQrEventObservation() _state.update { it.copy( qrId = null, @@ -96,10 +120,11 @@ class RazorpayQrViewModel : ViewModel() { currency = body.currency, imageUrl = body.imageUrl, isClosed = false, + isCredited = false, error = null ) } - loadQrList(propertyId, bookingId) + refreshRequestCache(propertyId = propertyId, bookingId = bookingId) startQrEventStream(propertyId, bookingId, body.qrId) } else { _state.update { @@ -120,127 +145,11 @@ class RazorpayQrViewModel : ViewModel() { } } - private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) { - if (qrId.isNullOrBlank()) return - if (lastQrId == qrId && qrEventSource != null) return - stopQrEventStream() - stopQrEventPolling() - lastQrId = qrId - val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0) - val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream" - val request = Request.Builder().url(url).get().build() - qrEventSource = EventSources.createFactory(client).newEventSource( - request, - object : EventSourceListener() { - override fun onEvent( - eventSource: EventSource, - id: String?, - type: String?, - data: String - ) { - if (data.isBlank() || type == "ping") return - val event = runCatching { - gson.fromJson(data, RazorpayQrEventDto::class.java) - }.getOrNull() ?: return - val status = event.status?.lowercase() - val eventName = event.event?.lowercase() - if (eventName == "qr_code.credited" || status == "credited") { - _state.update { it.copy(isCredited = true, imageUrl = null) } - stopQrEventStream() - stopQrEventPolling() - return - } - if (isClosedStatus(status)) { - _state.update { it.copy(isClosed = true, imageUrl = null) } - stopQrEventStream() - } - } - - override fun onFailure( - eventSource: EventSource, - t: Throwable?, - response: okhttp3.Response? - ) { - stopQrEventStream() - startQrEventPolling(propertyId, bookingId, qrId) - } - - override fun onClosed(eventSource: EventSource) { - stopQrEventStream() - startQrEventPolling(propertyId, bookingId, qrId) - } - } - ) - // Keep polling as a fallback in case SSE is buffered or never delivers events. - startQrEventPolling(propertyId, bookingId, qrId) - } - - private fun stopQrEventStream() { - qrEventSource?.cancel() - qrEventSource = null - lastQrId = null - } - - private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) { - if (qrPollJob?.isActive == true) return - qrPollJob = viewModelScope.launch { - var delayMs = 4000L - while (true) { - val currentQrId = state.value.qrId - if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) { - break - } - try { - val response = ApiClient.create().listRazorpayQrEvents( - propertyId = propertyId, - bookingId = bookingId, - qrId = qrId - ) - val body = response.body() - if (response.isSuccessful && body != null) { - if (body.any { it.event?.lowercase() == "qr_code.credited" || it.status?.lowercase() == "credited" }) { - _state.update { it.copy(isCredited = true, imageUrl = null) } - stopQrEventStream() - break - } - if (body.any { isClosedStatus(it.status) }) { - _state.update { it.copy(isClosed = true, imageUrl = null) } - stopQrEventStream() - break - } - } - delayMs = 4000L - } catch (e: Exception) { - delayMs = (delayMs * 1.5).toLong().coerceAtMost(15000L) - } - delay(delayMs) - } - } - } - - private fun stopQrEventPolling() { - qrPollJob?.cancel() - qrPollJob = null - } - - private fun isClosedStatus(status: String?): Boolean { - return when (status?.lowercase()) { - "credited", "closed", "expired" -> true - else -> false - } - } - fun loadQrList(propertyId: String, bookingId: String) { + if (propertyId.isBlank() || bookingId.isBlank()) return + observeRequestCache(propertyId = propertyId, bookingId = bookingId) viewModelScope.launch { - try { - val response = ApiClient.create().listRazorpayRequests(propertyId, bookingId) - val body = response.body() - if (response.isSuccessful && body != null) { - _state.update { it.copy(qrList = body) } - } - } catch (_: Exception) { - // ignore list load errors - } + refreshRequestCache(propertyId = propertyId, bookingId = bookingId) } } @@ -256,10 +165,7 @@ class RazorpayQrViewModel : ViewModel() { body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId) ) if (response.isSuccessful) { - _state.update { current -> - current.copy(qrList = current.qrList.filterNot { it.requestId == item.requestId }) - } - loadQrList(propertyId, bookingId) + refreshRequestCache(propertyId = propertyId, bookingId = bookingId) } } catch (_: Exception) { // ignore close errors @@ -313,7 +219,7 @@ class RazorpayQrViewModel : ViewModel() { error = null ) } - loadQrList(propertyId, bookingId) + refreshRequestCache(propertyId = propertyId, bookingId = bookingId) } else { _state.update { it.copy( @@ -333,6 +239,200 @@ class RazorpayQrViewModel : ViewModel() { } } + private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) { + if (qrId.isNullOrBlank()) return + observeQrEventCache(propertyId = propertyId, bookingId = bookingId, qrId = qrId) + if (lastQrId == qrId && qrEventSource != null) return + stopQrEventStream() + stopQrEventPolling() + lastQrId = qrId + val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0) + val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream" + val request = Request.Builder().url(url).get().build() + qrEventSource = EventSources.createFactory(client).newEventSource( + request, + object : EventSourceListener() { + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String + ) { + if (data.isBlank() || type == "ping") return + val event = runCatching { gson.fromJson(data, RazorpayQrEventDto::class.java) } + .getOrNull() ?: return + val eventQrId = event.qrId?.takeIf { it.isNotBlank() } ?: qrId + viewModelScope.launch { + refreshQrEventCache( + propertyId = propertyId, + bookingId = bookingId, + qrId = eventQrId + ) + } + } + + override fun onFailure( + eventSource: EventSource, + t: Throwable?, + response: okhttp3.Response? + ) { + stopQrEventStream() + startQrEventPolling(propertyId, bookingId, qrId) + } + + override fun onClosed(eventSource: EventSource) { + stopQrEventStream() + startQrEventPolling(propertyId, bookingId, qrId) + } + } + ) + // Keep polling as a fallback in case SSE is buffered or never delivers events. + startQrEventPolling(propertyId, bookingId, qrId) + } + + private fun stopQrEventStream() { + qrEventSource?.cancel() + qrEventSource = null + lastQrId = null + } + + private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) { + if (qrPollJob?.isActive == true) return + qrPollJob = viewModelScope.launch { + var delayMs = 4000L + while (true) { + val currentQrId = state.value.qrId + if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) { + break + } + try { + refreshQrEventCache( + propertyId = propertyId, + bookingId = bookingId, + qrId = qrId + ) + delayMs = 4000L + } catch (_: Exception) { + delayMs = (delayMs * 1.5).toLong().coerceAtMost(15000L) + } + delay(delayMs) + } + } + } + + private fun stopQrEventPolling() { + qrPollJob?.cancel() + qrPollJob = null + } + + private fun observeRequestCache(propertyId: String, bookingId: String) { + val key = "$propertyId:$bookingId" + if (requestObserveKey == key && requestObserveJob?.isActive == true) return + stopRequestObservation() + requestObserveKey = key + requestObserveJob = viewModelScope.launch { + razorpayRepository.observeRequests(propertyId = propertyId, bookingId = bookingId).collect { items -> + _state.update { current -> current.copy(qrList = items) } + } + } + } + + private fun observeQrEventCache(propertyId: String, bookingId: String, qrId: String) { + val key = "$propertyId:$bookingId:$qrId" + if (eventObserveKey == key && eventObserveJob?.isActive == true) return + stopQrEventObservation() + eventObserveKey = key + eventObserveJob = viewModelScope.launch { + razorpayRepository.observeQrEvents( + propertyId = propertyId, + bookingId = bookingId, + qrId = qrId + ).collect { events -> + applyQrEventState( + propertyId = propertyId, + bookingId = bookingId, + events = events + ) + } + } + } + + private fun stopRequestObservation() { + requestObserveJob?.cancel() + requestObserveJob = null + requestObserveKey = null + } + + private fun stopQrEventObservation() { + eventObserveJob?.cancel() + eventObserveJob = null + eventObserveKey = null + } + + private suspend fun refreshRequestCache(propertyId: String, bookingId: String) { + val result = razorpayRepository.refreshRequests(propertyId = propertyId, bookingId = bookingId) + result.exceptionOrNull()?.let { throwable -> + _state.update { it.copy(error = throwable.localizedMessage ?: "Load failed") } + } + } + + private suspend fun refreshQrEventCache( + propertyId: String, + bookingId: String, + qrId: String + ): Result> = + razorpayRepository.refreshQrEvents( + propertyId = propertyId, + bookingId = bookingId, + qrId = qrId + ) + + private fun applyQrEventState( + propertyId: String, + bookingId: String, + events: List + ) { + if (events.isEmpty()) return + val isCredited = events.any { + it.event?.lowercase() == "qr_code.credited" || it.status?.lowercase() == "credited" + } + val isClosed = events.any { event -> + when (event.status?.lowercase()) { + "credited", "closed", "expired" -> true + else -> false + } + } + var becameCredited = false + _state.update { current -> + val nextCredited = current.isCredited || isCredited + val nextClosed = current.isClosed || isClosed || nextCredited + becameCredited = !current.isCredited && nextCredited + if (nextCredited == current.isCredited && nextClosed == current.isClosed) { + current + } else { + current.copy( + isCredited = nextCredited, + isClosed = nextClosed, + imageUrl = if (nextClosed) null else current.imageUrl + ) + } + } + if (becameCredited) { + stopQrEventStream() + stopQrEventPolling() + viewModelScope.launch { + syncPaymentCachesAfterCredit(propertyId = propertyId, bookingId = bookingId) + } + } else if (isClosed) { + stopQrEventStream() + } + } + + private suspend fun syncPaymentCachesAfterCredit(propertyId: String, bookingId: String) { + paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId) + bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId) + } + private fun buildDeviceInfo(): String { val release = android.os.Build.VERSION.RELEASE val model = android.os.Build.MODEL @@ -342,5 +442,8 @@ class RazorpayQrViewModel : ViewModel() { override fun onCleared() { super.onCleared() stopQrEventStream() + stopQrEventPolling() + stopRequestObservation() + stopQrEventObservation() } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt index fcee79a..cff31ff 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt @@ -3,7 +3,7 @@ package com.android.trisolarispms.ui.roomstay 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.local.bookinglist.BookingListRepository import com.android.trisolarispms.data.local.core.LocalDatabaseProvider import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository import kotlinx.coroutines.Job @@ -21,8 +21,14 @@ class ActiveRoomStaysViewModel( 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) } @@ -35,41 +41,24 @@ class ActiveRoomStaysViewModel( fun load(propertyId: String) { if (propertyId.isBlank()) return observeCache(propertyId = propertyId) + observeBookingLists(propertyId = propertyId) _state.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { val activeResult = repository.refresh(propertyId = propertyId) - val api = ApiClient.create() - val checkedInBookingsResponse = runCatching { - api.listBookings(propertyId = propertyId, status = "CHECKED_IN") - }.getOrNull() - val openBookingsResponse = runCatching { - api.listBookings(propertyId = propertyId, status = "OPEN") - }.getOrNull() - val checkedInBookings = checkedInBookingsResponse - ?.body() - .orEmpty() - .takeIf { checkedInBookingsResponse?.isSuccessful == true } - .orEmpty() - val openBookings = openBookingsResponse - ?.body() - .orEmpty() - .takeIf { openBookingsResponse?.isSuccessful == true } - .orEmpty() + val checkedInResult = bookingListRepository.refreshByStatus( + propertyId = propertyId, + status = "CHECKED_IN" + ) + val openResult = bookingListRepository.refreshByStatus( + propertyId = propertyId, + status = "OPEN" + ) val errorMessage = activeResult.exceptionOrNull()?.localizedMessage - ?: when { - checkedInBookingsResponse != null && !checkedInBookingsResponse.isSuccessful -> - "Load failed: ${checkedInBookingsResponse.code()}" - - openBookingsResponse != null && !openBookingsResponse.isSuccessful -> - "Load failed: ${openBookingsResponse.code()}" - - else -> null - } + ?: checkedInResult.exceptionOrNull()?.localizedMessage + ?: openResult.exceptionOrNull()?.localizedMessage _state.update { it.copy( isLoading = false, - checkedInBookings = checkedInBookings, - openBookings = openBookings, error = errorMessage ) } @@ -79,6 +68,8 @@ class ActiveRoomStaysViewModel( override fun onCleared() { super.onCleared() observeJob?.cancel() + observeCheckedInJob?.cancel() + observeOpenJob?.cancel() } 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) } + } + } + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index f126040..c21465f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -56,12 +56,9 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.SvgDecoder 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.FirebaseAuthTokenProvider 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.ui.booking.BookingDatePickerDialog import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard @@ -293,19 +290,16 @@ fun BookingDetailsTabsScreen( checkoutLoading.value = true checkoutError.value = null try { - val response = ApiClient.create().checkOut( + val result = detailsViewModel.checkoutBooking( propertyId = propertyId, - bookingId = bookingId, - body = BookingCheckOutRequest() + bookingId = bookingId ) - if (response.isSuccessful) { + if (result.isSuccess) { showCheckoutConfirm.value = false onBack() - } else if (response.code() == 409) { - checkoutError.value = - extractApiErrorMessage(response) ?: "Checkout conflict" } else { - checkoutError.value = "Checkout failed: ${response.code()}" + val message = result.exceptionOrNull()?.localizedMessage + checkoutError.value = message ?: "Checkout failed" } } catch (e: Exception) { checkoutError.value = e.localizedMessage ?: "Checkout failed" @@ -364,16 +358,16 @@ fun BookingDetailsTabsScreen( cancelLoading.value = true cancelError.value = null try { - val response = ApiClient.create().cancelBooking( + val result = detailsViewModel.cancelBooking( propertyId = propertyId, - bookingId = bookingId, - body = BookingCancelRequest() + bookingId = bookingId ) - if (response.isSuccessful) { + if (result.isSuccess) { showCancelConfirm.value = false onBack() } else { - cancelError.value = "Cancel failed: ${response.code()}" + val message = result.exceptionOrNull()?.localizedMessage + cancelError.value = message ?: "Cancel failed" } } catch (e: Exception) { cancelError.value = e.localizedMessage ?: "Cancel failed" diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt index e40953c..7d42840 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiConstants +import com.android.trisolarispms.data.api.model.BookingCancelRequest +import com.android.trisolarispms.data.api.model.BookingCheckOutRequest import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.local.booking.BookingDetailsRepository @@ -163,6 +165,41 @@ class BookingDetailsViewModel( return result } + suspend fun checkoutBooking( + propertyId: String, + bookingId: String + ): Result = 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 = runCatching { + val response = ApiClient.create().cancelBooking( + propertyId = propertyId, + bookingId = bookingId, + body = BookingCancelRequest() + ) + if (!response.isSuccessful) { + throw IllegalStateException("Cancel failed: ${response.code()}") + } + syncBookingCaches(propertyId = propertyId, bookingId = bookingId) + } + override fun onCleared() { super.onCleared() observeJob?.cancel() @@ -196,4 +233,12 @@ class BookingDetailsViewModel( startStream(propertyId, bookingId) } } + + private suspend fun syncBookingCaches(propertyId: String, bookingId: String) { + roomStayRepository.refresh(propertyId = propertyId).getOrThrow() + bookingRepository.refreshBookingDetails( + propertyId = propertyId, + bookingId = bookingId + ).getOrThrow() + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt index 28bf408..79b3154 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient 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 @@ -22,6 +23,9 @@ class BookingRoomStaysViewModel( 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 @@ -62,7 +66,14 @@ class BookingRoomStaysViewModel( ) when { 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 -> current.copy( checkingOutRoomStayId = null, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt index ba08947..0791a24 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStayRatesViewModel.kt @@ -1,17 +1,29 @@ package com.android.trisolarispms.ui.roomstay -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest import com.android.trisolarispms.data.api.model.BookingBulkCheckInStayRequest import com.android.trisolarispms.core.viewmodel.launchRequest +import com.android.trisolarispms.data.local.booking.BookingDetailsRepository +import com.android.trisolarispms.data.local.core.LocalDatabaseProvider +import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -class ManageRoomStayRatesViewModel : ViewModel() { +class ManageRoomStayRatesViewModel( + application: Application +) : AndroidViewModel(application) { private val _state = MutableStateFlow(ManageRoomStayRatesState()) val state: StateFlow = _state + private val roomStayRepository = ActiveRoomStayRepository( + dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao() + ) + private val bookingRepository = BookingDetailsRepository( + dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao() + ) fun setItems(items: List) { val mapped = items.map { item -> @@ -102,6 +114,11 @@ class ManageRoomStayRatesViewModel : ViewModel() { body = BookingBulkCheckInRequest(stays = stays) ) if (response.isSuccessful) { + roomStayRepository.refresh(propertyId = propertyId) + bookingRepository.refreshBookingDetails( + propertyId = propertyId, + bookingId = bookingId + ) _state.update { it.copy(isLoading = false, error = null) } onDone() } else {