Compare commits

...

2 Commits

Author SHA1 Message Date
androidlover5842
f69a01a460 ai added more room db stuff 2026-02-08 19:54:35 +05:30
androidlover5842
1000f2411c add room db 2026-02-08 19:21:07 +05:30
41 changed files with 2418 additions and 418 deletions

View File

@@ -576,6 +576,14 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
- Imports/packages follow the structure above.
- Build passes: `./gradlew :app:compileDebugKotlin`.
### Room DB synchronization rule (mandatory)
- For any editable API-backed field, follow this write path only: `UI action -> server request -> Room DB update -> UI reacts from Room observers`.
- Server is source of truth; do not bypass server by writing final business state directly from UI.
- UI must render from Room-backed state, not from one-off API responses or direct text mutation.
- Avoid forced full refresh after each mutation unless strictly required by backend limitations; prefer targeted Room updates for linked entities.
- On mutation failure, keep prior DB state unchanged and surface error state to UI.
### Guest Documents Authorization (mandatory)
- View access: `ADMIN`, `MANAGER` (and super admin).

View File

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

View File

@@ -0,0 +1,30 @@
package com.android.trisolarispms.data.local.booking
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface BookingDetailsCacheDao {
@Query(
"""
SELECT * FROM booking_details_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
LIMIT 1
"""
)
fun observe(propertyId: String, bookingId: String): Flow<BookingDetailsCacheEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: BookingDetailsCacheEntity)
@Query(
"""
DELETE FROM booking_details_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
"""
)
suspend fun delete(propertyId: String, bookingId: String)
}

View File

@@ -0,0 +1,136 @@
package com.android.trisolarispms.data.local.booking
import androidx.room.Entity
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
@Entity(
tableName = "booking_details_cache",
primaryKeys = ["propertyId", "bookingId"]
)
data class BookingDetailsCacheEntity(
val propertyId: String,
val bookingId: String,
val detailsId: String? = null,
val status: String? = null,
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val guestNationality: String? = null,
val guestAge: String? = null,
val guestAddressText: String? = null,
val guestSignatureUrl: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val adultCount: Int? = null,
val maleCount: Int? = null,
val femaleCount: Int? = null,
val childCount: Int? = null,
val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null,
val totalNightlyRate: Long? = null,
val notes: String? = null,
val registeredByName: String? = null,
val registeredByPhone: String? = null,
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null,
val billableNights: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
internal fun BookingDetailsResponse.toCacheEntity(
propertyId: String,
bookingId: String
): BookingDetailsCacheEntity = BookingDetailsCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
detailsId = id,
status = status,
guestId = guestId,
guestName = guestName,
guestPhone = guestPhone,
guestNationality = guestNationality,
guestAge = guestAge,
guestAddressText = guestAddressText,
guestSignatureUrl = guestSignatureUrl,
vehicleNumbers = vehicleNumbers,
roomNumbers = roomNumbers,
source = source,
fromCity = fromCity,
toCity = toCity,
memberRelation = memberRelation,
transportMode = transportMode,
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt,
checkInAt = checkInAt,
checkOutAt = checkOutAt,
adultCount = adultCount,
maleCount = maleCount,
femaleCount = femaleCount,
childCount = childCount,
totalGuestCount = totalGuestCount,
expectedGuestCount = expectedGuestCount,
totalNightlyRate = totalNightlyRate,
notes = notes,
registeredByName = registeredByName,
registeredByPhone = registeredByPhone,
expectedPay = expectedPay,
amountCollected = amountCollected,
pending = pending,
billableNights = billableNights,
billingMode = billingMode,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime
)
internal fun BookingDetailsCacheEntity.toApiModel(): BookingDetailsResponse = BookingDetailsResponse(
id = detailsId ?: bookingId,
status = status,
guestId = guestId,
guestName = guestName,
guestPhone = guestPhone,
guestNationality = guestNationality,
guestAge = guestAge,
guestAddressText = guestAddressText,
guestSignatureUrl = guestSignatureUrl,
vehicleNumbers = vehicleNumbers,
roomNumbers = roomNumbers,
source = source,
fromCity = fromCity,
toCity = toCity,
memberRelation = memberRelation,
transportMode = transportMode,
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt,
checkInAt = checkInAt,
checkOutAt = checkOutAt,
adultCount = adultCount,
maleCount = maleCount,
femaleCount = femaleCount,
childCount = childCount,
totalGuestCount = totalGuestCount,
expectedGuestCount = expectedGuestCount,
totalNightlyRate = totalNightlyRate,
notes = notes,
registeredByName = registeredByName,
registeredByPhone = registeredByPhone,
expectedPay = expectedPay,
amountCollected = amountCollected,
pending = pending,
billableNights = billableNights,
billingMode = billingMode,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime
)

View File

@@ -0,0 +1,58 @@
package com.android.trisolarispms.data.local.booking
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class BookingDetailsRepository(
private val dao: BookingDetailsCacheDao,
private val createApi: () -> ApiService = { ApiClient.create() }
) {
fun observeBookingDetails(
propertyId: String,
bookingId: String
): Flow<BookingDetailsResponse?> =
dao.observe(propertyId = propertyId, bookingId = bookingId).map { cached ->
cached?.toApiModel()
}
suspend fun refreshBookingDetails(
propertyId: String,
bookingId: String
): Result<Unit> = runCatching {
val response = createApi().getBookingDetails(propertyId = propertyId, bookingId = bookingId)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val body = response.body() ?: throw IllegalStateException("Load failed: empty response")
dao.upsert(body.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
}
suspend fun updateExpectedDates(
propertyId: String,
bookingId: String,
body: BookingExpectedDatesRequest
): Result<Unit> = runCatching {
val api = createApi()
val response = api.updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = body
)
if (!response.isSuccessful) {
throw IllegalStateException("Update failed: ${response.code()}")
}
refreshBookingDetails(propertyId = propertyId, bookingId = bookingId).getOrThrow()
}
suspend fun storeSnapshot(
propertyId: String,
bookingId: String,
details: BookingDetailsResponse
) {
dao.upsert(details.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
}
}

View File

@@ -0,0 +1,43 @@
package com.android.trisolarispms.data.local.bookinglist
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface BookingListCacheDao {
@Query(
"""
SELECT * FROM booking_list_cache
WHERE propertyId = :propertyId AND status = :status
ORDER BY sortOrder ASC, bookingId ASC
"""
)
fun observeByStatus(propertyId: String, status: String): Flow<List<BookingListCacheEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<BookingListCacheEntity>)
@Query(
"""
DELETE FROM booking_list_cache
WHERE propertyId = :propertyId AND status = :status
"""
)
suspend fun deleteByPropertyAndStatus(propertyId: String, status: String)
@Transaction
suspend fun replaceForStatus(
propertyId: String,
status: String,
items: List<BookingListCacheEntity>
) {
deleteByPropertyAndStatus(propertyId = propertyId, status = status)
if (items.isNotEmpty()) {
upsertAll(items)
}
}
}

View File

