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

View File

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

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
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<BookingCreateState> = _state
private val activeRoomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private val bookingListRepository = BookingListRepository(
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
)
private val bookingDetailsRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private var expectedCheckoutPreviewRequestId: Long = 0
private val fromCitySearch = CitySearchController(
scope = viewModelScope,
@@ -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 {

View File

@@ -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<BookingRoomRequestState> = _state
private val activeRoomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private val bookingListRepository = BookingListRepository(
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
)
private val bookingDetailsRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
fun load(propertyId: String, fromAt: String, toAt: String) {
if (propertyId.isBlank()) return
@@ -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? =

View File

@@ -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<GuestInfoState> = _state
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private val roomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private var nationalitySearchJob: Job? = null
private var phoneAutofillJob: Job? = null
private var lastAutofilledPhoneE164: String? = null
@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,43 @@
package com.android.trisolarispms.ui.payment
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RazorpayRefundRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.payment.PaymentRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.Response
class BookingPaymentsViewModel : ViewModel() {
class BookingPaymentsViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingPaymentsState())
val state: StateFlow<BookingPaymentsState> = _state
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private val paymentRepository = PaymentRepository(
dao = LocalDatabaseProvider.get(application).paymentCacheDao()
)
private var observeKey: String? = null
private var observePaymentsJob: Job? = null
private var observeBookingJob: Job? = null
fun load(propertyId: String, bookingId: String) {
runPaymentAction(defaultError = "Load failed") { api ->
val paymentsResponse = api.listPayments(propertyId, bookingId)
val payments = paymentsResponse.body()
if (paymentsResponse.isSuccessful && payments != null) {
val pending = api.getBookingBalance(propertyId, bookingId)
.body()
?.pending
_state.update {
it.copy(
isLoading = false,
payments = payments,
pendingBalance = pending,
error = null,
message = null
)
}
} else {
setActionFailure("Load", paymentsResponse)
}
if (propertyId.isBlank() || bookingId.isBlank()) return
observeCaches(propertyId = propertyId, bookingId = bookingId)
runPaymentAction(defaultError = "Load failed") {
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update { it.copy(isLoading = false, error = null, message = null) }
}
}
@@ -58,12 +61,10 @@ class BookingPaymentsViewModel : ViewModel() {
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
val nextPending = latestPending?.let { (it - amount).coerceAtLeast(0L) }
current.copy(
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update {
it.copy(
isLoading = false,
payments = listOf(body) + current.payments,
pendingBalance = nextPending ?: current.pendingBalance,
error = null,
message = "Cash payment added"
)
@@ -76,24 +77,16 @@ class BookingPaymentsViewModel : ViewModel() {
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
runPaymentAction(defaultError = "Delete failed") { api ->
val payment = _state.value.payments.firstOrNull { it.id == paymentId }
val response = api.deletePayment(
propertyId = propertyId,
bookingId = bookingId,
paymentId = paymentId
)
if (response.isSuccessful) {
_state.update { current ->
val restoredPending = if (payment?.method == "CASH") {
val amount = payment.amount ?: 0L
current.pendingBalance?.plus(amount)
} else {
current.pendingBalance
}
current.copy(
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update {
it.copy(
isLoading = false,
payments = current.payments.filterNot { it.id == paymentId },
pendingBalance = restoredPending,
error = null,
message = "Cash payment deleted"
)
@@ -133,7 +126,7 @@ class BookingPaymentsViewModel : ViewModel() {
)
val body = response.body()
if (response.isSuccessful && body != null) {
load(propertyId, bookingId)
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update {
it.copy(
isLoading = false,
@@ -183,4 +176,46 @@ class BookingPaymentsViewModel : ViewModel() {
if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)"
return null
}
override fun onCleared() {
super.onCleared()
observePaymentsJob?.cancel()
observeBookingJob?.cancel()
}
private fun observeCaches(propertyId: String, bookingId: String) {
val key = "$propertyId:$bookingId"
if (observeKey == key &&
observePaymentsJob?.isActive == true &&
observeBookingJob?.isActive == true
) {
return
}
observePaymentsJob?.cancel()
observeBookingJob?.cancel()
observeKey = key
observePaymentsJob = viewModelScope.launch {
paymentRepository.observeByBooking(propertyId = propertyId, bookingId = bookingId).collect { items ->
_state.update { current -> current.copy(payments = items) }
}
}
observeBookingJob = viewModelScope.launch {
bookingRepository.observeBookingDetails(
propertyId = propertyId,
bookingId = bookingId
).collect { details ->
_state.update { current ->
current.copy(pendingBalance = details?.pending ?: current.pendingBalance)
}
}
}
}
private suspend fun refreshPaymentCaches(propertyId: String, bookingId: String) {
paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId).getOrThrow()
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
).getOrThrow()
}
}

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.ui.razorpay
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants
@@ -9,37 +10,60 @@ import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.payment.PaymentRepository
import com.android.trisolarispms.data.local.razorpay.RazorpayCacheRepository
import com.google.gson.Gson
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.Request
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
class RazorpayQrViewModel : ViewModel() {
class RazorpayQrViewModel(
application: Application
) : AndroidViewModel(application) {
private val gson = Gson()
private val _state = MutableStateFlow(
RazorpayQrState(deviceInfo = buildDeviceInfo())
)
val state: StateFlow<RazorpayQrState> = _state
private val razorpayRepository = RazorpayCacheRepository(
dao = LocalDatabaseProvider.get(application).razorpayCacheDao()
)
private val paymentRepository = PaymentRepository(
dao = LocalDatabaseProvider.get(application).paymentCacheDao()
)
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private var qrEventSource: EventSource? = null
private var lastQrId: String? = null
private var qrPollJob: Job? = null
private var requestObserveKey: String? = null
private var requestObserveJob: Job? = null
private var eventObserveKey: String? = null
private var eventObserveJob: Job? = null
fun reset() {
stopQrEventStream()
stopQrEventPolling()
stopRequestObservation()
stopQrEventObservation()
_state.value = RazorpayQrState(deviceInfo = buildDeviceInfo())
}
fun exitQrView() {
stopQrEventStream()
stopQrEventPolling()
stopQrEventObservation()
_state.update {
it.copy(
qrId = null,
@@ -96,10 +120,11 @@ class RazorpayQrViewModel : ViewModel() {
currency = body.currency,
imageUrl = body.imageUrl,
isClosed = false,
isCredited = false,
error = null
)
}
loadQrList(propertyId, bookingId)
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
startQrEventStream(propertyId, bookingId, body.qrId)
} else {
_state.update {
@@ -120,127 +145,11 @@ class RazorpayQrViewModel : ViewModel() {
}
}
private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) {
if (qrId.isNullOrBlank()) return
if (lastQrId == qrId && qrEventSource != null) return
stopQrEventStream()
stopQrEventPolling()
lastQrId = qrId
val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream"
val request = Request.Builder().url(url).get().build()
qrEventSource = EventSources.createFactory(client).newEventSource(
request,
object : EventSourceListener() {
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String
) {
if (data.isBlank() || type == "ping") return
val event = runCatching {
gson.fromJson(data, RazorpayQrEventDto::class.java)
}.getOrNull() ?: return
val status = event.status?.lowercase()
val eventName = event.event?.lowercase()
if (eventName == "qr_code.credited" || status == "credited") {
_state.update { it.copy(isCredited = true, imageUrl = null) }
stopQrEventStream()
stopQrEventPolling()
return
}
if (isClosedStatus(status)) {
_state.update { it.copy(isClosed = true, imageUrl = null) }
stopQrEventStream()
}
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: okhttp3.Response?
) {
stopQrEventStream()
startQrEventPolling(propertyId, bookingId, qrId)
}
override fun onClosed(eventSource: EventSource) {
stopQrEventStream()
startQrEventPolling(propertyId, bookingId, qrId)
}
}
)
// Keep polling as a fallback in case SSE is buffered or never delivers events.
startQrEventPolling(propertyId, bookingId, qrId)
}
private fun stopQrEventStream() {
qrEventSource?.cancel()
qrEventSource = null
lastQrId = null
}
private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) {
if (qrPollJob?.isActive == true) return
qrPollJob = viewModelScope.launch {
var delayMs = 4000L
while (true) {
val currentQrId = state.value.qrId
if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) {
break
}
try {
val response = ApiClient.create().listRazorpayQrEvents(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId
)
val body = response.body()
if (response.isSuccessful && body != null) {
if (body.any { it.event?.lowercase() == "qr_code.credited" || it.status?.lowercase() == "credited" }) {
_state.update { it.copy(isCredited = true, imageUrl = null) }
stopQrEventStream()
break
}
if (body.any { isClosedStatus(it.status) }) {
_state.update { it.copy(isClosed = true, imageUrl = null) }
stopQrEventStream()
break
}
}
delayMs = 4000L
} catch (e: Exception) {
delayMs = (delayMs * 1.5).toLong().coerceAtMost(15000L)
}
delay(delayMs)
}
}
}
private fun stopQrEventPolling() {
qrPollJob?.cancel()
qrPollJob = null
}
private fun isClosedStatus(status: String?): Boolean {
return when (status?.lowercase()) {
"credited", "closed", "expired" -> true
else -> false
}
}
fun loadQrList(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
observeRequestCache(propertyId = propertyId, bookingId = bookingId)
viewModelScope.launch {
try {
val response = ApiClient.create().listRazorpayRequests(propertyId, bookingId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { it.copy(qrList = body) }
}
} catch (_: Exception) {
// ignore list load errors
}
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
}
}
@@ -256,10 +165,7 @@ class RazorpayQrViewModel : ViewModel() {
body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId)
)
if (response.isSuccessful) {
_state.update { current ->
current.copy(qrList = current.qrList.filterNot { it.requestId == item.requestId })
}
loadQrList(propertyId, bookingId)
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
}
} catch (_: Exception) {
// ignore close errors
@@ -313,7 +219,7 @@ class RazorpayQrViewModel : ViewModel() {
error = null
)
}
loadQrList(propertyId, bookingId)
refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
} else {
_state.update {
it.copy(
@@ -333,6 +239,200 @@ class RazorpayQrViewModel : ViewModel() {
}
}
private fun startQrEventStream(propertyId: String, bookingId: String, qrId: String?) {
if (qrId.isNullOrBlank()) return
observeQrEventCache(propertyId = propertyId, bookingId = bookingId, qrId = qrId)
if (lastQrId == qrId && qrEventSource != null) return
stopQrEventStream()
stopQrEventPolling()
lastQrId = qrId
val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
val url = "${ApiConstants.BASE_URL}properties/$propertyId/bookings/$bookingId/payments/razorpay/qr/$qrId/events/stream"
val request = Request.Builder().url(url).get().build()
qrEventSource = EventSources.createFactory(client).newEventSource(
request,
object : EventSourceListener() {
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String
) {
if (data.isBlank() || type == "ping") return
val event = runCatching { gson.fromJson(data, RazorpayQrEventDto::class.java) }
.getOrNull() ?: return
val eventQrId = event.qrId?.takeIf { it.isNotBlank() } ?: qrId
viewModelScope.launch {
refreshQrEventCache(
propertyId = propertyId,
bookingId = bookingId,
qrId = eventQrId
)
}
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: okhttp3.Response?
) {
stopQrEventStream()
startQrEventPolling(propertyId, bookingId, qrId)
}
override fun onClosed(eventSource: EventSource) {
stopQrEventStream()
startQrEventPolling(propertyId, bookingId, qrId)
}
}
)
// Keep polling as a fallback in case SSE is buffered or never delivers events.
startQrEventPolling(propertyId, bookingId, qrId)
}
private fun stopQrEventStream() {
qrEventSource?.cancel()
qrEventSource = null
lastQrId = null
}
private fun startQrEventPolling(propertyId: String, bookingId: String, qrId: String) {
if (qrPollJob?.isActive == true) return
qrPollJob = viewModelScope.launch {
var delayMs = 4000L
while (true) {
val currentQrId = state.value.qrId
if (currentQrId.isNullOrBlank() || currentQrId != qrId || state.value.isClosed) {
break
}
try {
refreshQrEventCache(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId
)
delayMs = 4000L
} catch (_: Exception) {
delayMs = (delayMs * 1.5).toLong().coerceAtMost(15000L)
}
delay(delayMs)
}
}
}
private fun stopQrEventPolling() {
qrPollJob?.cancel()
qrPollJob = null
}
private fun observeRequestCache(propertyId: String, bookingId: String) {
val key = "$propertyId:$bookingId"
if (requestObserveKey == key && requestObserveJob?.isActive == true) return
stopRequestObservation()
requestObserveKey = key
requestObserveJob = viewModelScope.launch {
razorpayRepository.observeRequests(propertyId = propertyId, bookingId = bookingId).collect { items ->
_state.update { current -> current.copy(qrList = items) }
}
}
}
private fun observeQrEventCache(propertyId: String, bookingId: String, qrId: String) {
val key = "$propertyId:$bookingId:$qrId"
if (eventObserveKey == key && eventObserveJob?.isActive == true) return
stopQrEventObservation()
eventObserveKey = key
eventObserveJob = viewModelScope.launch {
razorpayRepository.observeQrEvents(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId
).collect { events ->
applyQrEventState(
propertyId = propertyId,
bookingId = bookingId,
events = events
)
}
}
}
private fun stopRequestObservation() {
requestObserveJob?.cancel()
requestObserveJob = null
requestObserveKey = null
}
private fun stopQrEventObservation() {
eventObserveJob?.cancel()
eventObserveJob = null
eventObserveKey = null
}
private suspend fun refreshRequestCache(propertyId: String, bookingId: String) {
val result = razorpayRepository.refreshRequests(propertyId = propertyId, bookingId = bookingId)
result.exceptionOrNull()?.let { throwable ->
_state.update { it.copy(error = throwable.localizedMessage ?: "Load failed") }
}
}
private suspend fun refreshQrEventCache(
propertyId: String,
bookingId: String,
qrId: String
): Result<List<RazorpayQrEventDto>> =
razorpayRepository.refreshQrEvents(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId
)
private fun applyQrEventState(
propertyId: String,
bookingId: String,
events: List<RazorpayQrEventDto>
) {
if (events.isEmpty()) return
val isCredited = events.any {
it.event?.lowercase() == "qr_code.credited" || it.status?.lowercase() == "credited"
}
val isClosed = events.any { event ->
when (event.status?.lowercase()) {
"credited", "closed", "expired" -> true
else -> false
}
}
var becameCredited = false
_state.update { current ->
val nextCredited = current.isCredited || isCredited
val nextClosed = current.isClosed || isClosed || nextCredited
becameCredited = !current.isCredited && nextCredited
if (nextCredited == current.isCredited && nextClosed == current.isClosed) {
current
} else {
current.copy(
isCredited = nextCredited,
isClosed = nextClosed,
imageUrl = if (nextClosed) null else current.imageUrl
)
}
}
if (becameCredited) {
stopQrEventStream()
stopQrEventPolling()
viewModelScope.launch {
syncPaymentCachesAfterCredit(propertyId = propertyId, bookingId = bookingId)
}
} else if (isClosed) {
stopQrEventStream()
}
}
private suspend fun syncPaymentCachesAfterCredit(propertyId: String, bookingId: String) {
paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId)
bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId)
}
private fun buildDeviceInfo(): String {
val release = android.os.Build.VERSION.RELEASE
val model = android.os.Build.MODEL
@@ -342,5 +442,8 @@ class RazorpayQrViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
stopQrEventStream()
stopQrEventPolling()
stopRequestObservation()
stopQrEventObservation()
}
}

View File

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

View File

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

View File

@@ -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<Unit> = runCatching {
val response = ApiClient.create().checkOut(
propertyId = propertyId,
bookingId = bookingId,
body = BookingCheckOutRequest()
)
if (!response.isSuccessful) {
val message = if (response.code() == 409) {
extractApiErrorMessage(response) ?: "Checkout conflict"
} else {
"Checkout failed: ${response.code()}"
}
throw IllegalStateException(message)
}
syncBookingCaches(propertyId = propertyId, bookingId = bookingId)
}
suspend fun cancelBooking(
propertyId: String,
bookingId: String
): Result<Unit> = runCatching {
val response = ApiClient.create().cancelBooking(
propertyId = propertyId,
bookingId = bookingId,
body = BookingCancelRequest()
)
if (!response.isSuccessful) {
throw IllegalStateException("Cancel failed: ${response.code()}")
}
syncBookingCaches(propertyId = propertyId, bookingId = bookingId)
}
override fun onCleared() {
super.onCleared()
observeJob?.cancel()
@@ -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()
}
}

View File

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

View File

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