add room db
This commit is contained in:
@@ -576,6 +576,14 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
|
|||||||
- Imports/packages follow the structure above.
|
- Imports/packages follow the structure above.
|
||||||
- Build passes: `./gradlew :app:compileDebugKotlin`.
|
- Build passes: `./gradlew :app:compileDebugKotlin`.
|
||||||
|
|
||||||
|
### Room DB synchronization rule (mandatory)
|
||||||
|
|
||||||
|
- For any editable API-backed field, follow this write path only: `UI action -> server request -> Room DB update -> UI reacts from Room observers`.
|
||||||
|
- Server is source of truth; do not bypass server by writing final business state directly from UI.
|
||||||
|
- UI must render from Room-backed state, not from one-off API responses or direct text mutation.
|
||||||
|
- Avoid forced full refresh after each mutation unless strictly required by backend limitations; prefer targeted Room updates for linked entities.
|
||||||
|
- On mutation failure, keep prior DB state unchanged and surface error state to UI.
|
||||||
|
|
||||||
### Guest Documents Authorization (mandatory)
|
### Guest Documents Authorization (mandatory)
|
||||||
|
|
||||||
- View access: `ADMIN`, `MANAGER` (and super admin).
|
- View access: `ADMIN`, `MANAGER` (and super admin).
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
extensions.configure<ApplicationExtension>("android") {
|
||||||
namespace = "com.android.trisolarispms"
|
namespace = "com.android.trisolarispms"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.android.trisolarispms"
|
applicationId = "com.android.trisolarispms"
|
||||||
@@ -29,16 +32,24 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
@@ -63,6 +74,9 @@ dependencies {
|
|||||||
implementation(libs.calendar.compose)
|
implementation(libs.calendar.compose)
|
||||||
implementation(libs.libphonenumber)
|
implementation(libs.libphonenumber)
|
||||||
implementation(libs.zxing.core)
|
implementation(libs.zxing.core)
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
implementation(platform(libs.firebase.bom))
|
implementation(platform(libs.firebase.bom))
|
||||||
implementation(libs.firebase.auth.ktx)
|
implementation(libs.firebase.auth.ktx)
|
||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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.roomstay.ActiveRoomStayCacheDao
|
||||||
|
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheEntity
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
BookingDetailsCacheEntity::class,
|
||||||
|
ActiveRoomStayCacheEntity::class
|
||||||
|
],
|
||||||
|
version = 2,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
@TypeConverters(RoomConverters::class)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
abstract fun bookingDetailsCacheDao(): BookingDetailsCacheDao
|
||||||
|
abstract fun activeRoomStayCacheDao(): ActiveRoomStayCacheDao
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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)
|
||||||
|
.build()
|
||||||
|
.also { built ->
|
||||||
|
instance = built
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,28 @@
|
|||||||
package com.android.trisolarispms.ui.roomstay
|
package com.android.trisolarispms.ui.roomstay
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.android.trisolarispms.data.api.core.ApiClient
|
import com.android.trisolarispms.data.api.core.ApiClient
|
||||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||||
|
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ActiveRoomStaysViewModel : ViewModel() {
|
class ActiveRoomStaysViewModel(
|
||||||
|
application: Application
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
private val _state = MutableStateFlow(ActiveRoomStaysState())
|
private val _state = MutableStateFlow(ActiveRoomStaysState())
|
||||||
val state: StateFlow<ActiveRoomStaysState> = _state
|
val state: StateFlow<ActiveRoomStaysState> = _state
|
||||||
|
private val repository = ActiveRoomStayRepository(
|
||||||
|
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||||
|
)
|
||||||
|
private var observeJob: Job? = null
|
||||||
|
private var observePropertyId: String? = null
|
||||||
|
|
||||||
fun toggleShowOpenBookings() {
|
fun toggleShowOpenBookings() {
|
||||||
_state.update { it.copy(showOpenBookings = !it.showOpenBookings) }
|
_state.update { it.copy(showOpenBookings = !it.showOpenBookings) }
|
||||||
@@ -21,28 +34,65 @@ class ActiveRoomStaysViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun load(propertyId: String) {
|
fun load(propertyId: String) {
|
||||||
if (propertyId.isBlank()) return
|
if (propertyId.isBlank()) return
|
||||||
launchRequest(
|
observeCache(propertyId = propertyId)
|
||||||
state = _state,
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
setLoading = { it.copy(isLoading = true, error = null) },
|
viewModelScope.launch {
|
||||||
setError = { current, message -> current.copy(isLoading = false, error = message) },
|
val activeResult = repository.refresh(propertyId = propertyId)
|
||||||
defaultError = "Load failed"
|
|
||||||
) {
|
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val activeResponse = api.listActiveRoomStays(propertyId)
|
val checkedInBookingsResponse = runCatching {
|
||||||
val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN")
|
api.listBookings(propertyId = propertyId, status = "CHECKED_IN")
|
||||||
val openBookingsResponse = api.listBookings(propertyId, status = "OPEN")
|
}.getOrNull()
|
||||||
if (activeResponse.isSuccessful) {
|
val openBookingsResponse = runCatching {
|
||||||
_state.update {
|
api.listBookings(propertyId = propertyId, status = "OPEN")
|
||||||
it.copy(
|
}.getOrNull()
|
||||||
isLoading = false,
|
val checkedInBookings = checkedInBookingsResponse
|
||||||
items = activeResponse.body().orEmpty(),
|
?.body()
|
||||||
checkedInBookings = bookingsResponse.body().orEmpty(),
|
.orEmpty()
|
||||||
openBookings = openBookingsResponse.body().orEmpty(),
|
.takeIf { checkedInBookingsResponse?.isSuccessful == true }
|
||||||
error = null
|
.orEmpty()
|
||||||
|
val openBookings = openBookingsResponse
|
||||||
|
?.body()
|
||||||
|
.orEmpty()
|
||||||
|
.takeIf { openBookingsResponse?.isSuccessful == true }
|
||||||
|
.orEmpty()
|
||||||
|
val errorMessage = activeResult.exceptionOrNull()?.localizedMessage
|
||||||
|
?: when {
|
||||||
|
checkedInBookingsResponse != null && !checkedInBookingsResponse.isSuccessful ->
|
||||||
|
"Load failed: ${checkedInBookingsResponse.code()}"
|
||||||
|
|
||||||
|
openBookingsResponse != null && !openBookingsResponse.isSuccessful ->
|
||||||
|
"Load failed: ${openBookingsResponse.code()}"
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
checkedInBookings = checkedInBookings,
|
||||||
|
openBookings = openBookings,
|
||||||
|
error = errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
observeJob?.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()}") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import com.android.trisolarispms.data.api.model.BookingBillingMode
|
|||||||
import com.android.trisolarispms.data.api.model.BookingCancelRequest
|
import com.android.trisolarispms.data.api.model.BookingCancelRequest
|
||||||
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
|
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
|
||||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
|
||||||
import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
|
import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
|
||||||
import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard
|
import com.android.trisolarispms.ui.booking.BookingDateTimeQuickEditorCard
|
||||||
import com.android.trisolarispms.ui.booking.BookingTimePickerDialog
|
import com.android.trisolarispms.ui.booking.BookingTimePickerDialog
|
||||||
@@ -211,7 +210,6 @@ fun BookingDetailsTabsScreen(
|
|||||||
when (page) {
|
when (page) {
|
||||||
0 -> GuestInfoTabContent(
|
0 -> GuestInfoTabContent(
|
||||||
propertyId = propertyId,
|
propertyId = propertyId,
|
||||||
bookingId = bookingId,
|
|
||||||
details = detailsState.details,
|
details = detailsState.details,
|
||||||
guestId = guestId,
|
guestId = guestId,
|
||||||
isLoading = detailsState.isLoading,
|
isLoading = detailsState.isLoading,
|
||||||
@@ -219,7 +217,16 @@ fun BookingDetailsTabsScreen(
|
|||||||
onEditGuestInfo = onEditGuestInfo,
|
onEditGuestInfo = onEditGuestInfo,
|
||||||
onEditSignature = onEditSignature,
|
onEditSignature = onEditSignature,
|
||||||
onOpenRazorpayQr = onOpenRazorpayQr,
|
onOpenRazorpayQr = onOpenRazorpayQr,
|
||||||
onOpenPayments = onOpenPayments
|
onOpenPayments = onOpenPayments,
|
||||||
|
onUpdateExpectedDates = { status, updatedCheckInAt, updatedCheckOutAt ->
|
||||||
|
detailsViewModel.updateExpectedDates(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
status = status,
|
||||||
|
updatedCheckInAt = updatedCheckInAt,
|
||||||
|
updatedCheckOutAt = updatedCheckOutAt
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
1 -> BookingRoomStaysTabContent(
|
1 -> BookingRoomStaysTabContent(
|
||||||
propertyId = propertyId,
|
propertyId = propertyId,
|
||||||
@@ -394,7 +401,6 @@ fun BookingDetailsTabsScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun GuestInfoTabContent(
|
private fun GuestInfoTabContent(
|
||||||
propertyId: String,
|
propertyId: String,
|
||||||
bookingId: String,
|
|
||||||
details: BookingDetailsResponse?,
|
details: BookingDetailsResponse?,
|
||||||
guestId: String?,
|
guestId: String?,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
@@ -402,7 +408,8 @@ private fun GuestInfoTabContent(
|
|||||||
onEditGuestInfo: (String) -> Unit,
|
onEditGuestInfo: (String) -> Unit,
|
||||||
onEditSignature: (String) -> Unit,
|
onEditSignature: (String) -> Unit,
|
||||||
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
onOpenRazorpayQr: (Long?, String?) -> Unit,
|
||||||
onOpenPayments: () -> Unit
|
onOpenPayments: () -> Unit,
|
||||||
|
onUpdateExpectedDates: suspend (String?, OffsetDateTime?, OffsetDateTime?) -> Result<Unit>
|
||||||
) {
|
) {
|
||||||
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
|
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
|
||||||
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||||
@@ -436,37 +443,18 @@ private fun GuestInfoTabContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun submitExpectedDatesUpdate(updatedCheckInAt: OffsetDateTime?, updatedCheckOutAt: OffsetDateTime?) {
|
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
|
if (bookingStatus != "OPEN" && bookingStatus != "CHECKED_IN") return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isUpdatingDates.value = true
|
isUpdatingDates.value = true
|
||||||
updateDatesError.value = null
|
updateDatesError.value = null
|
||||||
try {
|
val result = onUpdateExpectedDates(currentStatus, updatedCheckInAt, updatedCheckOutAt)
|
||||||
val body = when (bookingStatus) {
|
result.exceptionOrNull()?.let { throwable ->
|
||||||
"OPEN" -> BookingExpectedDatesRequest(
|
updateDatesError.value = throwable.localizedMessage ?: "Update failed"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
isUpdatingDates.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,25 +516,21 @@ private fun GuestInfoTabContent(
|
|||||||
checkOutDateText = zonedCheckOut?.format(dateFormatter) ?: "--/--/----",
|
checkOutDateText = zonedCheckOut?.format(dateFormatter) ?: "--/--/----",
|
||||||
checkOutTimeText = zonedCheckOut?.format(timeFormatter) ?: "--:--",
|
checkOutTimeText = zonedCheckOut?.format(timeFormatter) ?: "--:--",
|
||||||
totalTimeText = formatBookingDurationText(parsedCheckIn, parsedCheckOut),
|
totalTimeText = formatBookingDurationText(parsedCheckIn, parsedCheckOut),
|
||||||
checkInEditable = canEditCheckIn,
|
checkInEditable = canEditCheckIn && !isUpdatingDates.value,
|
||||||
checkOutEditable = canEditCheckOut,
|
checkOutEditable = canEditCheckOut && !isUpdatingDates.value,
|
||||||
onCheckInDateClick = {
|
onCheckInDateClick = {
|
||||||
if (canEditCheckIn) showCheckInDatePicker.value = true
|
if (canEditCheckIn && !isUpdatingDates.value) showCheckInDatePicker.value = true
|
||||||
},
|
},
|
||||||
onCheckInTimeClick = {
|
onCheckInTimeClick = {
|
||||||
if (canEditCheckIn) showCheckInTimePicker.value = true
|
if (canEditCheckIn && !isUpdatingDates.value) showCheckInTimePicker.value = true
|
||||||
},
|
},
|
||||||
onCheckOutDateClick = {
|
onCheckOutDateClick = {
|
||||||
if (canEditCheckOut) showCheckOutDatePicker.value = true
|
if (canEditCheckOut && !isUpdatingDates.value) showCheckOutDatePicker.value = true
|
||||||
},
|
},
|
||||||
onCheckOutTimeClick = {
|
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 ->
|
updateDatesError.value?.let { message ->
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package com.android.trisolarispms.ui.roomstay
|
package com.android.trisolarispms.ui.roomstay
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.android.trisolarispms.data.api.core.ApiClient
|
import com.android.trisolarispms.data.api.core.ApiClient
|
||||||
import com.android.trisolarispms.data.api.core.ApiConstants
|
import com.android.trisolarispms.data.api.core.ApiConstants
|
||||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||||
|
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||||
|
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||||
|
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -17,13 +22,25 @@ import okhttp3.Request
|
|||||||
import okhttp3.sse.EventSource
|
import okhttp3.sse.EventSource
|
||||||
import okhttp3.sse.EventSourceListener
|
import okhttp3.sse.EventSourceListener
|
||||||
import okhttp3.sse.EventSources
|
import okhttp3.sse.EventSources
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
class BookingDetailsViewModel : ViewModel() {
|
class BookingDetailsViewModel(
|
||||||
|
application: Application
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
private val _state = MutableStateFlow(BookingDetailsState())
|
private val _state = MutableStateFlow(BookingDetailsState())
|
||||||
val state: StateFlow<BookingDetailsState> = _state
|
val state: StateFlow<BookingDetailsState> = _state
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
private val bookingRepository = BookingDetailsRepository(
|
||||||
|
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||||
|
)
|
||||||
|
private val roomStayRepository = ActiveRoomStayRepository(
|
||||||
|
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||||
|
)
|
||||||
private var eventSource: EventSource? = null
|
private var eventSource: EventSource? = null
|
||||||
private var streamKey: String? = null
|
private var streamKey: String? = null
|
||||||
|
private var observeKey: String? = null
|
||||||
|
private var observeJob: Job? = null
|
||||||
private var lastPropertyId: String? = null
|
private var lastPropertyId: String? = null
|
||||||
private var lastBookingId: String? = null
|
private var lastBookingId: String? = null
|
||||||
private var retryJob: Job? = null
|
private var retryJob: Job? = null
|
||||||
@@ -31,25 +48,19 @@ class BookingDetailsViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun load(propertyId: String, bookingId: String) {
|
fun load(propertyId: String, bookingId: String) {
|
||||||
if (propertyId.isBlank() || bookingId.isBlank()) return
|
if (propertyId.isBlank() || bookingId.isBlank()) return
|
||||||
launchRequest(
|
observeCache(propertyId = propertyId, bookingId = bookingId)
|
||||||
state = _state,
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
setLoading = { it.copy(isLoading = true, error = null) },
|
viewModelScope.launch {
|
||||||
setError = { current, message -> current.copy(isLoading = false, error = message) },
|
val result = bookingRepository.refreshBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||||
defaultError = "Load failed"
|
result.fold(
|
||||||
) {
|
onSuccess = {
|
||||||
val api = ApiClient.create()
|
_state.update { current -> current.copy(isLoading = false, error = null) }
|
||||||
val response = api.getBookingDetails(propertyId, bookingId)
|
},
|
||||||
if (response.isSuccessful) {
|
onFailure = { throwable ->
|
||||||
_state.update {
|
val message = throwable.localizedMessage ?: "Load failed"
|
||||||
it.copy(
|
_state.update { current -> current.copy(isLoading = false, error = message) }
|
||||||
isLoading = false,
|
|
||||||
details = response.body(),
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
)
|
||||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +91,15 @@ class BookingDetailsViewModel : ViewModel() {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (details != null) {
|
if (details != null) {
|
||||||
_state.update { it.copy(isLoading = false, details = details, error = null) }
|
viewModelScope.launch {
|
||||||
retryDelayMs = 2000
|
bookingRepository.storeSnapshot(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
details = details
|
||||||
|
)
|
||||||
|
_state.update { current -> current.copy(isLoading = false, error = null) }
|
||||||
|
retryDelayMs = 2000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +128,64 @@ class BookingDetailsViewModel : ViewModel() {
|
|||||||
retryJob = null
|
retryJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateExpectedDates(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String,
|
||||||
|
status: String?,
|
||||||
|
updatedCheckInAt: OffsetDateTime?,
|
||||||
|
updatedCheckOutAt: OffsetDateTime?
|
||||||
|
): Result<Unit> {
|
||||||
|
val bookingStatus = status?.uppercase()
|
||||||
|
val body = when (bookingStatus) {
|
||||||
|
"OPEN" -> BookingExpectedDatesRequest(
|
||||||
|
expectedCheckInAt = updatedCheckInAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
|
||||||
|
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||||
|
)
|
||||||
|
|
||||||
|
"CHECKED_IN" -> BookingExpectedDatesRequest(
|
||||||
|
expectedCheckOutAt = updatedCheckOutAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> return Result.failure(IllegalStateException("Expected dates are not editable"))
|
||||||
|
}
|
||||||
|
val result = bookingRepository.updateExpectedDates(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
body = body
|
||||||
|
)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
roomStayRepository.patchExpectedCheckoutForBooking(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
expectedCheckoutAt = body.expectedCheckOutAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
observeJob?.cancel()
|
||||||
stopStream()
|
stopStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeCache(propertyId: String, bookingId: String) {
|
||||||
|
val key = "$propertyId:$bookingId"
|
||||||
|
if (observeKey == key && observeJob?.isActive == true) return
|
||||||
|
observeJob?.cancel()
|
||||||
|
observeKey = key
|
||||||
|
observeJob = viewModelScope.launch {
|
||||||
|
bookingRepository.observeBookingDetails(propertyId = propertyId, bookingId = bookingId).collect { details ->
|
||||||
|
_state.update { current ->
|
||||||
|
current.copy(
|
||||||
|
details = details,
|
||||||
|
isLoading = if (details != null) false else current.isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
val propertyId = lastPropertyId ?: return
|
val propertyId = lastPropertyId ?: return
|
||||||
val bookingId = lastBookingId ?: return
|
val bookingId = lastBookingId ?: return
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
package com.android.trisolarispms.ui.roomstay
|
package com.android.trisolarispms.ui.roomstay
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.android.trisolarispms.data.api.core.ApiClient
|
import com.android.trisolarispms.data.api.core.ApiClient
|
||||||
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
|
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
|
||||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||||
import kotlinx.coroutines.async
|
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class BookingRoomStaysViewModel : ViewModel() {
|
class BookingRoomStaysViewModel(
|
||||||
|
application: Application
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
private val _state = MutableStateFlow(BookingRoomStaysState())
|
private val _state = MutableStateFlow(BookingRoomStaysState())
|
||||||
val state: StateFlow<BookingRoomStaysState> = _state
|
val state: StateFlow<BookingRoomStaysState> = _state
|
||||||
|
private val repository = ActiveRoomStayRepository(
|
||||||
|
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||||
|
)
|
||||||
|
private var observeJob: Job? = null
|
||||||
|
private var observeKey: String? = null
|
||||||
|
|
||||||
fun toggleShowAll(value: Boolean) {
|
fun toggleShowAll(value: Boolean) {
|
||||||
_state.update { it.copy(showAll = value) }
|
_state.update { it.copy(showAll = value) }
|
||||||
@@ -22,38 +31,10 @@ class BookingRoomStaysViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun load(propertyId: String, bookingId: String) {
|
fun load(propertyId: String, bookingId: String) {
|
||||||
if (propertyId.isBlank() || bookingId.isBlank()) return
|
if (propertyId.isBlank() || bookingId.isBlank()) return
|
||||||
launchRequest(
|
observeBookingCache(propertyId = propertyId, bookingId = bookingId)
|
||||||
state = _state,
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
setLoading = { it.copy(isLoading = true, error = null) },
|
viewModelScope.launch {
|
||||||
setError = { current, message -> current.copy(isLoading = false, error = message) },
|
refreshForBooking(propertyId = propertyId, bookingId = bookingId)
|
||||||
defaultError = "Load failed"
|
|
||||||
) {
|
|
||||||
val api = ApiClient.create()
|
|
||||||
val (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()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +62,9 @@ class BookingRoomStaysViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
when {
|
when {
|
||||||
response.isSuccessful -> {
|
response.isSuccessful -> {
|
||||||
|
repository.removeFromCache(propertyId = propertyId, roomStayId = roomStayId)
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
current.copy(
|
current.copy(
|
||||||
stays = current.stays.filterNot { it.roomStayId == roomStayId },
|
|
||||||
checkingOutRoomStayId = null,
|
checkingOutRoomStayId = null,
|
||||||
checkoutError = null,
|
checkoutError = null,
|
||||||
checkoutBlockedReason = null
|
checkoutBlockedReason = null
|
||||||
@@ -115,6 +96,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(
|
private fun handleCheckoutConflict(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
alias(libs.plugins.google.services) apply false
|
alias(libs.plugins.google.services) apply false
|
||||||
}
|
alias(libs.plugins.ksp) apply false
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ kotlin.code.style=official
|
|||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
android.builtInKotlin=false
|
||||||
|
android.newDsl=false
|
||||||
systemProp.java.net.preferIPv4Stack=true
|
systemProp.java.net.preferIPv4Stack=true
|
||||||
org.gradle.internal.http.connectionTimeout=600000
|
org.gradle.internal.http.connectionTimeout=600000
|
||||||
org.gradle.internal.http.socketTimeout=600000
|
org.gradle.internal.http.socketTimeout=600000
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ lottieCompose = "6.7.1"
|
|||||||
calendarCompose = "2.6.0"
|
calendarCompose = "2.6.0"
|
||||||
libphonenumber = "8.13.34"
|
libphonenumber = "8.13.34"
|
||||||
zxingCore = "3.5.3"
|
zxingCore = "3.5.3"
|
||||||
|
room = "2.8.4"
|
||||||
|
ksp = "2.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -57,8 +59,13 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
|
|||||||
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
|
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
|
||||||
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
|
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
|
||||||
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
|
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
|
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user