@@ -0,0 +1,114 @@
package com.android.trisolarispms.data.local.bookinglist
import androidx.room.Entity
import androidx.room.Index
import com.android.trisolarispms.data.api.model.BookingListItem
@Entity(
tableName = "booking_list_cache",
primaryKeys = ["propertyId", "bookingId"],
indices = [
Index(value = ["propertyId"]),
Index(value = ["propertyId", "status"])
]
)
data class BookingListCacheEntity(
val propertyId: String,
val bookingId: String,
val status: String? = null,
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null,
val adultCount: Int? = null,
val childCount: Int? = null,
val maleCount: Int? = null,
val femaleCount: Int? = null,
val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null,
val notes: String? = null,
val pending: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null,
val sortOrder: Int = 0,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
internal fun BookingListItem.toCacheEntity(
propertyId: String,
sortOrder: Int
): BookingListCacheEntity? {
val safeBookingId = id?.trim()?.ifBlank { null } ?: return null
return BookingListCacheEntity(
propertyId = propertyId,
bookingId = safeBookingId,
status = status,
guestId = guestId,
guestName = guestName,
guestPhone = guestPhone,
vehicleNumbers = vehicleNumbers,
roomNumbers = roomNumbers,
source = source,
fromCity = fromCity,
toCity = toCity,
memberRelation = memberRelation,
transportMode = transportMode,
checkInAt = checkInAt,
checkOutAt = checkOutAt,
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt,
adultCount = adultCount,
childCount = childCount,
maleCount = maleCount,
femaleCount = femaleCount,
totalGuestCount = totalGuestCount,
expectedGuestCount = expectedGuestCount,
notes = notes,
pending = pending,
billingMode = billingMode,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime,
sortOrder = sortOrder
)
}
internal fun BookingListCacheEntity.toApiModel(): BookingListItem = BookingListItem(
id = bookingId,
status = status,
guestId = guestId,
guestName = guestName,
guestPhone = guestPhone,
vehicleNumbers = vehicleNumbers,
roomNumbers = roomNumbers,
source = source,
fromCity = fromCity,
toCity = toCity,
memberRelation = memberRelation,
transportMode = transportMode,
checkInAt = checkInAt,
checkOutAt = checkOutAt,
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt,
adultCount = adultCount,
childCount = childCount,
maleCount = maleCount,
femaleCount = femaleCount,
totalGuestCount = totalGuestCount,
expectedGuestCount = expectedGuestCount,
notes = notes,
pending = pending,
billingMode = billingMode,
billingCheckinTime = billingCheckinTime,
billingCheckoutTime = billingCheckoutTime
)

View File

@@ -0,0 +1,28 @@
package com.android.trisolarispms.data.local.bookinglist
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.BookingListItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class BookingListRepository(
private val dao: BookingListCacheDao,
private val createApi: () -> ApiService = { ApiClient.create() }
) {
fun observeByStatus(propertyId: String, status: String): Flow<List<BookingListItem>> =
dao.observeByStatus(propertyId = propertyId, status = status).map { rows ->
rows.map { it.toApiModel() }
}
suspend fun refreshByStatus(propertyId: String, status: String): Result<Unit> = runCatching {
val response = createApi().listBookings(propertyId = propertyId, status = status)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val rows = response.body().orEmpty().mapIndexedNotNull { index, booking ->
booking.toCacheEntity(propertyId = propertyId, sortOrder = index)
}
dao.replaceForStatus(propertyId = propertyId, status = status, items = rows)
}
}

View File

@@ -0,0 +1,260 @@
package com.android.trisolarispms.data.local.core
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheDao
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheEntity
import com.android.trisolarispms.data.local.bookinglist.BookingListCacheDao
import com.android.trisolarispms.data.local.bookinglist.BookingListCacheEntity
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentCacheDao
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentCacheEntity
import com.android.trisolarispms.data.local.payment.PaymentCacheDao
import com.android.trisolarispms.data.local.payment.PaymentCacheEntity
import com.android.trisolarispms.data.local.razorpay.RazorpayCacheDao
import com.android.trisolarispms.data.local.razorpay.RazorpayQrEventCacheEntity
import com.android.trisolarispms.data.local.razorpay.RazorpayRequestCacheEntity
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheDao
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheEntity
import androidx.room.migration.Migration
@Database(
entities = [
BookingDetailsCacheEntity::class,
ActiveRoomStayCacheEntity::class,
BookingListCacheEntity::class,
PaymentCacheEntity::class,
RazorpayRequestCacheEntity::class,
RazorpayQrEventCacheEntity::class,
GuestDocumentCacheEntity::class
],
version = 5,
exportSchema = false
)
@TypeConverters(RoomConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun bookingDetailsCacheDao(): BookingDetailsCacheDao
abstract fun activeRoomStayCacheDao(): ActiveRoomStayCacheDao
abstract fun bookingListCacheDao(): BookingListCacheDao
abstract fun paymentCacheDao(): PaymentCacheDao
abstract fun razorpayCacheDao(): RazorpayCacheDao
abstract fun guestDocumentCacheDao(): GuestDocumentCacheDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `active_room_stay_cache` (
`roomStayId` TEXT NOT NULL,
`propertyId` TEXT NOT NULL,
`bookingId` TEXT,
`guestId` TEXT,
`guestName` TEXT,
`guestPhone` TEXT,
`roomId` TEXT,
`roomNumber` INTEGER,
`roomTypeCode` TEXT,
`roomTypeName` TEXT,
`fromAt` TEXT,
`checkinAt` TEXT,
`expectedCheckoutAt` TEXT,
`nightlyRate` INTEGER,
`currency` TEXT,
`updatedAtEpochMs` INTEGER NOT NULL,
PRIMARY KEY(`roomStayId`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_active_room_stay_cache_propertyId`
ON `active_room_stay_cache` (`propertyId`)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_active_room_stay_cache_propertyId_bookingId`
ON `active_room_stay_cache` (`propertyId`, `bookingId`)
""".trimIndent()
)
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `booking_list_cache` (
`propertyId` TEXT NOT NULL,
`bookingId` TEXT NOT NULL,
`status` TEXT,
`guestId` TEXT,
`guestName` TEXT,
`guestPhone` TEXT,
`vehicleNumbers` TEXT NOT NULL,
`roomNumbers` TEXT NOT NULL,
`source` TEXT,
`fromCity` TEXT,
`toCity` TEXT,
`memberRelation` TEXT,
`transportMode` TEXT,
`checkInAt` TEXT,
`checkOutAt` TEXT,
`expectedCheckInAt` TEXT,
`expectedCheckOutAt` TEXT,
`adultCount` INTEGER,
`childCount` INTEGER,
`maleCount` INTEGER,
`femaleCount` INTEGER,
`totalGuestCount` INTEGER,
`expectedGuestCount` INTEGER,
`notes` TEXT,
`pending` INTEGER,
`billingMode` TEXT,
`billingCheckinTime` TEXT,
`billingCheckoutTime` TEXT,
`sortOrder` INTEGER NOT NULL,
`updatedAtEpochMs` INTEGER NOT NULL,
PRIMARY KEY(`propertyId`, `bookingId`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_booking_list_cache_propertyId`
ON `booking_list_cache` (`propertyId`)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_booking_list_cache_propertyId_status`
ON `booking_list_cache` (`propertyId`, `status`)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `payment_cache` (
`propertyId` TEXT NOT NULL,
`bookingId` TEXT NOT NULL,
`paymentId` TEXT NOT NULL,
`amount` INTEGER,
`currency` TEXT,
`method` TEXT,
`gatewayPaymentId` TEXT,
`gatewayTxnId` TEXT,
`bankRefNum` TEXT,
`mode` TEXT,
`pgType` TEXT,
`payerVpa` TEXT,
`payerName` TEXT,
`paymentSource` TEXT,
`reference` TEXT,
`notes` TEXT,
`receivedAt` TEXT,
`receivedByUserId` TEXT,
`sortOrder` INTEGER NOT NULL,
`updatedAtEpochMs` INTEGER NOT NULL,
PRIMARY KEY(`propertyId`, `bookingId`, `paymentId`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_payment_cache_propertyId_bookingId`
ON `payment_cache` (`propertyId`, `bookingId`)
""".trimIndent()
)
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `razorpay_request_cache` (
`propertyId` TEXT NOT NULL,
`bookingId` TEXT NOT NULL,
`requestKey` TEXT NOT NULL,
`type` TEXT,
`requestId` TEXT,
`amount` INTEGER,
`currency` TEXT,
`status` TEXT,
`createdAt` TEXT,
`qrId` TEXT,
`imageUrl` TEXT,
`expiryAt` TEXT,
`paymentLinkId` TEXT,
`paymentLink` TEXT,
`sortOrder` INTEGER NOT NULL,
`updatedAtEpochMs` INTEGER NOT NULL,
PRIMARY KEY(`propertyId`, `bookingId`, `requestKey`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_razorpay_request_cache_propertyId_bookingId`
ON `razorpay_request_cache` (`propertyId`, `bookingId`)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `razorpay_qr_event_cache` (
`propertyId` TEXT NOT NULL,
`bookingId` TEXT NOT NULL,
`qrId` TEXT NOT NULL,
`eventKey` TEXT NOT NULL,
`event` TEXT,
`status` TEXT,
`receivedAt` TEXT,
`sortOrder` INTEGER NOT NULL,
`updatedAtEpochMs` INTEGER NOT NULL,
PRIMARY KEY(`propertyId`, `bookingId`, `qrId`, `eventKey`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_razorpay_qr_event_cache_propertyId_bookingId_qrId`
ON `razorpay_qr_event_cache` (`propertyId`, `bookingId`, `qrId`)
""".trimIndent()
)
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `guest_document_cache` (
`propertyId` TEXT NOT NULL,
`guestId` TEXT NOT NULL,
`documentId` TEXT NOT NULL,
`bookingId` TEXT,
`uploadedByUserId` TEXT,
`uploadedAt` TEXT,
`originalFilename` TEXT,
`contentType` TEXT,
`sizeBytes` INTEGER,
`extractedDataJson` TEXT,
`extractedAt` TEXT,
`sortOrder` INTEGER NOT NULL,
`updatedAtEpochMs` INTEGER NOT NULL,
PRIMARY KEY(`propertyId`, `guestId`, `documentId`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS `index_guest_document_cache_propertyId_guestId`
ON `guest_document_cache` (`propertyId`, `guestId`)
""".trimIndent()
)
}
}
}
}

