add room db

This commit is contained in:
androidlover5842
2026-02-08 19:21:07 +05:30
parent e9c3b4f669
commit 1000f2411c
18 changed files with 823 additions and 129 deletions

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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