View File

@@ -0,0 +1,27 @@
package com.android.trisolarispms.data.local.core
import android.content.Context
import androidx.room.Room
object LocalDatabaseProvider {
@Volatile
private var instance: AppDatabase? = null
fun get(context: Context): AppDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"trisolaris_pms_local.db"
)
.addMigrations(AppDatabase.MIGRATION_1_2)
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_4)
.addMigrations(AppDatabase.MIGRATION_4_5)
.build()
.also { built ->
instance = built
}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.android.trisolarispms.data.local.core
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class RoomConverters {
private val gson = Gson()
private val stringListType = object : TypeToken<List<String>>() {}.type
private val intListType = object : TypeToken<List<Int>>() {}.type
@TypeConverter
fun fromStringList(value: List<String>?): String = gson.toJson(value.orEmpty())
@TypeConverter
fun toStringList(value: String?): List<String> {
if (value.isNullOrBlank()) return emptyList()
return runCatching { gson.fromJson<List<String>>(value, stringListType) }.getOrDefault(emptyList())
}
@TypeConverter
fun fromIntList(value: List<Int>?): String = gson.toJson(value.orEmpty())
@TypeConverter
fun toIntList(value: String?): List<Int> {
if (value.isNullOrBlank()) return emptyList()
return runCatching { gson.fromJson<List<Int>>(value, intListType) }.getOrDefault(emptyList())
}
}

View File

@@ -0,0 +1,43 @@
package com.android.trisolarispms.data.local.guestdoc
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface GuestDocumentCacheDao {
@Query(
"""
SELECT * FROM guest_document_cache
WHERE propertyId = :propertyId AND guestId = :guestId
ORDER BY sortOrder ASC, documentId ASC
"""
)
fun observeByGuest(propertyId: String, guestId: String): Flow<List<GuestDocumentCacheEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<GuestDocumentCacheEntity>)
@Query(
"""
DELETE FROM guest_document_cache
WHERE propertyId = :propertyId AND guestId = :guestId
"""
)
suspend fun deleteByGuest(propertyId: String, guestId: String)
@Transaction
suspend fun replaceForGuest(
propertyId: String,
guestId: String,
items: List<GuestDocumentCacheEntity>
) {
deleteByGuest(propertyId = propertyId, guestId = guestId)
if (items.isNotEmpty()) {
upsertAll(items)
}
}
}

View File

@@ -0,0 +1,76 @@
package com.android.trisolarispms.data.local.guestdoc
import androidx.room.Entity
import androidx.room.Index
import com.android.trisolarispms.data.api.model.GuestDocumentDto
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
@Entity(
tableName = "guest_document_cache",
primaryKeys = ["propertyId", "guestId", "documentId"],
indices = [
Index(value = ["propertyId", "guestId"])
]
)
data class GuestDocumentCacheEntity(
val propertyId: String,
val guestId: String,
val documentId: String,
val bookingId: String? = null,
val uploadedByUserId: String? = null,
val uploadedAt: String? = null,
val originalFilename: String? = null,
val contentType: String? = null,
val sizeBytes: Long? = null,
val extractedDataJson: String? = null,
val extractedAt: String? = null,
val sortOrder: Int = 0,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
private val extractedDataType = object : TypeToken<Map<String, String>>() {}.type
internal fun GuestDocumentDto.toCacheEntity(
propertyId: String,
guestId: String,
sortOrder: Int
): GuestDocumentCacheEntity? {
val safeDocumentId = id?.trim()?.ifBlank { null } ?: return null
val extractedJson = runCatching { Gson().toJson(extractedData ?: emptyMap<String, String>()) }
.getOrNull()
return GuestDocumentCacheEntity(
propertyId = propertyId,
guestId = guestId,
documentId = safeDocumentId,
bookingId = bookingId,
uploadedByUserId = uploadedByUserId,
uploadedAt = uploadedAt,
originalFilename = originalFilename,
contentType = contentType,
sizeBytes = sizeBytes,
extractedDataJson = extractedJson,
extractedAt = extractedAt,
sortOrder = sortOrder
)
}
internal fun GuestDocumentCacheEntity.toApiModel(): GuestDocumentDto {
val extractedMap = runCatching {
if (extractedDataJson.isNullOrBlank()) emptyMap<String, String>()
else Gson().fromJson<Map<String, String>>(extractedDataJson, extractedDataType)
}.getOrElse { emptyMap() }
return GuestDocumentDto(
id = documentId,
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
uploadedByUserId = uploadedByUserId,
uploadedAt = uploadedAt,
originalFilename = originalFilename,
contentType = contentType,
sizeBytes = sizeBytes,
extractedData = extractedMap,
extractedAt = extractedAt
)
}

View File

@@ -0,0 +1,47 @@
package com.android.trisolarispms.data.local.guestdoc
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.GuestDocumentDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GuestDocumentRepository(
private val dao: GuestDocumentCacheDao,
private val createApi: () -> ApiService = { ApiClient.create() }
) {
fun observeByGuest(propertyId: String, guestId: String): Flow<List<GuestDocumentDto>> =
dao.observeByGuest(propertyId = propertyId, guestId = guestId).map { rows ->
rows.map { it.toApiModel() }
}
suspend fun refresh(propertyId: String, guestId: String): Result<Unit> = runCatching {
val response = createApi().listGuestDocuments(propertyId = propertyId, guestId = guestId)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val rows = response.body().orEmpty().mapIndexedNotNull { index, doc ->
doc.toCacheEntity(
propertyId = propertyId,
guestId = guestId,
sortOrder = index
)
}
dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows)
}
suspend fun storeSnapshot(
propertyId: String,
guestId: String,
documents: List<GuestDocumentDto>
) {
val rows = documents.mapIndexedNotNull { index, doc ->
doc.toCacheEntity(
propertyId = propertyId,
guestId = guestId,
sortOrder = index
)
}
dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows)
}
}

View File

@@ -0,0 +1,43 @@
package com.android.trisolarispms.data.local.payment
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface PaymentCacheDao {
@Query(
"""
SELECT * FROM payment_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
ORDER BY sortOrder ASC, paymentId ASC
"""
)
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<PaymentCacheEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<PaymentCacheEntity>)
@Query(
"""
DELETE FROM payment_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
"""
)
suspend fun deleteByBooking(propertyId: String, bookingId: String)
@Transaction
suspend fun replaceForBooking(
propertyId: String,
bookingId: String,
items: List<PaymentCacheEntity>
) {
deleteByBooking(propertyId = propertyId, bookingId = bookingId)
if (items.isNotEmpty()) {
upsertAll(items)
}
}
}

View File

@@ -0,0 +1,84 @@
package com.android.trisolarispms.data.local.payment
import androidx.room.Entity
import androidx.room.Index
import com.android.trisolarispms.data.api.model.PaymentDto
@Entity(
tableName = "payment_cache",
primaryKeys = ["propertyId", "bookingId", "paymentId"],
indices = [
Index(value = ["propertyId", "bookingId"])
]
)
data class PaymentCacheEntity(
val propertyId: String,
val bookingId: String,
val paymentId: String,
val amount: Long? = null,
val currency: String? = null,
val method: String? = null,
val gatewayPaymentId: String? = null,
val gatewayTxnId: String? = null,
val bankRefNum: String? = null,
val mode: String? = null,
val pgType: String? = null,
val payerVpa: String? = null,
val payerName: String? = null,
val paymentSource: String? = null,
val reference: String? = null,
val notes: String? = null,
val receivedAt: String? = null,
val receivedByUserId: String? = null,
val sortOrder: Int = 0,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
internal fun PaymentDto.toCacheEntity(
propertyId: String,
bookingId: String,
sortOrder: Int
): PaymentCacheEntity? {
val safePaymentId = id?.trim()?.ifBlank { null } ?: return null
return PaymentCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
paymentId = safePaymentId,
amount = amount,
currency = currency,
method = method,
gatewayPaymentId = gatewayPaymentId,
gatewayTxnId = gatewayTxnId,
bankRefNum = bankRefNum,
mode = mode,
pgType = pgType,
payerVpa = payerVpa,
payerName = payerName,
paymentSource = paymentSource,
reference = reference,
notes = notes,
receivedAt = receivedAt,
receivedByUserId = receivedByUserId,
sortOrder = sortOrder
)
}
internal fun PaymentCacheEntity.toApiModel(): PaymentDto = PaymentDto(
id = paymentId,
bookingId = bookingId,
amount = amount,
currency = currency,
method = method,
gatewayPaymentId = gatewayPaymentId,
gatewayTxnId = gatewayTxnId,
bankRefNum = bankRefNum,
mode = mode,
pgType = pgType,
payerVpa = payerVpa,
payerName = payerName,
paymentSource = paymentSource,
reference = reference,
notes = notes,
receivedAt = receivedAt,
receivedByUserId = receivedByUserId
)

View File

@@ -0,0 +1,32 @@
package com.android.trisolarispms.data.local.payment
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.PaymentDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class PaymentRepository(
private val dao: PaymentCacheDao,
private val createApi: () -> ApiService = { ApiClient.create() }
) {
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<PaymentDto>> =
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
rows.map { it.toApiModel() }
}
suspend fun refresh(propertyId: String, bookingId: String): Result<Unit> = runCatching {
val response = createApi().listPayments(propertyId = propertyId, bookingId = bookingId)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val rows = response.body().orEmpty().mapIndexedNotNull { index, payment ->
payment.toCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
sortOrder = index
)
}
dao.replaceForBooking(propertyId = propertyId, bookingId = bookingId, items = rows)
}
}

View File

@@ -0,0 +1,80 @@
package com.android.trisolarispms.data.local.razorpay
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface RazorpayCacheDao {
@Query(
"""
SELECT * FROM razorpay_request_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
ORDER BY sortOrder ASC, requestKey ASC
"""
)
fun observeRequests(propertyId: String, bookingId: String): Flow<List<RazorpayRequestCacheEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRequests(items: List<RazorpayRequestCacheEntity>)
@Query(
"""
DELETE FROM razorpay_request_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
"""
)
suspend fun deleteRequests(propertyId: String, bookingId: String)
@Transaction
suspend fun replaceRequests(
propertyId: String,
bookingId: String,
items: List<RazorpayRequestCacheEntity>
) {
deleteRequests(propertyId = propertyId, bookingId = bookingId)
if (items.isNotEmpty()) {
upsertRequests(items)
}
}
@Query(
"""
SELECT * FROM razorpay_qr_event_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId AND qrId = :qrId
ORDER BY sortOrder ASC, eventKey ASC
"""
)
fun observeQrEvents(
propertyId: String,
bookingId: String,
qrId: String
): Flow<List<RazorpayQrEventCacheEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertQrEvents(items: List<RazorpayQrEventCacheEntity>)
@Query(
"""
DELETE FROM razorpay_qr_event_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId AND qrId = :qrId
"""
)
suspend fun deleteQrEvents(propertyId: String, bookingId: String, qrId: String)
@Transaction
suspend fun replaceQrEvents(
propertyId: String,
bookingId: String,
qrId: String,
items: List<RazorpayQrEventCacheEntity>
) {
deleteQrEvents(propertyId = propertyId, bookingId = bookingId, qrId = qrId)
if (items.isNotEmpty()) {
upsertQrEvents(items)
}
}
}

View File

@@ -0,0 +1,80 @@
package com.android.trisolarispms.data.local.razorpay
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RazorpayCacheRepository(
private val dao: RazorpayCacheDao,
private val createApi: () -> ApiService = { ApiClient.create() }
) {
fun observeRequests(
propertyId: String,
bookingId: String
): Flow<List<RazorpayRequestListItemDto>> =
dao.observeRequests(propertyId = propertyId, bookingId = bookingId).map { rows ->
rows.map { it.toApiModel() }
}
fun observeQrEvents(
propertyId: String,
bookingId: String,
qrId: String
): Flow<List<RazorpayQrEventDto>> =
dao.observeQrEvents(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId
).map { rows ->
rows.map { it.toApiModel() }
}
suspend fun refreshRequests(propertyId: String, bookingId: String): Result<Unit> = runCatching {
val response = createApi().listRazorpayRequests(propertyId = propertyId, bookingId = bookingId)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val rows = response.body().orEmpty().mapIndexed { index, item ->
item.toCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
sortOrder = index
)
}
dao.replaceRequests(propertyId = propertyId, bookingId = bookingId, items = rows)
}
suspend fun refreshQrEvents(
propertyId: String,
bookingId: String,
qrId: String
): Result<List<RazorpayQrEventDto>> = runCatching {
val response = createApi().listRazorpayQrEvents(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId
)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val body = response.body().orEmpty()
val rows = body.mapIndexed { index, item ->
item.toCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId,
sortOrder = index
)
}
dao.replaceQrEvents(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId,
items = rows
)
body
}
}

View File

@@ -0,0 +1,50 @@
package com.android.trisolarispms.data.local.razorpay
import androidx.room.Entity
import androidx.room.Index
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
@Entity(
tableName = "razorpay_qr_event_cache",
primaryKeys = ["propertyId", "bookingId", "qrId", "eventKey"],
indices = [
Index(value = ["propertyId", "bookingId", "qrId"])
]
)
data class RazorpayQrEventCacheEntity(
val propertyId: String,
val bookingId: String,
val qrId: String,
val eventKey: String,
val event: String? = null,
val status: String? = null,
val receivedAt: String? = null,
val sortOrder: Int = 0,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
internal fun RazorpayQrEventDto.toCacheEntity(
propertyId: String,
bookingId: String,
qrId: String,
sortOrder: Int
): RazorpayQrEventCacheEntity {
val key = "${receivedAt.orEmpty()}:${event.orEmpty()}:${status.orEmpty()}:$sortOrder"
return RazorpayQrEventCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
qrId = qrId,
eventKey = key,
event = event,
status = status,
receivedAt = receivedAt,
sortOrder = sortOrder
)
}
internal fun RazorpayQrEventCacheEntity.toApiModel(): RazorpayQrEventDto = RazorpayQrEventDto(
event = event,
qrId = qrId,
status = status,
receivedAt = receivedAt
)

View File

@@ -0,0 +1,73 @@
package com.android.trisolarispms.data.local.razorpay
import androidx.room.Entity
import androidx.room.Index
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
@Entity(
tableName = "razorpay_request_cache",
primaryKeys = ["propertyId", "bookingId", "requestKey"],
indices = [
Index(value = ["propertyId", "bookingId"])
]
)
data class RazorpayRequestCacheEntity(
val propertyId: String,
val bookingId: String,
val requestKey: String,
val type: String? = null,
val requestId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val status: String? = null,
val createdAt: String? = null,
val qrId: String? = null,
val imageUrl: String? = null,
val expiryAt: String? = null,
val paymentLinkId: String? = null,
val paymentLink: String? = null,
val sortOrder: Int = 0,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
internal fun RazorpayRequestListItemDto.toCacheEntity(
propertyId: String,
bookingId: String,
sortOrder: Int
): RazorpayRequestCacheEntity {
val key = requestId?.trim()?.ifBlank { null }
?: qrId?.trim()?.ifBlank { null }?.let { "qr:$it" }
?: paymentLinkId?.trim()?.ifBlank { null }?.let { "plink:$it" }
?: "idx:$sortOrder:${createdAt.orEmpty()}:${type.orEmpty()}"
return RazorpayRequestCacheEntity(
propertyId = propertyId,
bookingId = bookingId,
requestKey = key,
type = type,
requestId = requestId,
amount = amount,
currency = currency,
status = status,
createdAt = createdAt,
qrId = qrId,
imageUrl = imageUrl,
expiryAt = expiryAt,
paymentLinkId = paymentLinkId,
paymentLink = paymentLink,
sortOrder = sortOrder
)
}
internal fun RazorpayRequestCacheEntity.toApiModel(): RazorpayRequestListItemDto = RazorpayRequestListItemDto(
type = type,
requestId = requestId,
amount = amount,
currency = currency,
status = status,
createdAt = createdAt,
qrId = qrId,
imageUrl = imageUrl,
expiryAt = expiryAt,
paymentLinkId = paymentLinkId,
paymentLink = paymentLink
)

View File

@@ -0,0 +1,61 @@
package com.android.trisolarispms.data.local.roomstay
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface ActiveRoomStayCacheDao {
@Query(
"""
SELECT * FROM active_room_stay_cache
WHERE propertyId = :propertyId
ORDER BY roomNumber ASC, roomStayId ASC
"""
)
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayCacheEntity>>
@Query(
"""
SELECT * FROM active_room_stay_cache
WHERE propertyId = :propertyId AND bookingId = :bookingId
ORDER BY roomNumber ASC, roomStayId ASC
"""
)
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayCacheEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<ActiveRoomStayCacheEntity>)
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId")
suspend fun deleteByProperty(propertyId: String)
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId AND roomStayId = :roomStayId")
suspend fun deleteByRoomStay(propertyId: String, roomStayId: String)
@Query(
"""
UPDATE active_room_stay_cache
SET expectedCheckoutAt = :expectedCheckoutAt,
updatedAtEpochMs = :updatedAtEpochMs
WHERE propertyId = :propertyId AND bookingId = :bookingId
"""
)
suspend fun updateExpectedCheckoutAtForBooking(
propertyId: String,
bookingId: String,
expectedCheckoutAt: String?,
updatedAtEpochMs: Long
)
@Transaction
suspend fun replaceForProperty(propertyId: String, items: List<ActiveRoomStayCacheEntity>) {
deleteByProperty(propertyId)
if (items.isNotEmpty()) {
upsertAll(items)
}
}
}

View File

@@ -0,0 +1,71 @@
package com.android.trisolarispms.data.local.roomstay
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
@Entity(
tableName = "active_room_stay_cache",
indices = [
Index(value = ["propertyId"]),
Index(value = ["propertyId", "bookingId"])
]
)
data class ActiveRoomStayCacheEntity(
@PrimaryKey
val roomStayId: String,
val propertyId: String,
val bookingId: String? = null,
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val roomId: String? = null,
val roomNumber: Int? = null,
val roomTypeCode: String? = null,
val roomTypeName: String? = null,
val fromAt: String? = null,
val checkinAt: String? = null,
val expectedCheckoutAt: String? = null,
val nightlyRate: Long? = null,
val currency: String? = null,
val updatedAtEpochMs: Long = System.currentTimeMillis()
)
internal fun ActiveRoomStayDto.toCacheEntity(propertyId: String): ActiveRoomStayCacheEntity? {
val stayId = roomStayId?.trim()?.ifBlank { null } ?: return null
return ActiveRoomStayCacheEntity(
roomStayId = stayId,
propertyId = propertyId,
bookingId = bookingId,
guestId = guestId,
guestName = guestName,
guestPhone = guestPhone,
roomId = roomId,
roomNumber = roomNumber,
roomTypeCode = roomTypeCode,
roomTypeName = roomTypeName,
fromAt = fromAt,
checkinAt = checkinAt,
expectedCheckoutAt = expectedCheckoutAt,
nightlyRate = nightlyRate,
currency = currency
)
}
internal fun ActiveRoomStayCacheEntity.toApiModel(): ActiveRoomStayDto = ActiveRoomStayDto(
roomStayId = roomStayId,
bookingId = bookingId,
guestId = guestId,
guestName = guestName,
guestPhone = guestPhone,
roomId = roomId,
roomNumber = roomNumber,
roomTypeCode = roomTypeCode,
roomTypeName = roomTypeName,
fromAt = fromAt,
checkinAt = checkinAt,
expectedCheckoutAt = expectedCheckoutAt,
nightlyRate = nightlyRate,
currency = currency
)

View File

@@ -0,0 +1,48 @@
package com.android.trisolarispms.data.local.roomstay
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ActiveRoomStayRepository(
private val dao: ActiveRoomStayCacheDao,
private val createApi: () -> ApiService = { ApiClient.create() }
) {
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayDto>> =
dao.observeByProperty(propertyId = propertyId).map { rows ->
rows.map { it.toApiModel() }
}
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayDto>> =
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
rows.map { it.toApiModel() }
}
suspend fun refresh(propertyId: String): Result<Unit> = runCatching {
val response = createApi().listActiveRoomStays(propertyId = propertyId)
if (!response.isSuccessful) {
throw IllegalStateException("Load failed: ${response.code()}")
}
val rows = response.body().orEmpty().mapNotNull { it.toCacheEntity(propertyId = propertyId) }
dao.replaceForProperty(propertyId = propertyId, items = rows)
}
suspend fun removeFromCache(propertyId: String, roomStayId: String) {
dao.deleteByRoomStay(propertyId = propertyId, roomStayId = roomStayId)
}
suspend fun patchExpectedCheckoutForBooking(
propertyId: String,
bookingId: String,
expectedCheckoutAt: String?
) {
dao.updateExpectedCheckoutAtForBooking(
propertyId = propertyId,
bookingId = bookingId,
expectedCheckoutAt = expectedCheckoutAt,
updatedAtEpochMs = System.currentTimeMillis()
)
}
}

View File

@@ -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) {
_state.update {
it.copy(
isLoading = false,
documents = body,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed"
)
}
val result = repository.refresh(propertyId = propertyId, guestId = guestId)
_state.update {
it.copy(
isLoading = false,
error = result.exceptionOrNull()?.localizedMessage
)
}
}
}
fun startStream(propertyId: String, guestId: String) {
if (propertyId.isBlank() || guestId.isBlank()) return
observeCache(propertyId = propertyId, guestId = guestId)
val key = "$propertyId:$guestId"
if (streamKey == key && eventSource != null) return
stopStream()
streamKey = key
_state.update { it.copy(isLoading = true, error = null, documents = emptyList()) }
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
repository.refresh(propertyId = propertyId, guestId = guestId)
_state.update { it.copy(isLoading = false) }
}
val client = ApiClient.createOkHttpClient(readTimeoutSeconds = 0)
val url = "${ApiConstants.BASE_URL}properties/$propertyId/guests/$guestId/documents/stream"
val request = Request.Builder().url(url).get().build()
@@ -91,19 +90,16 @@ class GuestDocumentsViewModel : ViewModel() {
data: String
) {
if (data.isBlank() || type == "ping") return
val docs = try {
val docs = runCatching {
gson.fromJson(data, Array<GuestDocumentDto>::class.java)?.toList()
} catch (_: Exception) {
null
}
if (docs != null) {
_state.update {
it.copy(
isLoading = false,
documents = docs,
error = null
)
}
}.getOrNull() ?: return
viewModelScope.launch {
repository.storeSnapshot(
propertyId = propertyId,
guestId = guestId,
documents = docs
)
_state.update { current -> current.copy(isLoading = false, error = null) }
}
}
@@ -126,7 +122,6 @@ class GuestDocumentsViewModel : ViewModel() {
}
}
)
_state.update { it.copy(isLoading = false) }
}
fun stopStream() {
@@ -166,49 +161,43 @@ class GuestDocumentsViewModel : ViewModel() {
}
val filename = resolveFileName(resolver, uri) ?: "document"
val file = copyToCache(resolver, uri, context.cacheDir, filename)
uploadFile(propertyId, guestId, bookingId, file, mime)
val uploadError = uploadFile(propertyId, guestId, bookingId, file, mime)
if (uploadError != null) {
errorMessage = uploadError
}
} catch (e: Exception) {
errorMessage = e.localizedMessage ?: "Upload failed"
}
}
_state.update { current ->
current.copy(
_state.update {
it.copy(
isUploading = false,
error = errorMessage ?: current.error
error = errorMessage
)
}
}
}
fun deleteDocument(propertyId: String, guestId: String, documentId: String) {
if (propertyId.isBlank() || guestId.isBlank() || documentId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteGuestDocument(propertyId, guestId, documentId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
error = null,
documents = current.documents.filterNot { it.id == documentId }
)
}
} else {
val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId)
_state.update {
it.copy(
isLoading = false,
error = "Delete failed: ${response.code()}"
error = refreshResult.exceptionOrNull()?.localizedMessage
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Delete failed"
)
}
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
}
}
}
@@ -222,15 +211,12 @@ class GuestDocumentsViewModel : ViewModel() {
) {
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
try {
uploadFile(propertyId, guestId, bookingId, file, mimeType)
} catch (e: Exception) {
_state.update {
it.copy(
isUploading = false,
error = e.localizedMessage ?: "Upload failed"
)
}
val uploadError = uploadFile(propertyId, guestId, bookingId, file, mimeType)
_state.update {
it.copy(
isUploading = false,
error = uploadError
)
}
}
}
@@ -241,7 +227,7 @@ class GuestDocumentsViewModel : ViewModel() {
bookingId: String,
file: File,
mimeType: String
) {
): String? {
val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val api = ApiClient.create()
@@ -251,22 +237,29 @@ class GuestDocumentsViewModel : ViewModel() {
bookingId = bookingId,
file = part
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { it.copy(isUploading = false, error = null) }
} else if (response.code() == 409) {
_state.update {
it.copy(
isUploading = false,
error = "Duplicate document"
)
}
} else {
_state.update {
it.copy(
isUploading = false,
error = "Upload failed: ${response.code()}"
)
if (response.isSuccessful && response.body() != null) {
val refreshResult = repository.refresh(propertyId = propertyId, guestId = guestId)
return refreshResult.exceptionOrNull()?.localizedMessage
}
if (response.code() == 409) {
return "Duplicate document"
}
return "Upload failed: ${response.code()}"
}
private fun observeCache(propertyId: String, guestId: String) {
val key = "$propertyId:$guestId"
if (observeKey == key && observeJob?.isActive == true) return
observeJob?.cancel()
observeKey = key
observeJob = viewModelScope.launch {
repository.observeByGuest(propertyId = propertyId, guestId = guestId).collect { docs ->
_state.update { current ->
current.copy(
documents = docs,
isLoading = if (docs.isNotEmpty()) false else current.isLoading
)
}
}
}
}
@@ -296,6 +289,7 @@ class GuestDocumentsViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
observeJob?.cancel()
stopStream()
}
}

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

@@ -1,15 +1,34 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ActiveRoomStaysViewModel : ViewModel() {
class ActiveRoomStaysViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(ActiveRoomStaysState())
val state: StateFlow<ActiveRoomStaysState> = _state
private val repository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private val bookingListRepository = BookingListRepository(
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
)
private var observeJob: Job? = null
private var observePropertyId: String? = null
private var observeBookingListPropertyId: String? = null
private var observeCheckedInJob: Job? = null
private var observeOpenJob: Job? = null
fun toggleShowOpenBookings() {
_state.update { it.copy(showOpenBookings = !it.showOpenBookings) }
@@ -21,28 +40,78 @@ class ActiveRoomStaysViewModel : ViewModel() {
fun load(propertyId: String) {
if (propertyId.isBlank()) return
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create()
val activeResponse = api.listActiveRoomStays(propertyId)
val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN")
val openBookingsResponse = api.listBookings(propertyId, status = "OPEN")
if (activeResponse.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
items = activeResponse.body().orEmpty(),
checkedInBookings = bookingsResponse.body().orEmpty(),
openBookings = openBookingsResponse.body().orEmpty(),
error = null
observeCache(propertyId = propertyId)
observeBookingLists(propertyId = propertyId)
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
val activeResult = repository.refresh(propertyId = propertyId)
val checkedInResult = bookingListRepository.refreshByStatus(
propertyId = propertyId,
status = "CHECKED_IN"
)
val openResult = bookingListRepository.refreshByStatus(
propertyId = propertyId,
status = "OPEN"
)
val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
?: checkedInResult.exceptionOrNull()?.localizedMessage
?: openResult.exceptionOrNull()?.localizedMessage
_state.update {
it.copy(
isLoading = false,
error = errorMessage
)
}
}
}
override fun onCleared() {
super.onCleared()
observeJob?.cancel()
observeCheckedInJob?.cancel()
observeOpenJob?.cancel()
}
private fun observeCache(propertyId: String) {
if (observePropertyId == propertyId && observeJob?.isActive == true) return
observeJob?.cancel()
observePropertyId = propertyId
observeJob = viewModelScope.launch {
repository.observeByProperty(propertyId = propertyId).collect { items ->
_state.update { current ->
current.copy(
items = items,
isLoading = if (items.isNotEmpty()) false else current.isLoading
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${activeResponse.code()}") }
}
}
}
private fun observeBookingLists(propertyId: String) {
if (observeBookingListPropertyId == propertyId &&
observeCheckedInJob?.isActive == true &&
observeOpenJob?.isActive == true
) {
return
}
observeCheckedInJob?.cancel()
observeOpenJob?.cancel()
observeBookingListPropertyId = propertyId
observeCheckedInJob = viewModelScope.launch {
bookingListRepository.observeByStatus(
propertyId = propertyId,
status = "CHECKED_IN"
).collect { bookings ->
_state.update { current -> current.copy(checkedInBookings = bookings) }
}
}
observeOpenJob = viewModelScope.launch {
bookingListRepository.observeByStatus(
propertyId = propertyId,
status = "OPEN"
).collect { bookings ->
_state.update { current -> current.copy(openBookings = bookings) }
}
}
}

View File

@@ -56,14 +56,10 @@ 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.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard
import com.android.trisolarispms.ui.booking.BookingTimePickerDialog
@@ -211,7 +207,6 @@ fun BookingDetailsTabsScreen(
when (page) {
0 -> GuestInfoTabContent(
propertyId = propertyId,
bookingId = bookingId,
details = detailsState.details,
guestId = guestId,
isLoading = detailsState.isLoading,
@@ -219,7 +214,16 @@ fun BookingDetailsTabsScreen(
onEditGuestInfo = onEditGuestInfo,
onEditSignature = onEditSignature,
onOpenRazorpayQr = onOpenRazorpayQr,
onOpenPayments = onOpenPayments
onOpenPayments = onOpenPayments,
onUpdateExpectedDates = { status, updatedCheckInAt, updatedCheckOutAt ->
detailsViewModel.updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
status = status,
updatedCheckInAt = updatedCheckInAt,
updatedCheckOutAt = updatedCheckOutAt
)
}
)
1 -> BookingRoomStaysTabContent(
propertyId = propertyId,
@@ -286,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"
@@ -357,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"
@@ -394,7 +395,6 @@ fun BookingDetailsTabsScreen(
@Composable
private fun GuestInfoTabContent(
propertyId: String,
bookingId: String,
details: BookingDetailsResponse?,
guestId: String?,
isLoading: Boolean,
@@ -402,7 +402,8 @@ private fun GuestInfoTabContent(
onEditGuestInfo: (String) -> Unit,
onEditSignature: (String) -> Unit,
onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit
onOpenPayments: () -> Unit,
onUpdateExpectedDates: suspend (String?, OffsetDateTime?, OffsetDateTime?) -> Result<Unit>
) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
@@ -436,37 +437,18 @@ private fun GuestInfoTabContent(
}
fun submitExpectedDatesUpdate(updatedCheckInAt: OffsetDateTime?, updatedCheckOutAt: OffsetDateTime?) {
val bookingStatus = details?.status?.uppercase() ?: return
if (isUpdatingDates.value) return
val currentStatus = details?.status ?: return
val bookingStatus = currentStatus.uppercase()
if (bookingStatus != "OPEN" && bookingStatus != "CHECKED_IN") return
scope.launch {
isUpdatingDates.value = true
updateDatesError.value = null
try {
val body = when (bookingStatus) {
"OPEN" -> BookingExpectedDatesRequest(
expectedCheckInAt = updatedCheckInAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
else -> BookingExpectedDatesRequest(
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
}
val response = ApiClient.create().updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = body
)
if (response.isSuccessful) {
draftCheckInAt.value = updatedCheckInAt
draftCheckOutAt.value = updatedCheckOutAt
} else {
updateDatesError.value = "Update failed: ${response.code()}"
}
} catch (e: Exception) {
updateDatesError.value = e.localizedMessage ?: "Update failed"
} finally {
isUpdatingDates.value = false
val result = onUpdateExpectedDates(currentStatus, updatedCheckInAt, updatedCheckOutAt)
result.exceptionOrNull()?.let { throwable ->
updateDatesError.value = throwable.localizedMessage ?: "Update failed"
}
isUpdatingDates.value = false
}
}
@@ -528,25 +510,21 @@ private fun GuestInfoTabContent(
checkOutDateText = zonedCheckOut?.format(dateFormatter) ?: "--/--/----",
checkOutTimeText = zonedCheckOut?.format(timeFormatter) ?: "--:--",
totalTimeText = formatBookingDurationText(parsedCheckIn, parsedCheckOut),
checkInEditable = canEditCheckIn,
checkOutEditable = canEditCheckOut,
checkInEditable = canEditCheckIn && !isUpdatingDates.value,
checkOutEditable = canEditCheckOut && !isUpdatingDates.value,
onCheckInDateClick = {
if (canEditCheckIn) showCheckInDatePicker.value = true
if (canEditCheckIn && !isUpdatingDates.value) showCheckInDatePicker.value = true
},
onCheckInTimeClick = {
if (canEditCheckIn) showCheckInTimePicker.value = true
if (canEditCheckIn && !isUpdatingDates.value) showCheckInTimePicker.value = true
},
onCheckOutDateClick = {
if (canEditCheckOut) showCheckOutDatePicker.value = true
if (canEditCheckOut && !isUpdatingDates.value) showCheckOutDatePicker.value = true
},
onCheckOutTimeClick = {
if (canEditCheckOut) showCheckOutTimePicker.value = true
if (canEditCheckOut && !isUpdatingDates.value) showCheckOutTimePicker.value = true
}
)
if (isUpdatingDates.value) {
Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator()
}
updateDatesError.value?.let { message ->
Spacer(modifier = Modifier.height(8.dp))
Text(

View File

@@ -1,12 +1,19 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants
import com.android.trisolarispms.data.api.model.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.core.viewmodel.launchRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import com.google.gson.Gson
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -17,13 +24,25 @@ import okhttp3.Request
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
class BookingDetailsViewModel : ViewModel() {
class BookingDetailsViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingDetailsState())
val state: StateFlow<BookingDetailsState> = _state
private val gson = Gson()
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private val roomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private var eventSource: EventSource? = null
private var streamKey: String? = null
private var observeKey: String? = null
private var observeJob: Job? = null
private var lastPropertyId: String? = null
private var lastBookingId: String? = null
private var retryJob: Job? = null
@@ -31,25 +50,19 @@ class BookingDetailsViewModel : ViewModel() {
fun load(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create()
val response = api.getBookingDetails(propertyId, bookingId)
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
details = response.body(),
error = null
)
observeCache(propertyId = propertyId, bookingId = bookingId)
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
val result = bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId)
result.fold(
onSuccess = {
_state.update { current -> current.copy(isLoading = false, error = null) }
},
onFailure = { throwable ->
val message = throwable.localizedMessage ?: "Load failed"
_state.update { current -> current.copy(isLoading = false, error = message) }
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
)
}
}
@@ -80,8 +93,15 @@ class BookingDetailsViewModel : ViewModel() {
null
}
if (details != null) {
_state.update { it.copy(isLoading = false, details = details, error = null) }
retryDelayMs = 2000
viewModelScope.launch {
bookingRepository.storeSnapshot(
propertyId = propertyId,
bookingId = bookingId,
details = details
)
_state.update { current -> current.copy(isLoading = false, error = null) }
retryDelayMs = 2000
}
}
}
@@ -110,11 +130,99 @@ class BookingDetailsViewModel : ViewModel() {
retryJob = null
}
suspend fun updateExpectedDates(
propertyId: String,
bookingId: String,
status: String?,
updatedCheckInAt: OffsetDateTime?,
updatedCheckOutAt: OffsetDateTime?
): Result<Unit> {
val bookingStatus = status?.uppercase()
val body = when (bookingStatus) {
"OPEN" -> BookingExpectedDatesRequest(
expectedCheckInAt = updatedCheckInAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
"CHECKED_IN" -> BookingExpectedDatesRequest(
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
)
else -> return Result.failure(IllegalStateException("Expected dates are not editable"))
}
val result = bookingRepository.updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = body
)
if (result.isSuccess) {
roomStayRepository.patchExpectedCheckoutForBooking(
propertyId = propertyId,
bookingId = bookingId,
expectedCheckoutAt = body.expectedCheckOutAt
)
}
return result
}
suspend fun checkoutBooking(
propertyId: String,
bookingId: String
): Result<Unit> = runCatching {
val response = ApiClient.create().checkOut(
propertyId = propertyId,
bookingId = bookingId,
body = BookingCheckOutRequest()
)
if (!response.isSuccessful) {
val message = if (response.code() == 409) {
extractApiErrorMessage(response) ?: "Checkout conflict"
} else {
"Checkout failed: ${response.code()}"
}
throw IllegalStateException(message)
}
syncBookingCaches(propertyId = propertyId, bookingId = bookingId)
}
suspend fun cancelBooking(
propertyId: String,
bookingId: String
): Result<Unit> = runCatching {
val response = ApiClient.create().cancelBooking(
propertyId = propertyId,
bookingId = bookingId,
body = BookingCancelRequest()
)
if (!response.isSuccessful) {
throw IllegalStateException("Cancel failed: ${response.code()}")
}
syncBookingCaches(propertyId = propertyId, bookingId = bookingId)
}
override fun onCleared() {
super.onCleared()
observeJob?.cancel()
stopStream()
}
private fun observeCache(propertyId: String, bookingId: String) {
val key = "$propertyId:$bookingId"
if (observeKey == key && observeJob?.isActive == true) return
observeJob?.cancel()
observeKey = key
observeJob = viewModelScope.launch {
bookingRepository.observeBookingDetails(propertyId = propertyId, bookingId = bookingId).collect { details ->
_state.update { current ->
current.copy(
details = details,
isLoading = if (details != null) false else current.isLoading
)
}
}
}
}
private fun scheduleReconnect() {
val propertyId = lastPropertyId ?: return
val bookingId = lastBookingId ?: return
@@ -125,4 +233,12 @@ class BookingDetailsViewModel : ViewModel() {
startStream(propertyId, bookingId)
}
}
private suspend fun syncBookingCaches(propertyId: String, bookingId: String) {
roomStayRepository.refresh(propertyId = propertyId).getOrThrow()
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
).getOrThrow()
}
}

View File

@@ -1,20 +1,33 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingRoomStaysViewModel : ViewModel() {
class BookingRoomStaysViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingRoomStaysState())
val state: StateFlow<BookingRoomStaysState> = _state
private val repository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private var observeJob: Job? = null
private var observeKey: String? = null
fun toggleShowAll(value: Boolean) {
_state.update { it.copy(showAll = value) }
@@ -22,38 +35,10 @@ class BookingRoomStaysViewModel : ViewModel() {
fun load(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create()
val (staysResponse, detailsResponse) = coroutineScope {
val staysDeferred = async { api.listActiveRoomStays(propertyId) }
val detailsDeferred = async { api.getBookingDetails(propertyId, bookingId) }
staysDeferred.await() to detailsDeferred.await()
}
if (!staysResponse.isSuccessful) {
_state.update { it.copy(isLoading = false, error = "Load failed: ${staysResponse.code()}") }
return@launchRequest
}
val filtered = staysResponse.body().orEmpty().filter { it.bookingId == bookingId }
val details = detailsResponse.body().takeIf { detailsResponse.isSuccessful }
val blockedReason = deriveBookingCheckoutBlockedReason(details)
_state.update { current ->
current.copy(
isLoading = false,
stays = filtered,
error = null,
checkoutBlockedReason = blockedReason,
checkoutError = blockedReason,
conflictRoomStayIds = current.conflictRoomStayIds.intersect(
filtered.mapNotNull { stay -> stay.roomStayId }.toSet()
)
)
}
observeBookingCache(propertyId = propertyId, bookingId = bookingId)
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
refreshForBooking(propertyId = propertyId, bookingId = bookingId)
}
}
@@ -81,9 +66,16 @@ class BookingRoomStaysViewModel : ViewModel() {
)
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(
stays = current.stays.filterNot { it.roomStayId == roomStayId },
checkingOutRoomStayId = null,
checkoutError = null,
checkoutBlockedReason = null
@@ -115,6 +107,57 @@ class BookingRoomStaysViewModel : ViewModel() {
}
}
}
override fun onCleared() {
super.onCleared()
observeJob?.cancel()
}
private fun observeBookingCache(propertyId: String, bookingId: String) {
val key = "$propertyId:$bookingId"
if (observeKey == key && observeJob?.isActive == true) return
observeJob?.cancel()
observeKey = key
observeJob = viewModelScope.launch {
repository.observeByBooking(propertyId = propertyId, bookingId = bookingId).collect { stays ->
val stayIds = stays.mapNotNull { it.roomStayId }.toSet()
_state.update { current ->
current.copy(
stays = stays,
isLoading = if (stays.isNotEmpty()) false else current.isLoading,
conflictRoomStayIds = current.conflictRoomStayIds.intersect(stayIds)
)
}
}
}
}
private suspend fun refreshForBooking(
propertyId: String,
bookingId: String
): Result<Unit> {
val activeResult = repository.refresh(propertyId = propertyId)
val detailsResponse = runCatching {
ApiClient.create().getBookingDetails(propertyId = propertyId, bookingId = bookingId)
}.getOrNull()
val details = detailsResponse?.body().takeIf { detailsResponse?.isSuccessful == true }
val blockedReason = deriveBookingCheckoutBlockedReason(details)
val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
?: if (detailsResponse != null && !detailsResponse.isSuccessful) {
"Load failed: ${detailsResponse.code()}"
} else {
null
}
_state.update { current ->
current.copy(
isLoading = false,
error = errorMessage,
checkoutBlockedReason = blockedReason,
checkoutError = blockedReason
)
}
return activeResult
}
}
private fun handleCheckoutConflict(

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 {

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ lottieCompose = "6.7.1"
calendarCompose = "2.6.0"
libphonenumber = "8.13.34"
zxingCore = "3.5.3"
room = "2.8.4"
ksp = "2.3.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -57,8 +59,13 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }