Compare commits

..

22 Commits

Author SHA1 Message Date
androidlover5842
f69a01a460 ai added more room db stuff 2026-02-08 19:54:35 +05:30
androidlover5842
1000f2411c add room db 2026-02-08 19:21:07 +05:30
androidlover5842
e9c3b4f669 booking: ability to edit more guest info 2026-02-07 22:33:43 +05:30
androidlover5842
90c2b6fb9f roomStays: show rates 2026-02-05 13:05:43 +05:30
androidlover5842
1e5f412f82 ablity to checkout stay 2026-02-05 10:31:15 +05:30
androidlover5842
a67eacd77f createbooking: improve logic for future bookings 2026-02-04 17:55:35 +05:30
androidlover5842
f9b09e2376 ability to cancel future bookings 2026-02-04 16:45:15 +05:30
androidlover5842
e1250a0f32 stay: improve checkin and checkout time editor 2026-02-04 16:28:50 +05:30
androidlover5842
d69ed60a6e agents -_- 2026-02-04 15:32:44 +05:30
androidlover5842
56f13f5e79 ability to see open bookings list 2026-02-04 15:20:17 +05:30
androidlover5842
9555ae2e40 activeScreen:improve menu 2026-02-04 15:07:27 +05:30
androidlover5842
9d942d6411 createBooking: change checkout date based on property policy while editing checking date 2026-02-04 14:58:16 +05:30
androidlover5842
3a90aa848d billing: add billable nights view and improve payment ledger cash handle logic 2026-02-04 14:12:19 +05:30
androidlover5842
eab5517f9b extend checkout time improve ui 2026-02-04 13:41:06 +05:30
androidlover5842
b0c28d0aa4 add missing screen 2026-02-03 10:21:07 +05:30
androidlover5842
dcaaba92dd AI:remove more boilerplate 2026-02-03 10:20:48 +05:30
androidlover5842
52a6d379b0 AI:remove more boilerplate 2026-02-03 09:55:40 +05:30
androidlover5842
4e5f368256 AI:remove more boilerplate 2026-02-03 09:38:30 +05:30
androidlover5842
d6c8e522de AI:remove boilerplate 2026-02-03 09:28:23 +05:30
androidlover5842
18c5cb814d policy:time related 2026-02-02 11:35:30 +05:30
androidlover5842
a691e84fd8 ai removed boilerplate and orgnized code even more 2026-02-02 07:00:56 +05:30
androidlover5842
d54a9af5ee improve auth screen login 2026-02-02 06:29:49 +05:30
136 changed files with 8449 additions and 3605 deletions

View File

@@ -1,5 +1,9 @@
# TrisolarisPMS API Usage
## API Docs Path
- `/home/androidlover5842/IdeaProjects/TrisolarisServer/docs`
## 1) Booking
### Create booking
@@ -534,6 +538,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
### Non-negotiable coding rules
- Duplicate code is forbidden.
- Never add duplicate business logic in multiple files.
- Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns.
- If similar logic appears 2+ times, extract shared function/class immediately.
@@ -543,6 +548,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
### Required project structure (current baseline)
- `core/` -> cross-cutting business primitives/policies (e.g., auth policy, role enum).
- `core/viewmodel/` -> shared ViewModel execution helpers (loading/error wrappers, common request runners).
- `data/api/core/` -> API client, constants, token providers, aggregated API service.
- `data/api/service/` -> Retrofit endpoint interfaces only.
- `data/api/model/` -> DTO/request/response models.
@@ -558,6 +564,9 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
5. Keep UI dumb: consume state and callbacks; avoid business rules in composables.
6. If navigation changes, update `ui/navigation` only (single source of truth).
7. Before finishing, remove any newly introduced duplication and compile-check.
8. If 2+ ViewModels repeat loading/error coroutine flow, extract/use shared helper in `core/viewmodel`.
9. If Add/Edit screens differ only by initialization + submit callback, extract a feature-local shared form screen.
10. Prefer dedupe/organization improvements even if net LOC does not decrease, as long as behavior remains unchanged.
### PR/refactor acceptance checklist
@@ -567,6 +576,14 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
- Imports/packages follow the structure above.
- Build passes: `./gradlew :app:compileDebugKotlin`.
### Room DB synchronization rule (mandatory)
- For any editable API-backed field, follow this write path only: `UI action -> server request -> Room DB update -> UI reacts from Room observers`.
- Server is source of truth; do not bypass server by writing final business state directly from UI.
- UI must render from Room-backed state, not from one-off API responses or direct text mutation.
- Avoid forced full refresh after each mutation unless strictly required by backend limitations; prefer targeted Room updates for linked entities.
- On mutation failure, keep prior DB state unchanged and surface error state to UI.
### Guest Documents Authorization (mandatory)
- View access: `ADMIN`, `MANAGER` (and super admin).

View File

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

View File

@@ -18,6 +18,10 @@ class AuthzPolicy(
fun canManageRazorpaySettings(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageCancellationPolicy(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageBillingPolicy(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDeleteCashPayment(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canAddBookingPayment(propertyId: String): Boolean =
@@ -34,6 +38,12 @@ class AuthzPolicy(
fun canCreateBookingFor(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
fun canCheckOutRoomStay(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER)
fun canCheckOutBooking(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
fun canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER)

View File

@@ -0,0 +1,14 @@
package com.android.trisolarispms.core.booking
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
private val defaultPropertyZone: ZoneId = ZoneId.of("Asia/Kolkata")
fun isFutureBookingCheckIn(expectedCheckInAt: String?, zoneId: ZoneId = defaultPropertyZone): Boolean {
if (expectedCheckInAt.isNullOrBlank()) return false
val checkInDate = runCatching { OffsetDateTime.parse(expectedCheckInAt).toLocalDate() }.getOrNull() ?: return false
val today = LocalDate.now(zoneId)
return checkInDate.isAfter(today)
}

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.core.booking
object BookingProfileOptions {
val memberRelations: List<String> = listOf(
"FRIENDS",
"FAMILY",
"GROUP",
"ALONE"
)
val transportModes: List<String> = listOf(
"",
"CAR",
"BIKE",
"TRAIN",
"PLANE",
"BUS",
"FOOT",
"CYCLE",
"OTHER"
)
}

View File

@@ -0,0 +1,51 @@
package com.android.trisolarispms.core.viewmodel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class CitySearchController(
private val scope: CoroutineScope,
private val onUpdate: (isLoading: Boolean, suggestions: List<String>) -> Unit,
private val search: suspend (query: String, limit: Int) -> List<String>,
private val minQueryLength: Int = 2,
private val defaultLimit: Int = 20,
private val debounceMs: Long = 300L
) {
private var job: Job? = null
fun onQueryChanged(rawQuery: String) {
val query = rawQuery.trim()
job?.cancel()
if (query.length < minQueryLength) {
onUpdate(false, emptyList())
return
}
job = scope.launch {
delay(debounceMs)
if (!isActive) return@launch
onUpdate(true, emptyList())
try {
val suggestions = search(query, defaultLimit)
if (isActive) {
onUpdate(false, suggestions)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
if (isActive) {
onUpdate(false, emptyList())
}
}
}
}
fun cancel() {
job?.cancel()
job = null
}
}

View File

@@ -0,0 +1,56 @@
package com.android.trisolarispms.core.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.Response
internal fun <S> ViewModel.launchRequest(
state: MutableStateFlow<S>,
setLoading: (S) -> S,
setError: (S, String) -> S,
defaultError: String,
block: suspend () -> Unit
) {
viewModelScope.launch {
state.update(setLoading)
try {
block()
} catch (e: Exception) {
val message = e.localizedMessage ?: defaultError
state.update { current -> setError(current, message) }
}
}
}
internal fun <S> ViewModel.launchApiMutation(
state: MutableStateFlow<S>,
action: String,
setLoading: (S) -> S,
setSuccess: (S) -> S,
setError: (S, String) -> S,
onDone: () -> Unit = {},
createApi: () -> ApiService = { ApiClient.create() },
call: suspend (ApiService) -> Response<*>
) {
launchRequest(
state = state,
setLoading = setLoading,
setError = setError,
defaultError = "$action failed"
) {
val response = call(createApi())
if (response.isSuccessful) {
state.update(setSuccess)
onDone()
} else {
state.update { current ->
setError(current, "$action failed: ${response.code()}")
}
}
}
}

View File

@@ -2,10 +2,13 @@ package com.android.trisolarispms.data.api.core
import com.android.trisolarispms.data.api.service.AmenityApi
import com.android.trisolarispms.data.api.service.AuthApi
import com.android.trisolarispms.data.api.service.BillingPolicyApi
import com.android.trisolarispms.data.api.service.BookingApi
import com.android.trisolarispms.data.api.service.CancellationPolicyApi
import com.android.trisolarispms.data.api.service.CardApi
import com.android.trisolarispms.data.api.service.GuestApi
import com.android.trisolarispms.data.api.service.GuestDocumentApi
import com.android.trisolarispms.data.api.service.GeoApi
import com.android.trisolarispms.data.api.service.ImageTagApi
import com.android.trisolarispms.data.api.service.InboundEmailApi
import com.android.trisolarispms.data.api.service.PropertyApi
@@ -20,6 +23,7 @@ import com.android.trisolarispms.data.api.service.UserAdminApi
interface ApiService :
AuthApi,
BillingPolicyApi,
PropertyApi,
RoomTypeApi,
RoomApi,
@@ -30,9 +34,11 @@ interface ApiService :
CardApi,
GuestApi,
GuestDocumentApi,
GeoApi,
TransportApi,
InboundEmailApi,
AmenityApi,
RatePlanApi,
RazorpaySettingsApi,
CancellationPolicyApi,
UserAdminApi

View File

@@ -0,0 +1,30 @@
package com.android.trisolarispms.data.api.core
import com.google.gson.JsonElement
object GeoSearchRepository {
suspend fun searchCityDisplayValues(
query: String,
limit: Int = 20
): List<String> {
val response = ApiClient.create().searchCities(query = query, limit = limit)
if (!response.isSuccessful) return emptyList()
return response.body()
.orEmpty()
.mapNotNull(::extractCityDisplayValue)
.distinct()
}
}
private fun extractCityDisplayValue(element: JsonElement): String? {
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
return element.asString.trim().ifBlank { null }
}
if (!element.isJsonObject) return null
val obj = element.asJsonObject
val city = obj.get("city")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
?: return null
val state = obj.get("state")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
return if (state == null) city else "$city, $state"
}

View File

@@ -0,0 +1,12 @@
package com.android.trisolarispms.data.api.model
data class BillingPolicyRequest(
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class BillingPolicyResponse(
val propertyId: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)

View File

@@ -1,5 +1,20 @@
package com.android.trisolarispms.data.api.model
import java.util.Locale
enum class BookingBillingMode {
PROPERTY_POLICY,
CUSTOM_WINDOW,
FULL_24H;
companion object {
fun from(value: String?): BookingBillingMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class BookingCheckInRequest(
val roomIds: List<String>,
val checkInAt: String? = null,
@@ -11,6 +26,8 @@ data class BookingCheckInRequest(
data class BookingCreateRequest(
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val billingMode: BookingBillingMode? = null,
val billingCheckoutTime: String? = null,
val source: String? = null,
val guestPhoneE164: String? = null,
val fromCity: String? = null,
@@ -39,6 +56,7 @@ data class BookingListItem(
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null,
@@ -57,11 +75,16 @@ data class BookingListItem(
val expectedGuestCount: Int? = null,
val notes: String? = null
,
val pending: Long? = null
val pending: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingBulkCheckInRequest(
val stays: List<BookingBulkCheckInStayRequest>
val stays: List<BookingBulkCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
)
data class BookingBulkCheckInStayRequest(
@@ -79,6 +102,40 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null
)
data class BookingRoomRequestCreateRequest(
val roomTypeCode: String,
val quantity: Int,
val fromAt: String,
val toAt: String
)
data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingBillableNightsResponse(
val bookingId: String? = null,
val status: String? = null,
val billableNights: Long? = null
)
data class BookingExpectedCheckoutPreviewRequest(
val checkInAt: String,
val billableNights: Int? = null,
val billingMode: BookingBillingMode? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingExpectedCheckoutPreviewResponse(
val expectedCheckOutAt: String? = null,
val billableNights: Int? = null,
val billingMode: BookingBillingMode? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingDetailsResponse(
val id: String? = null,
val status: String? = null,
@@ -95,6 +152,7 @@ data class BookingDetailsResponse(
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,
@@ -110,11 +168,21 @@ data class BookingDetailsResponse(
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 pending: Long? = null,
val billableNights: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingBillingPolicyUpdateRequest(
val billingMode: BookingBillingMode,
val billingCheckoutTime: String? = null
)
data class BookingLinkGuestRequest(
@@ -126,6 +194,11 @@ data class BookingCheckOutRequest(
val notes: String? = null
)
data class BookingRoomStayCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest(
val cancelledAt: String? = null,
val reason: String? = null
@@ -136,39 +209,12 @@ data class BookingNoShowRequest(
val reason: String? = null
)
data class BookingRoomStayCreateRequest(
val roomId: String,
val fromAt: String,
val toAt: String,
val notes: String? = null
data class BookingBalanceResponse(
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null
)
// Room Stays
data class RoomStayCreateRequest(
val roomId: String,
val guestId: String? = null,
val checkIn: String? = null,
val checkOut: String? = null
)
data class RoomStayDto(
val id: String? = null,
val bookingId: String? = null,
val roomId: String? = null,
val status: String? = null
)
data class RoomChangeRequest(
val newRoomId: String,
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: String? = null,
val newRoomStayId: String? = null,
val oldRoomId: String? = null,
val newRoomId: String? = null,
val movedAt: String? = null
data class RoomStayVoidRequest(
val reason: String
)

View File

@@ -0,0 +1,26 @@
package com.android.trisolarispms.data.api.model
import java.util.Locale
enum class CancellationPenaltyMode {
NO_CHARGE,
ONE_NIGHT,
FULL_STAY;
companion object {
fun from(value: String?): CancellationPenaltyMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class CancellationPolicyRequest(
val freeDaysBeforeCheckin: Int,
val penaltyMode: CancellationPenaltyMode
)
data class CancellationPolicyResponse(
val freeDaysBeforeCheckin: Int? = null,
val penaltyMode: String? = null
)

View File

@@ -0,0 +1,6 @@
package com.android.trisolarispms.data.api.model
data class CitySearchItemDto(
val city: String? = null,
val state: String? = null
)

View File

@@ -1,11 +1,14 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class GuestDto(
val id: String? = null,
val name: String? = null,
val phoneE164: String? = null,
@SerializedName(value = "dob", alternate = ["age"])
val dob: String? = null,
val nationality: String? = null,
val age: String? = null,
val addressText: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val averageScore: Double? = null

View File

@@ -1,5 +1,7 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class RoomCreateRequest(
val roomNumber: Int,
val floor: Int? = null,
@@ -47,16 +49,11 @@ data class RoomAvailabilityResponse(
)
data class RoomAvailabilityRangeResponse(
val roomTypeName: String? = null,
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null
)
data class RoomAvailableRateResponse(
val roomId: String? = null,
val roomNumber: Int? = null,
@SerializedName(value = "roomTypeCode", alternate = ["code"])
val roomTypeCode: String? = null,
val roomTypeName: String? = null,
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null,
val averageRate: Double? = null,
val currency: String? = null,
val ratePlanCode: String? = null

View File

@@ -12,5 +12,7 @@ data class ActiveRoomStayDto(
val roomTypeName: String? = null,
val fromAt: String? = null,
val checkinAt: String? = null,
val expectedCheckoutAt: String? = null
val expectedCheckoutAt: String? = null,
val nightlyRate: Long? = null,
val currency: String? = null
)

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.BillingPolicyRequest
import com.android.trisolarispms.data.api.model.BillingPolicyResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface BillingPolicyApi {
@GET("properties/{propertyId}/billing-policy")
suspend fun getBillingPolicy(
@Path("propertyId") propertyId: String
): Response<BillingPolicyResponse>
@PUT("properties/{propertyId}/billing-policy")
suspend fun updateBillingPolicy(
@Path("propertyId") propertyId: String,
@Body body: BillingPolicyRequest
): Response<BillingPolicyResponse>
}

View File

@@ -4,16 +4,23 @@ import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckInRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsResponse
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.RoomStayDto
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
import com.google.gson.JsonObject
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
@@ -59,6 +66,40 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/profile")
suspend fun updateBookingProfile(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: JsonObject
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
suspend fun createRoomRequest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingRoomRequestCreateRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
suspend fun previewBillableNights(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBillableNightsRequest
): Response<BookingBillableNightsResponse>
@POST("properties/{propertyId}/bookings/expected-checkout-preview")
suspend fun previewExpectedCheckout(
@Path("propertyId") propertyId: String,
@Body body: BookingExpectedCheckoutPreviewRequest
): Response<BookingExpectedCheckoutPreviewResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/billing-policy")
suspend fun updateBookingBillingPolicy(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBillingPolicyUpdateRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails(
@Path("propertyId") propertyId: String,
@@ -70,7 +111,7 @@ interface BookingApi {
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest
): Response<BookingCreateResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn(
@@ -84,28 +125,35 @@ interface BookingApi {
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCheckOutRequest
): Response<ActionResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out")
suspend fun checkOutRoomStay(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Path("roomStayId") roomStayId: String,
@Body body: BookingRoomStayCheckOutRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/cancel")
suspend fun cancelBooking(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCancelRequest
): Response<ActionResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/no-show")
suspend fun noShow(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingNoShowRequest
): Response<ActionResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays")
suspend fun preAssignRoomStay(
@GET("properties/{propertyId}/bookings/{bookingId}/balance")
suspend fun getBookingBalance(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingRoomStayCreateRequest
): Response<RoomStayDto>
@Path("bookingId") bookingId: String
): Response<BookingBalanceResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr")
suspend fun generateRazorpayQr(

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.CancellationPolicyRequest
import com.android.trisolarispms.data.api.model.CancellationPolicyResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface CancellationPolicyApi {
@GET("properties/{propertyId}/cancellation-policy")
suspend fun getCancellationPolicy(
@Path("propertyId") propertyId: String
): Response<CancellationPolicyResponse>
@PUT("properties/{propertyId}/cancellation-policy")
suspend fun updateCancellationPolicy(
@Path("propertyId") propertyId: String,
@Body body: CancellationPolicyRequest
): Response<CancellationPolicyResponse>
}

View File

@@ -0,0 +1,20 @@
package com.android.trisolarispms.data.api.service
import com.google.gson.JsonElement
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
interface GeoApi {
@GET("geo/cities/search")
suspend fun searchCities(
@Query("q") query: String,
@Query("limit") limit: Int = 20
): Response<List<JsonElement>>
@GET("geo/countries/search")
suspend fun searchCountries(
@Query("q") query: String,
@Query("limit") limit: Int = 20
): Response<List<String>>
}

View File

@@ -2,7 +2,6 @@ package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
import com.android.trisolarispms.data.api.model.RoomBoardDto
import com.android.trisolarispms.data.api.model.RoomCreateRequest
import com.android.trisolarispms.data.api.model.RoomDto
@@ -55,7 +54,8 @@ interface RoomApi {
suspend fun getRoomAvailabilityRange(
@Path("propertyId") propertyId: String,
@Query("from") from: String,
@Query("to") to: String
@Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available")
@@ -63,14 +63,6 @@ interface RoomApi {
@Path("propertyId") propertyId: String
): Response<List<RoomDto>>
@GET("properties/{propertyId}/rooms/available-range-with-rate")
suspend fun listAvailableRoomsWithRate(
@Path("propertyId") propertyId: String,
@Query("from") from: String,
@Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailableRateResponse>>
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType(
@Path("propertyId") propertyId: String,

View File

@@ -1,8 +1,7 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RoomChangeRequest
import com.android.trisolarispms.data.api.model.RoomChangeResponse
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
import com.android.trisolarispms.data.api.model.RoomStayVoidRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
@@ -10,13 +9,13 @@ import retrofit2.http.POST
import retrofit2.http.Path
interface RoomStayApi {
@POST("properties/{propertyId}/room-stays/{roomStayId}/change-room")
suspend fun changeRoom(
@Path("propertyId") propertyId: String,
@Path("roomStayId") roomStayId: String,
@Body body: RoomChangeRequest
): Response<RoomChangeResponse>
@GET("properties/{propertyId}/room-stays/active")
suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response<List<ActiveRoomStayDto>>
@POST("properties/{propertyId}/room-stays/{roomStayId}/void")
suspend fun voidRoomStay(
@Path("propertyId") propertyId: String,
@Path("roomStayId") roomStayId: String,
@Body body: RoomStayVoidRequest
): Response<Unit>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package com.android.trisolarispms.ui.auth
import androidx.activity.ComponentActivity
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -27,27 +30,21 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.android.trisolarispms.ui.booking.phoneCountryOptions
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val activity = context as? ComponentActivity
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") }
val now = remember { mutableStateOf(System.currentTimeMillis()) }
val hasNetwork = remember(context, state.error) { hasInternetConnection(context) }
val noNetworkError = state.error?.contains("No internet connection", ignoreCase = true) == true
val isCheckingExistingSession = state.isLoading && !state.apiVerified && state.userId != null
val shouldHideAuthForm = !hasNetwork || noNetworkError || isCheckingExistingSession
LaunchedEffect(state.resendAvailableAt) {
while (state.resendAvailableAt != null) {
@@ -56,6 +53,30 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
}
}
if (shouldHideAuthForm) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center
) {
if (isCheckingExistingSession) {
Text(text = "Checking session...", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator()
} else {
Text(text = "No internet connection", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Please connect to the internet and try again.")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = viewModel::retryAfterConnectivityIssue) {
Text("Retry")
}
}
return
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -66,69 +87,12 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(0.35f)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
PhoneNumberCountryField(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
val filtered = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) true
else option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
filtered.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
viewModel.onPhoneCountryChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = state.phoneNationalNumber,
onValueChange = viewModel::onPhoneNationalNumberChange,
label = { Text("Number") },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(0.65f)
)
}
Spacer(modifier = Modifier.height(12.dp))
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true
@@ -200,12 +164,6 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
}
}
if (state.userId != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Firebase user: ${state.userId}")
Text(text = "API verified: ${state.apiVerified}")
}
if (state.noProperties) {
Spacer(modifier = Modifier.height(12.dp))
Text(
@@ -215,3 +173,10 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
}
}
}
private fun hasInternetConnection(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
val network = cm.activeNetwork ?: return false
val capabilities = cm.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
class AuthViewModel(
@@ -68,6 +69,15 @@ class AuthViewModel(
_state.update { it.copy(error = null) }
}
fun retryAfterConnectivityIssue() {
val currentUser = auth.currentUser
if (currentUser != null) {
verifyExistingSession(currentUser.uid)
} else {
clearError()
}
}
fun reportError(message: String) {
setError(message)
}
@@ -96,7 +106,7 @@ class AuthViewModel(
}
override fun onVerificationFailed(e: FirebaseException) {
setError(e.localizedMessage ?: "Verification failed")
setError(mapThrowableToMessage(e, fallback = "Verification failed"))
}
override fun onCodeSent(
@@ -168,7 +178,7 @@ class AuthViewModel(
val response = api.verifyAuth()
handleVerifyResponse(userId, response)
} catch (e: Exception) {
setError(e.localizedMessage ?: "Sign-in failed")
setError(mapThrowableToMessage(e, fallback = "Sign-in failed"))
}
}
}
@@ -200,7 +210,7 @@ class AuthViewModel(
val response = api.verifyAuth()
handleVerifyResponse(userId, response)
} catch (e: Exception) {
setError(e.localizedMessage ?: "Session verify failed")
setError(mapThrowableToMessage(e, fallback = "Session verify failed"))
}
}
}
@@ -251,7 +261,7 @@ class AuthViewModel(
noProperties = false,
unauthorized = false,
propertyRoles = emptyMap(),
error = "API verify failed: ${response.code()}"
error = mapHttpError(response.code(), "API verify failed")
)
}
}
@@ -309,10 +319,10 @@ class AuthViewModel(
)
}
} else {
setError("Update failed: ${response.code()}")
setError(mapHttpError(response.code(), "Update failed"))
}
} catch (e: Exception) {
setError(e.localizedMessage ?: "Update failed")
setError(mapThrowableToMessage(e, fallback = "Update failed"))
}
}
}
@@ -336,4 +346,21 @@ class AuthViewModel(
}
}
}
private fun mapHttpError(code: Int, prefix: String): String {
return if (code >= 500) {
"Server down. Please try again."
} else {
"$prefix: $code"
}
}
private fun mapThrowableToMessage(throwable: Throwable, fallback: String): String {
return when {
throwable is UnknownHostException -> "No internet connection."
throwable.localizedMessage?.contains("Unable to resolve host", ignoreCase = true) == true ->
"No internet connection."
else -> throwable.localizedMessage ?: fallback
}
}
}

View File

@@ -1,7 +1,5 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -10,30 +8,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -46,17 +31,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.CalendarMonth
import com.kizitonwose.calendar.core.DayPosition
import com.kizitonwose.calendar.core.daysOfWeek
import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
@@ -68,54 +49,56 @@ fun BookingCreateScreen(
viewModel: BookingCreateViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val showCheckInPicker = remember { mutableStateOf(false) }
val showCheckOutPicker = remember { mutableStateOf(false) }
val showCheckInDatePicker = remember { mutableStateOf(false) }
val showCheckInTimePicker = remember { mutableStateOf(false) }
val showCheckOutDatePicker = remember { mutableStateOf(false) }
val showCheckOutTimePicker = remember { mutableStateOf(false) }
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val checkInNow = remember { mutableStateOf(true) }
val sourceMenuExpanded = remember { mutableStateOf(false) }
val sourceOptions = listOf("WALKIN", "OTA", "AGENT")
val relationMenuExpanded = remember { mutableStateOf(false) }
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) }
val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
val billingModeMenuExpanded = remember { mutableStateOf(false) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
val applyCheckInSelection: (LocalDate, String) -> Unit = { date, time ->
checkInDate.value = date
checkInTime.value = time
val checkInAt = formatBookingIso(date, time)
viewModel.onExpectedCheckInAtChange(checkInAt)
val currentCheckOutDate = checkOutDate.value
if (currentCheckOutDate != null && currentCheckOutDate.isBefore(date)) {
checkOutDate.value = date
val adjustedCheckOutAt = formatBookingIso(date, checkOutTime.value)
viewModel.onExpectedCheckOutAtChange(adjustedCheckOutAt)
}
viewModel.autoSetBillingFromCheckIn(checkInAt)
viewModel.refreshExpectedCheckoutPreview(propertyId)
}
LaunchedEffect(propertyId) {
viewModel.reset()
viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
checkInNow.value = true
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate
checkOutTime.value = "11:00"
viewModel.onExpectedCheckOutAtChange(formatIso(defaultCheckoutDate, checkOutTime.value))
viewModel.onExpectedCheckOutAtChange(formatBookingIso(defaultCheckoutDate, checkOutTime.value))
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Booking") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submit(propertyId, onCreated) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
LaunchedEffect(state.expectedCheckOutAt) {
val parsed = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull() ?: return@LaunchedEffect
checkOutDate.value = parsed.toLocalDate()
checkOutTime.value = parsed.format(timeFormatter)
}
SaveTopBarScaffold(
title = "Create Booking",
onBack = onBack,
onSave = { viewModel.submit(propertyId, onCreated) }
) { padding ->
Column(
modifier = Modifier
@@ -125,59 +108,76 @@ fun BookingCreateScreen(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Check in now")
Switch(
checked = checkInNow.value,
onCheckedChange = { enabled ->
checkInNow.value = enabled
if (enabled) {
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
} else {
viewModel.onExpectedCheckInAtChange("")
val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val checkOutDisplay = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val cardDateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val cardTimeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
val totalTimeText = remember(state.expectedCheckInAt, state.expectedCheckOutAt) {
val start = runCatching { OffsetDateTime.parse(state.expectedCheckInAt) }.getOrNull()
val end = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull()
formatBookingDurationText(start, end)
}
BookingDateTimeQuickEditorCard(
checkInDateText = checkInDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkInTimeText = checkInDisplay?.format(cardTimeFormatter) ?: "--:--",
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
totalTimeText = totalTimeText,
checkInEditable = true,
onCheckInDateClick = { showCheckInDatePicker.value = true },
onCheckInTimeClick = { showCheckInTimePicker.value = true },
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
onCheckOutTimeClick = { showCheckOutTimePicker.value = true }
)
}
if (!checkInNow.value) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
trailingIcon = {
IconButton(onClick = { showCheckInPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = billingModeMenuExpanded.value,
onExpandedChange = { billingModeMenuExpanded.value = !billingModeMenuExpanded.value }
) {
OutlinedTextField(
value = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
value = state.billingMode.name,
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-out") },
label = { Text("Billing Mode") },
trailingIcon = {
IconButton(onClick = { showCheckOutPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
ExposedDropdownMenuDefaults.TrailingIcon(expanded = billingModeMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = billingModeMenuExpanded.value,
onDismissRequest = { billingModeMenuExpanded.value = false }
) {
BookingBillingMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.name) },
onClick = {
billingModeMenuExpanded.value = false
viewModel.onBillingModeChange(mode)
viewModel.refreshExpectedCheckoutPreview(propertyId)
}
)
}
}
}
if (state.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
Spacer(modifier = Modifier.height(12.dp))
BookingTimePickerTextField(
value = state.billingCheckoutTime,
label = { Text("Billing check-out (HH:mm)") },
onTimeSelected = { selectedTime ->
viewModel.onBillingCheckoutTimeChange(selectedTime)
viewModel.refreshExpectedCheckoutPreview(propertyId)
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
val phoneDigitsLength = state.phoneNationalNumber.length
@@ -224,115 +224,31 @@ fun BookingCreateScreen(
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(0.3f)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
PhoneNumberCountryField(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
countryWeight = 0.3f,
numberWeight = 0.7f
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
val filteredCountries = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) {
true
} else {
option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
}
filteredCountries.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
viewModel.onPhoneCountryChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = state.phoneNationalNumber,
onValueChange = viewModel::onPhoneNationalNumberChange,
label = { Text("Number") },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(0.7f)
)
}
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = sourceMenuExpanded.value,
onExpandedChange = { sourceMenuExpanded.value = !sourceMenuExpanded.value }
) {
OutlinedTextField(
value = state.source,
onValueChange = {},
readOnly = true,
label = { Text("Source") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceMenuExpanded.value) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = sourceMenuExpanded.value,
onDismissRequest = { sourceMenuExpanded.value = false }
) {
sourceOptions.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
sourceMenuExpanded.value = false
viewModel.onSourceChange(option)
}
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
CityAutocompleteField(
value = state.fromCity,
onValueChange = viewModel::onFromCityChange,
label = { Text("From City (optional)") },
modifier = Modifier.fillMaxWidth()
label = "From City (optional)",
suggestions = state.fromCitySuggestions,
isLoading = state.isFromCitySearchLoading,
onSuggestionSelected = viewModel::onFromCitySuggestionSelected
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
CityAutocompleteField(
value = state.toCity,
onValueChange = viewModel::onToCityChange,
label = { Text("To City (optional)") },
modifier = Modifier.fillMaxWidth()
label = "To City (optional)",
suggestions = state.toCitySuggestions,
isLoading = state.isToCitySearchLoading,
onSuggestionSelected = viewModel::onToCitySuggestionSelected
)
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
@@ -355,7 +271,7 @@ fun BookingCreateScreen(
expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false }
) {
relationOptions.forEach { option ->
BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
@@ -372,7 +288,7 @@ fun BookingCreateScreen(
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) {
OutlinedTextField(
value = state.transportMode,
value = state.transportMode.ifBlank { "Not set" },
onValueChange = {},
readOnly = true,
label = { Text("Transport Mode") },
@@ -387,9 +303,10 @@ fun BookingCreateScreen(
expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false }
) {
transportOptions.forEach { option ->
BookingProfileOptions.transportModes.forEach { option ->
val optionLabel = option.ifBlank { "Not set" }
DropdownMenuItem(
text = { Text(option) },
text = { Text(optionLabel) },
onClick = {
transportMenuExpanded.value = false
viewModel.onTransportModeChange(option)
@@ -456,187 +373,51 @@ fun BookingCreateScreen(
}
}
if (showCheckInPicker.value) {
DateTimePickerDialog(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
if (showCheckInDatePicker.value) {
BookingDatePickerDialog(
initialDate = checkInDate.value ?: LocalDate.now(),
minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false },
onConfirm = { date, time ->
checkInDate.value = date
checkInTime.value = time
val formatted = formatIso(date, time)
viewModel.onExpectedCheckInAtChange(formatted)
showCheckInPicker.value = false
onDismiss = { showCheckInDatePicker.value = false },
onDateSelected = { selectedDate ->
applyCheckInSelection(selectedDate, checkInTime.value)
}
)
}
if (showCheckOutPicker.value) {
DateTimePickerDialog(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
if (showCheckInTimePicker.value) {
BookingTimePickerDialog(
initialTime = checkInTime.value,
onDismiss = { showCheckInTimePicker.value = false },
onTimeSelected = { selectedTime ->
val selectedDate = checkInDate.value ?: LocalDate.now()
applyCheckInSelection(selectedDate, selectedTime)
}
)
}
if (showCheckOutDatePicker.value) {
BookingDatePickerDialog(
initialDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now()),
minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false },
onConfirm = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
val formatted = formatIso(date, time)
onDismiss = { showCheckOutDatePicker.value = false },
onDateSelected = { selectedDate ->
checkOutDate.value = selectedDate
val formatted = formatBookingIso(selectedDate, checkOutTime.value)
viewModel.onExpectedCheckOutAtChange(formatted)
}
)
}
if (showCheckOutTimePicker.value) {
BookingTimePickerDialog(
initialTime = checkOutTime.value,
onDismiss = { showCheckOutTimePicker.value = false },
onTimeSelected = { selectedTime ->
checkOutTime.value = selectedTime
val selectedDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now())
val formatted = formatBookingIso(selectedDate, selectedTime)
viewModel.onExpectedCheckOutAtChange(formatted)
showCheckOutPicker.value = false
}
)
}
}
@Composable
private fun DateTimePickerDialog(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, String) -> Unit
) {
val today = remember { LocalDate.now() }
val currentMonth = remember { YearMonth.from(today) }
val startMonth = remember { currentMonth }
val endMonth = remember { currentMonth.plusMonths(24) }
val daysOfWeek = remember { daysOfWeek() }
val calendarState = rememberCalendarState(
startMonth = startMonth,
endMonth = endMonth,
firstVisibleMonth = currentMonth,
firstDayOfWeek = daysOfWeek.first()
)
val selectedDate = remember { mutableStateOf(initialDate ?: today) }
val timeValue = remember { mutableStateOf(initialTime) }
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
DaysOfWeekHeader(daysOfWeek)
HorizontalCalendar(
state = calendarState,
dayContent = { day ->
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
DayCell(
day = day,
isSelectedStart = selectedDate.value == day.date,
isSelectedEnd = false,
isInRange = false,
hasRate = false,
isSelectable = selectable,
onClick = { selectedDate.value = day.date }
)
},
monthHeader = { month ->
MonthHeader(month)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = timeValue.value,
onValueChange = { timeValue.value = it },
label = { Text("Time (HH:MM)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
val time = timeValue.value.ifBlank { initialTime }
onConfirm(selectedDate.value, time)
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun DaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
daysOfWeek.forEach { day ->
Text(
text = day.name.take(3),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
@Composable
private fun MonthHeader(month: CalendarMonth) {
Text(
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun DayCell(
day: CalendarDay,
isSelectedStart: Boolean,
isSelectedEnd: Boolean,
isInRange: Boolean,
hasRate: Boolean,
isSelectable: Boolean,
onClick: () -> Unit
) {
val isInMonth = day.position == DayPosition.MonthDate
val background = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
isInRange -> MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> Color.Transparent
}
val textColor = when {
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
!isSelectable -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
Column(
modifier = Modifier
.size(40.dp)
.padding(2.dp)
.background(background, shape = MaterialTheme.shapes.small)
.clickable(enabled = isSelectable) { onClick() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
}
}
private fun formatIso(date: LocalDate, time: String): String {
val parts = time.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
val zone = ZoneId.of("Asia/Kolkata")
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
val offset = zone.rules.getOffset(localDateTime)
return OffsetDateTime.of(localDateTime, offset)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}

View File

@@ -1,5 +1,7 @@
package com.android.trisolarispms.ui.booking
import com.android.trisolarispms.data.api.model.BookingBillingMode
data class BookingCreateState(
val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
@@ -8,11 +10,18 @@ data class BookingCreateState(
val phoneVisitCountPhone: String? = null,
val expectedCheckInAt: String = "",
val expectedCheckOutAt: String = "",
val source: String = "WALKIN",
val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
val billingCheckoutTime: String = "",
val source: String = "",
val fromCity: String = "",
val fromCitySuggestions: List<String> = emptyList(),
val isFromCitySearchLoading: Boolean = false,
val toCity: String = "",
val toCitySuggestions: List<String> = emptyList(),
val isToCitySearchLoading: Boolean = false,
val memberRelation: String = "",
val transportMode: String = "CAR",
val isTransportModeAuto: Boolean = true,
val childCount: String = "",
val maleCount: String = "",
val femaleCount: String = "",

View File

@@ -1,32 +1,184 @@
package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
import com.android.trisolarispms.core.viewmodel.CitySearchController
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.GeoSearchRepository
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
import com.android.trisolarispms.data.api.model.GuestDto
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class BookingCreateViewModel(
application: Application
) : AndroidViewModel(application) {
private companion object {
const val DEFAULT_PREVIEW_BILLABLE_NIGHTS = 1
}
class BookingCreateViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingCreateState())
val state: StateFlow<BookingCreateState> = _state
private val activeRoomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private val bookingListRepository = BookingListRepository(
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
)
private val bookingDetailsRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private var expectedCheckoutPreviewRequestId: Long = 0
private val fromCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isFromCitySearchLoading = isLoading,
fromCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
private val toCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isToCitySearchLoading = isLoading,
toCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
fun reset() {
expectedCheckoutPreviewRequestId = 0
fromCitySearch.cancel()
toCitySearch.cancel()
_state.value = BookingCreateState()
}
fun onExpectedCheckInAtChange(value: String) {
_state.update { it.copy(expectedCheckInAt = value, error = null) }
_state.update { current ->
val withCheckIn = current.copy(expectedCheckInAt = value, error = null)
withCheckIn.withDefaultTransportModeForCheckIn(value)
}
}
fun onExpectedCheckOutAtChange(value: String) {
_state.update { it.copy(expectedCheckOutAt = value, error = null) }
}
fun autoSetBillingFromCheckIn(checkInAtIso: String) {
val checkIn = runCatching { OffsetDateTime.parse(checkInAtIso) }.getOrNull() ?: return
val hour = checkIn.hour
if (hour in 0..4) {
_state.update {
it.copy(
billingMode = BookingBillingMode.CUSTOM_WINDOW,
billingCheckoutTime = "17:00",
error = null
)
}
return
}
if (hour in 5..11) {
_state.update { it.copy(billingMode = BookingBillingMode.FULL_24H, error = null) }
}
}
fun loadBillingPolicy(propertyId: String) {
if (propertyId.isBlank()) return
viewModelScope.launch {
try {
val api = ApiClient.create()
val response = api.getBillingPolicy(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
billingCheckoutTime = body.billingCheckoutTime.orEmpty(),
error = null
)
}
}
} catch (_: Exception) {
// Keep defaults; billing policy can still be set manually.
}
}
}
fun onBillingModeChange(value: BookingBillingMode) {
_state.update { it.copy(billingMode = value, error = null) }
}
fun onBillingCheckoutTimeChange(value: String) {
_state.update { it.copy(billingCheckoutTime = value, error = null) }
}
fun refreshExpectedCheckoutPreview(propertyId: String) {
if (propertyId.isBlank()) return
val requestBody = buildExpectedCheckoutPreviewRequest(_state.value) ?: return
val requestId = ++expectedCheckoutPreviewRequestId
viewModelScope.launch {
try {
val api = ApiClient.create()
val response = api.previewExpectedCheckout(
propertyId = propertyId,
body = requestBody
)
val expectedCheckOutAt = response.body()?.expectedCheckOutAt?.trim().orEmpty()
if (!response.isSuccessful || expectedCheckOutAt.isBlank() || requestId != expectedCheckoutPreviewRequestId) {
return@launch
}
_state.update { current ->
if (requestId != expectedCheckoutPreviewRequestId) {
current
} else {
current.copy(expectedCheckOutAt = expectedCheckOutAt, error = null)
}
}
} catch (_: Exception) {
// Keep user-entered check-out on preview failures.
}
}
}
private fun buildExpectedCheckoutPreviewRequest(state: BookingCreateState): BookingExpectedCheckoutPreviewRequest? {
val expectedCheckInAt = state.expectedCheckInAt.trim()
if (expectedCheckInAt.isBlank()) return null
val customBillingCheckoutTime = state.billingCheckoutTime.trim().ifBlank { null }
return BookingExpectedCheckoutPreviewRequest(
checkInAt = expectedCheckInAt,
billableNights = DEFAULT_PREVIEW_BILLABLE_NIGHTS,
billingMode = state.billingMode,
billingCheckoutTime = if (state.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
customBillingCheckoutTime
} else {
null
}
)
}
fun onPhoneCountryChange(value: String) {
val option = findPhoneCountryOption(value)
_state.update { current ->
@@ -90,10 +242,36 @@ class BookingCreateViewModel : ViewModel() {
fun onFromCityChange(value: String) {
_state.update { it.copy(fromCity = value, error = null) }
fromCitySearch.onQueryChanged(value)
}
fun onToCityChange(value: String) {
_state.update { it.copy(toCity = value, error = null) }
toCitySearch.onQueryChanged(value)
}
fun onFromCitySuggestionSelected(value: String) {
fromCitySearch.cancel()
_state.update {
it.copy(
fromCity = value,
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
error = null
)
}
}
fun onToCitySuggestionSelected(value: String) {
toCitySearch.cancel()
_state.update {
it.copy(
toCity = value,
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
error = null
)
}
}
fun onMemberRelationChange(value: String) {
@@ -101,19 +279,34 @@ class BookingCreateViewModel : ViewModel() {
}
fun onTransportModeChange(value: String) {
_state.update { it.copy(transportMode = value, error = null) }
_state.update {
it.copy(
transportMode = value,
isTransportModeAuto = false,
error = null
)
}
}
fun onChildCountChange(value: String) {
_state.update { it.copy(childCount = value.filter { it.isDigit() }, error = null) }
_state.update { current ->
current.copy(childCount = value.filter { it.isDigit() }, error = null)
.withDefaultMemberRelationForFamily()
}
}
fun onMaleCountChange(value: String) {
_state.update { it.copy(maleCount = value.filter { it.isDigit() }, error = null) }
_state.update { current ->
current.copy(maleCount = value.filter { it.isDigit() }, error = null)
.withDefaultMemberRelationForFamily()
}
}
fun onFemaleCountChange(value: String) {
_state.update { it.copy(femaleCount = value.filter { it.isDigit() }, error = null) }
_state.update { current ->
current.copy(femaleCount = value.filter { it.isDigit() }, error = null)
.withDefaultMemberRelationForFamily()
}
}
fun onExpectedGuestCountChange(value: String) {
@@ -132,6 +325,14 @@ class BookingCreateViewModel : ViewModel() {
_state.update { it.copy(error = "Check-in and check-out are required") }
return
}
val hhmmRegex = Regex("^([01]\\d|2[0-3]):[0-5]\\d$")
val billingCheckout = current.billingCheckoutTime.trim()
if (current.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
if (!hhmmRegex.matches(billingCheckout)) {
_state.update { it.copy(error = "Billing checkout time must be HH:mm") }
return
}
}
val childCount = current.childCount.toIntOrNull()
val maleCount = current.maleCount.toIntOrNull()
val femaleCount = current.femaleCount.toIntOrNull()
@@ -152,6 +353,12 @@ class BookingCreateViewModel : ViewModel() {
body = BookingCreateRequest(
expectedCheckInAt = checkIn,
expectedCheckOutAt = checkOut,
billingMode = current.billingMode,
billingCheckoutTime = when (current.billingMode) {
BookingBillingMode.CUSTOM_WINDOW -> billingCheckout
BookingBillingMode.PROPERTY_POLICY -> null
BookingBillingMode.FULL_24H -> null
},
source = current.source.trim().ifBlank { null },
guestPhoneE164 = phone,
fromCity = current.fromCity.trim().ifBlank { null },
@@ -167,6 +374,10 @@ class BookingCreateViewModel : ViewModel() {
)
val body = response.body()
if (response.isSuccessful && body != null) {
syncCreateBookingCaches(
propertyId = propertyId,
bookingId = body.id
)
_state.update { it.copy(isLoading = false, error = null) }
onDone(body, null, phone)
} else {
@@ -177,4 +388,34 @@ class BookingCreateViewModel : ViewModel() {
}
}
}
private suspend fun syncCreateBookingCaches(propertyId: String, bookingId: String?) {
activeRoomStayRepository.refresh(propertyId = propertyId)
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "OPEN")
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "CHECKED_IN")
val safeBookingId = bookingId?.trim().orEmpty()
if (safeBookingId.isNotBlank()) {
bookingDetailsRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = safeBookingId
)
}
}
}
private fun BookingCreateState.withDefaultTransportModeForCheckIn(expectedCheckInAt: String): BookingCreateState {
if (!isTransportModeAuto) return this
val defaultMode = if (isFutureBookingCheckIn(expectedCheckInAt)) "" else "CAR"
if (transportMode == defaultMode) return this
return copy(transportMode = defaultMode)
}
private fun BookingCreateState.withDefaultMemberRelationForFamily(): BookingCreateState {
if (memberRelation.isNotBlank()) return this
val child = childCount.toIntOrNull() ?: 0
val male = maleCount.toIntOrNull() ?: 0
val female = femaleCount.toIntOrNull() ?: 0
val shouldDefaultFamily = child >= 1 || (male >= 1 && female >= 1)
if (!shouldDefaultFamily) return this
return copy(memberRelation = "FAMILY")
}

View File

@@ -0,0 +1,228 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.ui.calendar.CalendarDayCell
import com.android.trisolarispms.ui.calendar.CalendarDaysOfWeekHeader
import com.android.trisolarispms.ui.calendar.CalendarMonthHeader
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.DayPosition
import com.kizitonwose.calendar.core.daysOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
internal fun BookingDateTimePickerDialog(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, String) -> Unit
) {
val (selectedDate, timeValue, daysOfWeek, calendarState) = rememberBookingDateTimePickerState(
initialDate = initialDate,
initialTime = initialTime,
minDate = minDate
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
BookingDateTimePickerContent(
selectedDate = selectedDate,
timeValue = timeValue,
minDate = minDate,
daysOfWeek = daysOfWeek,
calendarState = calendarState
)
},
confirmButton = {
TextButton(
onClick = {
val time = timeValue.value.ifBlank { initialTime }
val date = if (selectedDate.value.isBefore(minDate)) minDate else selectedDate.value
onConfirm(date, time)
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
internal fun BookingDateTimePickerInline(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onValueChange: (LocalDate, String) -> Unit
) {
val (selectedDate, timeValue, daysOfWeek, calendarState) = rememberBookingDateTimePickerState(
initialDate = initialDate,
initialTime = initialTime,
minDate = minDate
)
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
BookingDateTimePickerContent(
selectedDate = selectedDate,
timeValue = timeValue,
minDate = minDate,
daysOfWeek = daysOfWeek,
calendarState = calendarState,
onDateSelected = { date ->
val safeDate = if (date.isBefore(minDate)) minDate else date
onValueChange(safeDate, timeValue.value)
},
onTimeSelected = { time ->
val safeDate = if (selectedDate.value.isBefore(minDate)) minDate else selectedDate.value
onValueChange(safeDate, time)
}
)
}
}
}
@Composable
private fun BookingDateTimePickerContent(
selectedDate: MutableState<LocalDate>,
timeValue: MutableState<String>,
minDate: LocalDate,
daysOfWeek: List<java.time.DayOfWeek>,
calendarState: com.kizitonwose.calendar.compose.CalendarState,
onDateSelected: (LocalDate) -> Unit = {},
onTimeSelected: (String) -> Unit = {}
) {
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
Column {
CalendarDaysOfWeekHeader(daysOfWeek)
HorizontalCalendar(
state = calendarState,
dayContent = { day ->
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
CalendarDayCell(
day = day,
isSelectedStart = selectedDate.value == day.date,
isSelectedEnd = false,
isInRange = false,
hasRate = false,
isSelectable = selectable,
onClick = {
selectedDate.value = day.date
onDateSelected(day.date)
}
)
},
monthHeader = { month ->
CalendarMonthHeader(month)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
BookingTimePickerTextField(
value = timeValue.value,
onTimeSelected = {
timeValue.value = it
onTimeSelected(it)
},
label = { Text("Time (HH:MM)") },
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun rememberBookingDateTimePickerState(
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate
): PickerUiState {
val now = remember { LocalDate.now() }
val initialSelectedDate = remember(initialDate, minDate, now) {
val seed = initialDate ?: now
if (seed.isBefore(minDate)) minDate else seed
}
val selectedDate = remember(initialSelectedDate) { mutableStateOf(initialSelectedDate) }
val timeValue = remember(initialTime) { mutableStateOf(initialTime) }
val startMonth = remember(minDate) { YearMonth.from(minDate) }
val firstVisibleMonth = remember(initialSelectedDate) { YearMonth.from(initialSelectedDate) }
val endMonth = remember(startMonth) { startMonth.plusMonths(24) }
val daysOfWeek = remember { daysOfWeek() }
val calendarState = rememberCalendarState(
startMonth = startMonth,
endMonth = endMonth,
firstVisibleMonth = firstVisibleMonth,
firstDayOfWeek = daysOfWeek.first()
)
return PickerUiState(
selectedDate = selectedDate,
timeValue = timeValue,
daysOfWeek = daysOfWeek,
calendarState = calendarState
)
}
private data class PickerUiState(
val selectedDate: MutableState<LocalDate>,
val timeValue: MutableState<String>,
val daysOfWeek: List<java.time.DayOfWeek>,
val calendarState: com.kizitonwose.calendar.compose.CalendarState
)
internal fun formatBookingIso(date: LocalDate, time: String): String {
val parts = time.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
val zone = ZoneId.of("Asia/Kolkata")
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
val offset = zone.rules.getOffset(localDateTime)
return OffsetDateTime.of(localDateTime, offset)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}

View File

@@ -0,0 +1,217 @@
package com.android.trisolarispms.ui.booking
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
@Composable
internal fun BookingDateTimeQuickEditorCard(
checkInDateText: String,
checkInTimeText: String,
checkOutDateText: String,
checkOutTimeText: String,
totalTimeText: String?,
checkInEditable: Boolean,
checkOutEditable: Boolean = true,
onCheckInDateClick: () -> Unit,
onCheckInTimeClick: () -> Unit,
onCheckOutDateClick: () -> Unit,
onCheckOutTimeClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
BookingDateTimeQuickEditorRow(
label = "Check In Time:",
dateText = checkInDateText,
timeText = checkInTimeText,
editable = checkInEditable,
onDateClick = onCheckInDateClick,
onTimeClick = onCheckInTimeClick
)
BookingDateTimeQuickEditorRow(
label = "Check Out Time:",
dateText = checkOutDateText,
timeText = checkOutTimeText,
editable = checkOutEditable,
onDateClick = onCheckOutDateClick,
onTimeClick = onCheckOutTimeClick
)
if (!totalTimeText.isNullOrBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total Time:",
style = MaterialTheme.typography.titleSmall
)
Text(
text = totalTimeText,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
}
@Composable
private fun BookingDateTimeQuickEditorRow(
label: String,
dateText: String,
timeText: String,
editable: Boolean,
onDateClick: () -> Unit,
onTimeClick: () -> Unit
) {
val valueColor = if (editable) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = label, style = MaterialTheme.typography.bodyLarge)
Text(
text = timeText,
color = valueColor,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable(enabled = editable, onClick = onTimeClick)
.padding(vertical = 2.dp)
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = dateText,
color = valueColor,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable(enabled = editable, onClick = onDateClick)
.padding(vertical = 2.dp)
)
}
}
@Composable
internal fun BookingDatePickerDialog(
initialDate: LocalDate,
minDate: LocalDate,
onDismiss: () -> Unit,
onDateSelected: (LocalDate) -> Unit
) {
val context = LocalContext.current
val dismissState = rememberUpdatedState(onDismiss)
val selectDateState = rememberUpdatedState(onDateSelected)
DisposableEffect(context, initialDate, minDate) {
val dialog = DatePickerDialog(
context,
{ _, year, monthOfYear, dayOfMonth ->
selectDateState.value(LocalDate.of(year, monthOfYear + 1, dayOfMonth))
},
initialDate.year,
initialDate.monthValue - 1,
initialDate.dayOfMonth
)
val minDateMillis = minDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
dialog.datePicker.minDate = minDateMillis
dialog.setOnDismissListener { dismissState.value() }
dialog.show()
onDispose {
dialog.setOnDismissListener(null)
dialog.dismiss()
}
}
}
@Composable
internal fun BookingTimePickerDialog(
initialTime: String,
onDismiss: () -> Unit,
onTimeSelected: (String) -> Unit
) {
val context = LocalContext.current
val dismissState = rememberUpdatedState(onDismiss)
val selectTimeState = rememberUpdatedState(onTimeSelected)
val initialHour = initialTime.split(":").getOrNull(0)?.toIntOrNull()?.coerceIn(0, 23) ?: 12
val initialMinute = initialTime.split(":").getOrNull(1)?.toIntOrNull()?.coerceIn(0, 59) ?: 0
DisposableEffect(context, initialHour, initialMinute) {
val dialog = TimePickerDialog(
context,
{ _, hourOfDay, minute ->
selectTimeState.value("%02d:%02d".format(hourOfDay, minute))
},
initialHour,
initialMinute,
true
)
dialog.setOnDismissListener { dismissState.value() }
dialog.show()
onDispose {
dialog.setOnDismissListener(null)
dialog.dismiss()
}
}
}
internal fun formatBookingDurationText(
start: OffsetDateTime?,
end: OffsetDateTime?
): String? {
if (start == null || end == null || !end.isAfter(start)) return null
val totalMinutes = Duration.between(start, end).toMinutes()
val totalHours = totalMinutes / 60
val minutes = totalMinutes % 60
return if (totalHours >= 24) {
val days = totalHours / 24
val hoursLeft = totalHours % 24
"%02dd:%02dh left".format(days, hoursLeft)
} else if (totalHours > 0) {
"${totalHours}h ${minutes}m"
} else {
"${minutes}m"
}
}

View File

@@ -1,391 +0,0 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.CalendarMonth
import com.kizitonwose.calendar.core.DayPosition
import com.kizitonwose.calendar.core.daysOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingExpectedDatesScreen(
propertyId: String,
bookingId: String,
status: String?,
expectedCheckInAt: String?,
expectedCheckOutAt: String?,
onBack: () -> Unit,
onDone: () -> Unit
) {
val showCheckInPicker = remember { mutableStateOf(false) }
val showCheckOutPicker = remember { mutableStateOf(false) }
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val isLoading = remember { mutableStateOf(false) }
val error = remember { mutableStateOf<String?>(null) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val editableCheckIn = status?.uppercase() == "OPEN"
val scope = rememberCoroutineScope()
LaunchedEffect(bookingId) {
val now = OffsetDateTime.now()
expectedCheckInAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
val zoned = parsed.atZoneSameInstant(displayZone)
checkInDate.value = zoned.toLocalDate()
checkInTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
}
} ?: run {
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
}
expectedCheckOutAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
val zoned = parsed.atZoneSameInstant(displayZone)
checkOutDate.value = zoned.toLocalDate()
checkOutTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
}
} ?: run {
checkOutDate.value = (checkInDate.value ?: now.toLocalDate()).plusDays(1)
checkOutTime.value = "11:00"
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Update Expected Dates") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(
onClick = {
isLoading.value = true
error.value = null
val inAt = if (editableCheckIn) {
checkInDate.value?.let { formatIso(it, checkInTime.value) }
} else {
null
}
val outAt = checkOutDate.value?.let { formatIso(it, checkOutTime.value) }
scope.launch {
try {
val api = ApiClient.create()
val response = api.updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = BookingExpectedDatesRequest(
expectedCheckInAt = inAt,
expectedCheckOutAt = outAt
)
)
if (response.isSuccessful) {
onDone()
} else {
error.value = "Update failed: ${response.code()}"
}
} catch (e: Exception) {
error.value = e.localizedMessage ?: "Update failed"
} finally {
isLoading.value = false
}
}
},
enabled = !isLoading.value
) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
if (editableCheckIn) {
OutlinedTextField(
value = checkInDate.value?.let {
formatIso(it, checkInTime.value)
}?.let { iso ->
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
}.getOrDefault(iso)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
trailingIcon = {
IconButton(onClick = { showCheckInPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
OutlinedTextField(
value = checkOutDate.value?.let {
formatIso(it, checkOutTime.value)
}?.let { iso ->
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
}.getOrDefault(iso)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-out") },
trailingIcon = {
IconButton(onClick = { showCheckOutPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
if (isLoading.value) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
error.value?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
if (showCheckInPicker.value && editableCheckIn) {
DateTimePickerDialog(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false },
onConfirm = { date, time ->
checkInDate.value = date
checkInTime.value = time
showCheckInPicker.value = false
}
)
}
if (showCheckOutPicker.value) {
DateTimePickerDialog(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false },
onConfirm = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
showCheckOutPicker.value = false
}
)
}
}
@Composable
private fun DateTimePickerDialog(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, String) -> Unit
) {
val today = remember { LocalDate.now() }
val currentMonth = remember { YearMonth.from(today) }
val startMonth = remember { currentMonth }
val endMonth = remember { currentMonth.plusMonths(24) }
val daysOfWeek = remember { daysOfWeek() }
val calendarState = rememberCalendarState(
startMonth = startMonth,
endMonth = endMonth,
firstVisibleMonth = currentMonth,
firstDayOfWeek = daysOfWeek.first()
)
val selectedDate = remember { mutableStateOf(initialDate ?: today) }
val timeValue = remember { mutableStateOf(initialTime) }
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
DaysOfWeekHeader(daysOfWeek)
HorizontalCalendar(
state = calendarState,
dayContent = { day ->
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
DayCell(
day = day,
isSelectedStart = selectedDate.value == day.date,
isSelectedEnd = false,
isInRange = false,
hasRate = false,
isSelectable = selectable,
onClick = { selectedDate.value = day.date }
)
},
monthHeader = { month ->
MonthHeader(month)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = timeValue.value,
onValueChange = { timeValue.value = it },
label = { Text("Time (HH:MM)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
val time = timeValue.value.ifBlank { initialTime }
onConfirm(selectedDate.value, time)
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun DaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
daysOfWeek.forEach { day ->
Text(
text = day.name.take(3),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
@Composable
private fun MonthHeader(month: CalendarMonth) {
Text(
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun DayCell(
day: CalendarDay,
isSelectedStart: Boolean,
isSelectedEnd: Boolean,
isInRange: Boolean,
hasRate: Boolean,
isSelectable: Boolean,
onClick: () -> Unit
) {
val isInMonth = day.position == DayPosition.MonthDate
val background = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
isInRange -> MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> Color.Transparent
}
val textColor = when {
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
!isSelectable -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
Column(
modifier = Modifier
.size(40.dp)
.padding(2.dp)
.background(background, shape = MaterialTheme.shapes.small)
.clickable(enabled = isSelectable) { onClick() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
}
}
private fun formatIso(date: LocalDate, time: String): String {
val parts = time.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
val zone = ZoneId.of("Asia/Kolkata")
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
val offset = zone.rules.getOffset(localDateTime)
return OffsetDateTime.of(localDateTime, offset)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}

View File

@@ -0,0 +1,163 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
fun BookingRoomRequestScreen(
propertyId: String,
bookingId: String,
fromAt: String,
toAt: String,
onBack: () -> Unit,
onDone: () -> Unit,
viewModel: BookingRoomRequestViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(propertyId, fromAt, toAt) {
viewModel.load(propertyId, fromAt, toAt)
}
BackTopBarScaffold(
title = "Select Room Types",
onBack = onBack,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
val hasSelection = state.roomTypes.any { it.quantity > 0 }
Button(
onClick = {
viewModel.submit(propertyId, bookingId, fromAt, toAt, onDone)
},
enabled = hasSelection && !state.isSubmitting,
modifier = Modifier.fillMaxWidth()
) {
Text(if (state.isSubmitting) "Saving..." else "Proceed")
}
}
}
) { padding ->
PaddedScreenColumn(padding = padding, contentPadding = 16.dp) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!state.isLoading) {
if (state.roomTypes.isEmpty()) {
Text(text = "No room types found")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(state.roomTypes) { item ->
RoomTypeQuantityCard(
item = item,
onIncrease = { viewModel.increaseQuantity(item.roomTypeCode) },
onDecrease = { viewModel.decreaseQuantity(item.roomTypeCode) },
onRateChange = { viewModel.updateRate(item.roomTypeCode, it) }
)
}
}
}
}
}
}
}
@Composable
private fun RoomTypeQuantityCard(
item: BookingRoomTypeQuantityItem,
onIncrease: () -> Unit,
onDecrease: () -> Unit,
onRateChange: (String) -> Unit
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = item.roomTypeName, style = MaterialTheme.typography.titleMedium)
Text(
text = "${item.roomTypeCode} • Available: ${item.maxQuantity}",
style = MaterialTheme.typography.bodySmall
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onDecrease, enabled = item.quantity > 0) {
Icon(Icons.Default.Remove, contentDescription = "Decrease")
}
Text(
text = item.quantity.toString(),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 8.dp)
)
IconButton(onClick = onIncrease, enabled = item.quantity < item.maxQuantity) {
Icon(Icons.Default.Add, contentDescription = "Increase")
}
}
}
OutlinedTextField(
value = item.rateInput,
onValueChange = onRateChange,
label = { Text("Rate / night") },
placeholder = { Text("Enter rate") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
prefix = { item.currency?.takeIf { it.isNotBlank() }?.let { Text("$it ") } },
supportingText = {
item.ratePlanCode?.takeIf { it.isNotBlank() }?.let { Text("Plan: $it") }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp)
)
}
}

View File

@@ -0,0 +1,18 @@
package com.android.trisolarispms.ui.booking
data class BookingRoomTypeQuantityItem(
val roomTypeCode: String,
val roomTypeName: String,
val maxQuantity: Int,
val quantity: Int = 0,
val rateInput: String = "",
val currency: String? = null,
val ratePlanCode: String? = null
)
data class BookingRoomRequestState(
val isLoading: Boolean = false,
val isSubmitting: Boolean = false,
val error: String? = null,
val roomTypes: List<BookingRoomTypeQuantityItem> = emptyList()
)

View File

@@ -0,0 +1,183 @@
package com.android.trisolarispms.ui.booking
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
class BookingRoomRequestViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingRoomRequestState())
val state: StateFlow<BookingRoomRequestState> = _state
private val activeRoomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private val bookingListRepository = BookingListRepository(
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
)
private val bookingDetailsRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
fun load(propertyId: String, fromAt: String, toAt: String) {
if (propertyId.isBlank()) return
val fromDate = fromAt.toDateOnly() ?: run {
_state.update { it.copy(error = "Invalid check-in date") }
return
}
val toDate = toAt.toDateOnly() ?: run {
_state.update { it.copy(error = "Invalid check-out date") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val availabilityResponse = api.getRoomAvailabilityRange(propertyId, from = fromDate, to = toDate)
if (!availabilityResponse.isSuccessful) {
_state.update { it.copy(isLoading = false, error = "Load failed: ${availabilityResponse.code()}") }
return@launch
}
val currentByType = _state.value.roomTypes.associateBy { it.roomTypeCode }
val items = availabilityResponse.body().orEmpty()
.mapNotNull { entry ->
val maxQuantity = (entry.freeCount ?: entry.freeRoomNumbers.size).coerceAtLeast(0)
if (maxQuantity <= 0) return@mapNotNull null
val code = entry.roomTypeCode?.trim()?.takeIf { it.isNotBlank() }
?: entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val name = entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() } ?: code
val previous = currentByType[code]
val defaultRateInput = entry.averageRate?.toLong()?.toString().orEmpty()
BookingRoomTypeQuantityItem(
roomTypeCode = code,
roomTypeName = name,
maxQuantity = maxQuantity,
quantity = previous?.quantity?.coerceAtMost(maxQuantity) ?: 0,
rateInput = previous?.rateInput ?: defaultRateInput,
currency = entry.currency,
ratePlanCode = entry.ratePlanCode
)
}
.sortedBy { it.roomTypeName }
_state.update { it.copy(isLoading = false, roomTypes = items, error = null) }
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
fun increaseQuantity(roomTypeCode: String) {
updateQuantity(roomTypeCode, delta = 1)
}
fun decreaseQuantity(roomTypeCode: String) {
updateQuantity(roomTypeCode, delta = -1)
}
fun updateRate(roomTypeCode: String, value: String) {
val digitsOnly = value.filter { it.isDigit() }
_state.update { current ->
current.copy(
roomTypes = current.roomTypes.map { item ->
if (item.roomTypeCode == roomTypeCode) {
item.copy(rateInput = digitsOnly)
} else {
item
}
},
error = null
)
}
}
fun submit(
propertyId: String,
bookingId: String,
fromAt: String,
toAt: String,
onDone: () -> Unit
) {
if (propertyId.isBlank() || bookingId.isBlank() || fromAt.isBlank() || toAt.isBlank()) {
_state.update { it.copy(error = "Booking dates are missing") }
return
}
val selected = _state.value.roomTypes.filter { it.quantity > 0 }
if (selected.isEmpty()) {
_state.update { it.copy(error = "Select at least one room type") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSubmitting = true, error = null) }
try {
val api = ApiClient.create()
for (item in selected) {
val response = api.createRoomRequest(
propertyId = propertyId,
bookingId = bookingId,
body = BookingRoomRequestCreateRequest(
roomTypeCode = item.roomTypeCode,
quantity = item.quantity,
fromAt = fromAt,
toAt = toAt
)
)
if (!response.isSuccessful) {
_state.update { it.copy(isSubmitting = false, error = "Create failed: ${response.code()}") }
return@launch
}
}
syncRoomRequestCaches(propertyId = propertyId, bookingId = bookingId)
_state.update { it.copy(isSubmitting = false, error = null) }
onDone()
} catch (e: Exception) {
_state.update { it.copy(isSubmitting = false, error = e.localizedMessage ?: "Create failed") }
}
}
}
private fun updateQuantity(roomTypeCode: String, delta: Int) {
_state.update { current ->
current.copy(
roomTypes = current.roomTypes.map { item ->
if (item.roomTypeCode != roomTypeCode) {
item
} else {
val updated = (item.quantity + delta).coerceIn(0, item.maxQuantity)
item.copy(quantity = updated)
}
},
error = null
)
}
}
private suspend fun syncRoomRequestCaches(propertyId: String, bookingId: String) {
activeRoomStayRepository.refresh(propertyId = propertyId)
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "OPEN")
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "CHECKED_IN")
bookingDetailsRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
)
}
}
private fun String.toDateOnly(): String? =
runCatching {
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
}.getOrNull()

View File

@@ -0,0 +1,53 @@
package com.android.trisolarispms.ui.booking
import android.app.TimePickerDialog
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@Composable
internal fun BookingTimePickerTextField(
value: String,
label: @Composable () -> Unit,
onTimeSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val parsed = runCatching {
val parts = value.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 12
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
(hour.coerceIn(0, 23)) to (minute.coerceIn(0, 59))
}.getOrDefault(12 to 0)
fun openDialog() {
TimePickerDialog(
context,
{ _, hourOfDay, minute ->
onTimeSelected("%02d:%02d".format(hourOfDay, minute))
},
parsed.first,
parsed.second,
true
).show()
}
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
label = label,
trailingIcon = {
IconButton(onClick = ::openDialog) {
Icon(Icons.Default.Schedule, contentDescription = "Pick time")
}
},
modifier = modifier.clickable(onClick = ::openDialog)
)
}

View File

@@ -0,0 +1,88 @@
package com.android.trisolarispms.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.CalendarMonth
import com.kizitonwose.calendar.core.DayPosition
@Composable
fun CalendarDaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
daysOfWeek.forEach { day ->
Text(
text = day.name.take(3),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.height(6.dp))
}
@Composable
fun CalendarMonthHeader(month: CalendarMonth) {
Text(
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
fun CalendarDayCell(
day: CalendarDay,
isSelectedStart: Boolean,
isSelectedEnd: Boolean,
isInRange: Boolean,
hasRate: Boolean,
isSelectable: Boolean,
onClick: () -> Unit,
footerContent: (@Composable () -> Unit)? = null
) {
val isInMonth = day.position == DayPosition.MonthDate
val background = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primaryContainer
isInRange -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f)
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> Color.Transparent
}
val textColor = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.onPrimaryContainer
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
!isSelectable -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
else -> MaterialTheme.colorScheme.onSurface
}
Column(
modifier = Modifier
.size(40.dp)
.padding(2.dp)
.background(background, shape = MaterialTheme.shapes.small)
.clickable(enabled = isSelectable) { onClick() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
if (footerContent != null) {
footerContent()
}
}
}

View File

@@ -11,18 +11,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
@@ -39,6 +32,7 @@ import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import java.util.Calendar
import java.util.Date
import kotlinx.coroutines.launch
@@ -101,18 +95,9 @@ fun CardInfoScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Card Details") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
BackTopBarScaffold(
title = "Card Details",
onBack = onBack
) { padding ->
Column(
modifier = Modifier

View File

@@ -9,18 +9,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
@@ -39,6 +32,7 @@ import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.android.trisolarispms.ui.common.BackTopBarScaffold
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -97,18 +91,9 @@ fun IssueTemporaryCardScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Issue Temporary Card") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
BackTopBarScaffold(
title = "Issue Temporary Card",
onBack = onBack
) { padding ->
Column(
modifier = Modifier

View File

@@ -0,0 +1,89 @@
package com.android.trisolarispms.ui.common
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CityAutocompleteField(
value: String,
onValueChange: (String) -> Unit,
label: String,
suggestions: List<String>,
isLoading: Boolean,
onSuggestionSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
val expanded = remember { mutableStateOf(false) }
val query = value.trim()
val canShowMenu = expanded.value && (isLoading || suggestions.isNotEmpty() || query.length >= 2)
ExposedDropdownMenuBox(
expanded = canShowMenu,
onExpandedChange = { expanded.value = it }
) {
OutlinedTextField(
value = value,
onValueChange = { input ->
onValueChange(input)
expanded.value = input.trim().length >= 2
},
label = { Text(label) },
supportingText = {
if (query.length < 2) {
Text("Type at least 2 letters")
}
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded.value && (isLoading || suggestions.isNotEmpty())
)
},
modifier = modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
enabled = true
),
singleLine = true
)
ExposedDropdownMenu(
expanded = canShowMenu,
onDismissRequest = { expanded.value = false }
) {
if (isLoading) {
DropdownMenuItem(
text = { Text("Searching...") },
onClick = {},
enabled = false
)
} else if (query.length >= 2 && suggestions.isEmpty()) {
DropdownMenuItem(
text = { Text("No cities found") },
onClick = {},
enabled = false
)
} else {
suggestions.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
expanded.value = false
onSuggestionSelected(option)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package com.android.trisolarispms.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.android.trisolarispms.ui.booking.phoneCountryOptions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhoneNumberCountryField(
phoneCountryCode: String,
onPhoneCountryCodeChange: (String) -> Unit,
phoneNationalNumber: String,
onPhoneNationalNumberChange: (String) -> Unit,
modifier: Modifier = Modifier,
countryLabel: String = "Country",
numberLabel: String = "Number",
countryWeight: Float = 0.35f,
numberWeight: Float = 0.65f
) {
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountrySearch = remember { mutableStateOf("") }
val phoneCountries = remember { phoneCountryOptions() }
val selectedCountry = findPhoneCountryOption(phoneCountryCode)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(countryWeight)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text(countryLabel) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
),
singleLine = true
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
val filtered = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) {
true
} else {
option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
}
filtered.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
onPhoneCountryCodeChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = phoneNationalNumber,
onValueChange = onPhoneNationalNumberChange,
label = { Text(numberLabel) },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(numberWeight),
singleLine = true
)
}
}

View File

@@ -0,0 +1,126 @@
package com.android.trisolarispms.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackTopBarScaffold(
title: String,
onBack: () -> Unit,
showBack: Boolean = true,
actions: @Composable RowScope.() -> Unit = {},
bottomBar: @Composable (() -> Unit)? = null,
floatingActionButton: @Composable (() -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
if (showBack) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors()
)
},
bottomBar = { bottomBar?.invoke() },
floatingActionButton = { floatingActionButton?.invoke() },
content = content
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SaveTopBarScaffold(
title: String,
onBack: () -> Unit,
onSave: () -> Unit,
saveEnabled: Boolean = true,
actions: @Composable RowScope.() -> Unit = {},
bottomBar: @Composable (() -> Unit)? = null,
floatingActionButton: @Composable (() -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit
) {
BackTopBarScaffold(
title = title,
onBack = onBack,
actions = {
IconButton(onClick = onSave, enabled = saveEnabled) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
actions()
},
bottomBar = bottomBar,
floatingActionButton = floatingActionButton,
content = content
)
}
@Composable
fun PaddedScreenColumn(
padding: PaddingValues,
contentPadding: Dp = 24.dp,
scrollable: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
val baseModifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(contentPadding)
val scrollModifier = if (scrollable) {
baseModifier.verticalScroll(rememberScrollState())
} else {
baseModifier
}
Column(
modifier = scrollModifier,
verticalArrangement = Arrangement.Top,
content = content
)
}
@Composable
fun LoadingAndError(
isLoading: Boolean,
error: String?
) {
if (isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
}

View File

@@ -0,0 +1,396 @@
package com.android.trisolarispms.ui.guest
import android.app.DatePickerDialog
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GuestInfoFormFields(
phoneCountryCode: String,
onPhoneCountryCodeChange: (String) -> Unit,
phoneNationalNumber: String,
onPhoneNationalNumberChange: (String) -> Unit,
name: String,
onNameChange: (String) -> Unit,
nationality: String,
onNationalityChange: (String) -> Unit,
nationalitySuggestions: List<String>,
isNationalitySearchLoading: Boolean,
onNationalitySuggestionSelected: (String) -> Unit,
age: String,
onAgeChange: (String) -> Unit,
addressText: String,
onAddressChange: (String) -> Unit,
fromCity: String,
onFromCityChange: (String) -> Unit,
fromCitySuggestions: List<String>,
isFromCitySearchLoading: Boolean,
onFromCitySuggestionSelected: (String) -> Unit,
toCity: String,
onToCityChange: (String) -> Unit,
toCitySuggestions: List<String>,
isToCitySearchLoading: Boolean,
onToCitySuggestionSelected: (String) -> Unit,
memberRelation: String,
onMemberRelationChange: (String) -> Unit,
transportMode: String,
onTransportModeChange: (String) -> Unit,
childCount: String,
onChildCountChange: (String) -> Unit,
maleCount: String,
onMaleCountChange: (String) -> Unit,
femaleCount: String,
onFemaleCountChange: (String) -> Unit,
vehicleNumbers: List<String>
) {
val showDobPicker = remember { mutableStateOf(false) }
val nationalityMenuExpanded = remember { mutableStateOf(false) }
val relationMenuExpanded = remember { mutableStateOf(false) }
val transportMenuExpanded = remember { mutableStateOf(false) }
val dobFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val transportOptions = remember(vehicleNumbers) {
if (vehicleNumbers.isNotEmpty()) {
listOf("", "CAR", "BIKE")
} else {
BookingProfileOptions.transportModes
}
}
var dobFieldValue by remember {
mutableStateOf(TextFieldValue(text = age, selection = TextRange(age.length)))
}
LaunchedEffect(age) {
if (age != dobFieldValue.text) {
dobFieldValue = TextFieldValue(text = age, selection = TextRange(age.length))
}
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PhoneNumberCountryField(
phoneCountryCode = phoneCountryCode,
onPhoneCountryCodeChange = onPhoneCountryCodeChange,
phoneNationalNumber = phoneNationalNumber,
onPhoneNationalNumberChange = onPhoneNationalNumberChange,
numberLabel = "Phone (optional)"
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
ExposedDropdownMenuBox(
expanded = nationalityMenuExpanded.value &&
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
onExpandedChange = { nationalityMenuExpanded.value = it }
) {
OutlinedTextField(
value = nationality,
onValueChange = { value ->
onNationalityChange(value)
nationalityMenuExpanded.value = value.trim().length >= 3
},
label = { Text("Nationality (optional)") },
supportingText = {
if (nationality.trim().length < 3) {
Text("Type at least 3 letters")
}
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = nationalityMenuExpanded.value &&
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty())
)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
enabled = true
),
singleLine = true
)
ExposedDropdownMenu(
expanded = nationalityMenuExpanded.value &&
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
onDismissRequest = { nationalityMenuExpanded.value = false }
) {
if (isNationalitySearchLoading) {
DropdownMenuItem(
text = { Text("Searching...") },
onClick = {},
enabled = false
)
} else if (nationality.trim().length >= 3 && nationalitySuggestions.isEmpty()) {
DropdownMenuItem(
text = { Text("No countries found") },
onClick = {},
enabled = false
)
} else {
nationalitySuggestions.forEach { suggestion ->
DropdownMenuItem(
text = {
Text(suggestion)
},
onClick = {
nationalityMenuExpanded.value = false
onNationalitySuggestionSelected(suggestion)
}
)
}
}
}
}
OutlinedTextField(
value = dobFieldValue,
onValueChange = { input ->
val formatted = formatDobInput(input.text)
dobFieldValue = TextFieldValue(
text = formatted,
selection = TextRange(formatted.length)
)
if (formatted != age) onAgeChange(formatted)
},
label = { Text("DOB (dd/MM/yyyy)") },
trailingIcon = {
Icon(
imageVector = Icons.Default.CalendarMonth,
contentDescription = "Pick DOB",
modifier = Modifier.clickable { showDobPicker.value = true }
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = addressText,
onValueChange = onAddressChange,
label = { Text("Address (optional)") },
modifier = Modifier.fillMaxWidth()
)
CityAutocompleteField(
value = fromCity,
onValueChange = onFromCityChange,
label = "From City (optional)",
suggestions = fromCitySuggestions,
isLoading = isFromCitySearchLoading,
onSuggestionSelected = onFromCitySuggestionSelected
)
CityAutocompleteField(
value = toCity,
onValueChange = onToCityChange,
label = "To City (optional)",
suggestions = toCitySuggestions,
isLoading = isToCitySearchLoading,
onSuggestionSelected = onToCitySuggestionSelected
)
ExposedDropdownMenuBox(
expanded = relationMenuExpanded.value,
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
) {
OutlinedTextField(
value = memberRelation.ifBlank { "Not set" },
onValueChange = {},
readOnly = true,
label = { Text("Member Relation") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
)
)
ExposedDropdownMenu(
expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false }
) {
DropdownMenuItem(
text = { Text("Not set") },
onClick = {
relationMenuExpanded.value = false
onMemberRelationChange("")
}
)
BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
relationMenuExpanded.value = false
onMemberRelationChange(option)
}
)
}
}
}
ExposedDropdownMenuBox(
expanded = transportMenuExpanded.value,
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) {
OutlinedTextField(
value = transportMode.ifBlank { "Not set" },
onValueChange = {},
readOnly = true,
label = { Text("Transport Mode") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = transportMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
enabled = true
)
)
ExposedDropdownMenu(
expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false }
) {
transportOptions.forEach { option ->
DropdownMenuItem(
text = { Text(option.ifBlank { "Not set" }) },
onClick = {
transportMenuExpanded.value = false
onTransportModeChange(option)
}
)
}
}
}
OutlinedTextField(
value = childCount,
onValueChange = onChildCountChange,
label = { Text("Child Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = maleCount,
onValueChange = onMaleCountChange,
label = { Text("Male Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
singleLine = true
)
OutlinedTextField(
value = femaleCount,
onValueChange = onFemaleCountChange,
label = { Text("Female Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
singleLine = true
)
}
}
if (showDobPicker.value) {
val initialDate = age.toLocalDateOrNull(dobFormatter) ?: LocalDate.now().minusYears(18)
GuestDobDatePickerDialog(
initialDate = initialDate,
onDismiss = { showDobPicker.value = false },
onDateSelected = { selectedDate ->
onAgeChange(selectedDate.format(dobFormatter))
}
)
}
}
@Composable
private fun GuestDobDatePickerDialog(
initialDate: LocalDate,
onDismiss: () -> Unit,
onDateSelected: (LocalDate) -> Unit
) {
val context = LocalContext.current
val dismissState by rememberUpdatedState(onDismiss)
val selectState by rememberUpdatedState(onDateSelected)
DisposableEffect(context, initialDate) {
val dialog = DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
selectState(LocalDate.of(year, month + 1, dayOfMonth))
},
initialDate.year,
initialDate.monthValue - 1,
initialDate.dayOfMonth
)
val todayMillis = LocalDate.now()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val minDateMillis = LocalDate.of(1900, 1, 1)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
dialog.datePicker.maxDate = todayMillis
dialog.datePicker.minDate = minDateMillis
dialog.setOnDismissListener { dismissState() }
dialog.show()
onDispose {
dialog.setOnDismissListener(null)
dialog.dismiss()
}
}
}
private fun String.toLocalDateOrNull(formatter: DateTimeFormatter): LocalDate? =
runCatching { LocalDate.parse(this, formatter) }.getOrNull()
private fun formatDobInput(raw: String): String {
val digits = raw.filter { it.isDigit() }.take(8)
if (digits.isEmpty()) return ""
val builder = StringBuilder(digits.length + 2)
digits.forEachIndexed { index, char ->
builder.append(char)
if ((index == 1 || index == 3) && index != digits.lastIndex) {
builder.append('/')
}
}
return builder.toString()
}

View File

@@ -1,25 +1,10 @@
package com.android.trisolarispms.ui.guest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -27,11 +12,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestInfoScreen(
propertyId: String,
bookingId: String,
guestId: String,
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
initialPhone: String?,
@@ -41,70 +28,75 @@ fun GuestInfoScreen(
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(guestId) {
LaunchedEffect(propertyId, bookingId, guestId) {
viewModel.reset()
viewModel.setInitial(initialGuest, initialPhone)
viewModel.loadGuest(propertyId, guestId, initialPhone)
viewModel.loadGuest(
propertyId = propertyId,
bookingId = bookingId,
guestId = guestId,
fallbackPhone = initialPhone
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Guest Info") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submit(propertyId, guestId, onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
SaveTopBarScaffold(
title = "Guest Info",
onBack = onBack,
onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
PaddedScreenColumn(
padding = padding,
scrollable = true
) {
OutlinedTextField(
value = state.phoneE164,
onValueChange = viewModel::onPhoneChange,
label = { Text("Phone E164 (optional)") },
modifier = Modifier.fillMaxWidth()
GuestInfoFormFields(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = { code ->
viewModel.onPhoneCountryChange(
value = code,
propertyId = propertyId,
guestId = guestId
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth()
},
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = { number ->
viewModel.onPhoneNationalNumberChange(
value = number,
propertyId = propertyId,
guestId = guestId
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.nationality,
onValueChange = viewModel::onNationalityChange,
label = { Text("Nationality (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.age,
onValueChange = viewModel::onAgeChange,
label = { Text("DOB (dd/MM/yyyy)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.addressText,
onValueChange = viewModel::onAddressChange,
label = { Text("Address (optional)") },
modifier = Modifier.fillMaxWidth()
},
name = state.name,
onNameChange = viewModel::onNameChange,
nationality = state.nationality,
onNationalityChange = viewModel::onNationalityChange,
nationalitySuggestions = state.nationalitySuggestions,
isNationalitySearchLoading = state.isNationalitySearchLoading,
onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
age = state.age,
onAgeChange = viewModel::onAgeChange,
addressText = state.addressText,
onAddressChange = viewModel::onAddressChange,
fromCity = state.fromCity,
onFromCityChange = viewModel::onFromCityChange,
fromCitySuggestions = state.fromCitySuggestions,
isFromCitySearchLoading = state.isFromCitySearchLoading,
onFromCitySuggestionSelected = viewModel::onFromCitySuggestionSelected,
toCity = state.toCity,
onToCityChange = viewModel::onToCityChange,
toCitySuggestions = state.toCitySuggestions,
isToCitySearchLoading = state.isToCitySearchLoading,
onToCitySuggestionSelected = viewModel::onToCitySuggestionSelected,
memberRelation = state.memberRelation,
onMemberRelationChange = viewModel::onMemberRelationChange,
transportMode = state.transportMode,
onTransportModeChange = viewModel::onTransportModeChange,
childCount = state.childCount,
onChildCountChange = viewModel::onChildCountChange,
maleCount = state.maleCount,
onMaleCountChange = viewModel::onMaleCountChange,
femaleCount = state.femaleCount,
onFemaleCountChange = viewModel::onFemaleCountChange,
vehicleNumbers = state.vehicleNumbers
)
if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp))

View File

@@ -1,11 +1,25 @@
package com.android.trisolarispms.ui.guest
data class GuestInfoState(
val phoneE164: String = "",
val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
val name: String = "",
val nationality: String = "",
val nationalitySuggestions: List<String> = emptyList(),
val isNationalitySearchLoading: Boolean = false,
val age: String = "",
val addressText: String = "",
val fromCity: String = "",
val fromCitySuggestions: List<String> = emptyList(),
val isFromCitySearchLoading: Boolean = false,
val toCity: String = "",
val toCitySuggestions: List<String> = emptyList(),
val isToCitySearchLoading: Boolean = false,
val memberRelation: String = "",
val transportMode: String = "",
val childCount: String = "",
val maleCount: String = "",
val femaleCount: String = "",
val vehicleNumbers: List<String> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null

View File

@@ -1,25 +1,103 @@
package com.android.trisolarispms.ui.guest
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.core.viewmodel.CitySearchController
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.GeoSearchRepository
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.GuestDto
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GuestInfoViewModel : ViewModel() {
class GuestInfoViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(GuestInfoState())
val state: StateFlow<GuestInfoState> = _state
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private val roomStayRepository = ActiveRoomStayRepository(
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
)
private var nationalitySearchJob: Job? = null
private var phoneAutofillJob: Job? = null
private var lastAutofilledPhoneE164: String? = null
private var initialBookingProfile: BookingProfileSnapshot? = null
private val fromCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isFromCitySearchLoading = isLoading,
fromCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
private val toCitySearch = CitySearchController(
scope = viewModelScope,
onUpdate = { isLoading, suggestions ->
_state.update {
it.copy(
isToCitySearchLoading = isLoading,
toCitySuggestions = suggestions
)
}
},
search = { query, limit ->
GeoSearchRepository.searchCityDisplayValues(query = query, limit = limit)
}
)
fun reset() {
nationalitySearchJob?.cancel()
nationalitySearchJob = null
phoneAutofillJob?.cancel()
phoneAutofillJob = null
lastAutofilledPhoneE164 = null
fromCitySearch.cancel()
toCitySearch.cancel()
initialBookingProfile = null
_state.value = GuestInfoState()
}
fun onPhoneChange(value: String) {
_state.update { it.copy(phoneE164 = value, error = null) }
fun onPhoneCountryChange(value: String, propertyId: String, guestId: String) {
val option = findPhoneCountryOption(value)
_state.update { current ->
val trimmed = current.phoneNationalNumber.filter { it.isDigit() }.take(option.maxLength)
current.copy(
phoneCountryCode = option.code,
phoneNationalNumber = trimmed,
error = null
)
}
autoFillByPhoneIfExists(propertyId = propertyId, currentGuestId = guestId)
}
fun onPhoneNationalNumberChange(value: String, propertyId: String, guestId: String) {
val option = findPhoneCountryOption(_state.value.phoneCountryCode)
val trimmed = value.filter { it.isDigit() }.take(option.maxLength)
_state.update { it.copy(phoneNationalNumber = trimmed, error = null) }
autoFillByPhoneIfExists(propertyId = propertyId, currentGuestId = guestId)
}
fun onNameChange(value: String) {
@@ -28,6 +106,19 @@ class GuestInfoViewModel : ViewModel() {
fun onNationalityChange(value: String) {
_state.update { it.copy(nationality = value, error = null) }
searchCountrySuggestions(value)
}
fun onNationalitySuggestionSelected(suggestion: String) {
nationalitySearchJob?.cancel()
_state.update {
it.copy(
nationality = suggestion,
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
error = null
)
}
}
fun onAgeChange(value: String) {
@@ -38,13 +129,71 @@ class GuestInfoViewModel : ViewModel() {
_state.update { it.copy(addressText = value, error = null) }
}
fun setInitial(guest: GuestDto?, phone: String?) {
fun onFromCityChange(value: String) {
_state.update { it.copy(fromCity = value, error = null) }
fromCitySearch.onQueryChanged(value)
}
fun onToCityChange(value: String) {
_state.update { it.copy(toCity = value, error = null) }
toCitySearch.onQueryChanged(value)
}
fun onFromCitySuggestionSelected(value: String) {
fromCitySearch.cancel()
_state.update {
it.copy(
phoneE164 = guest?.phoneE164 ?: phone.orEmpty(),
fromCity = value,
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
error = null
)
}
}
fun onToCitySuggestionSelected(value: String) {
toCitySearch.cancel()
_state.update {
it.copy(
toCity = value,
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
error = null
)
}
}
fun onMemberRelationChange(value: String) {
_state.update { it.copy(memberRelation = value, error = null) }
}
fun onTransportModeChange(value: String) {
_state.update { it.copy(transportMode = value, error = null) }
}
fun onChildCountChange(value: String) {
_state.update { it.copy(childCount = value.filter { char -> char.isDigit() }, error = null) }
}
fun onMaleCountChange(value: String) {
_state.update { it.copy(maleCount = value.filter { char -> char.isDigit() }, error = null) }
}
fun onFemaleCountChange(value: String) {
_state.update { it.copy(femaleCount = value.filter { char -> char.isDigit() }, error = null) }
}
fun setInitial(guest: GuestDto?, phone: String?) {
val parsedPhone = parsePhoneE164(guest?.phoneE164 ?: phone)
_state.update {
it.copy(
phoneCountryCode = parsedPhone.countryCode,
phoneNationalNumber = parsedPhone.nationalNumber,
name = guest?.name.orEmpty(),
nationality = guest?.nationality.orEmpty(),
age = guest?.age.orEmpty(),
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
age = guest?.dob.orEmpty(),
addressText = guest?.addressText.orEmpty(),
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
error = null
@@ -52,75 +201,421 @@ class GuestInfoViewModel : ViewModel() {
}
}
fun loadGuest(propertyId: String, guestId: String, fallbackPhone: String?) {
if (propertyId.isBlank() || guestId.isBlank()) return
fun loadGuest(propertyId: String, bookingId: String, guestId: String, fallbackPhone: String?) {
if (propertyId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
var loadError: String? = null
try {
val api = ApiClient.create()
val response = api.getGuest(propertyId = propertyId, guestId = guestId)
val guest = response.body()
if (response.isSuccessful && guest != null) {
if (guestId.isNotBlank()) {
val guestResponse = api.getGuest(propertyId = propertyId, guestId = guestId)
val guest = guestResponse.body()
if (guestResponse.isSuccessful && guest != null) {
val parsedPhone = parsePhoneE164(guest.phoneE164 ?: fallbackPhone)
_state.update {
it.copy(
phoneE164 = guest.phoneE164 ?: fallbackPhone.orEmpty(),
phoneCountryCode = parsedPhone.countryCode,
phoneNationalNumber = parsedPhone.nationalNumber,
name = guest.name.orEmpty(),
nationality = guest.nationality.orEmpty(),
age = guest.age.orEmpty(),
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
age = guest.dob.orEmpty(),
addressText = guest.addressText.orEmpty(),
vehicleNumbers = guest.vehicleNumbers ?: emptyList(),
isLoading = false,
vehicleNumbers = guest.vehicleNumbers,
error = null
)
}
} else {
val parsedPhone = parsePhoneE164(fallbackPhone)
_state.update {
it.copy(
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
isLoading = false,
error = "Load failed: ${response.code()}"
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
isNationalitySearchLoading = false
)
}
loadError = "Load failed: ${guestResponse.code()}"
}
}
if (bookingId.isNotBlank()) {
val detailsResponse = api.getBookingDetails(propertyId = propertyId, bookingId = bookingId)
val details = detailsResponse.body()
if (detailsResponse.isSuccessful && details != null) {
val snapshot = BookingProfileSnapshot(
transportMode = details.transportMode?.trim()?.ifBlank { null },
childCount = details.childCount,
maleCount = details.maleCount,
femaleCount = details.femaleCount,
fromCity = details.fromCity?.trim()?.ifBlank { null },
toCity = details.toCity?.trim()?.ifBlank { null },
memberRelation = details.memberRelation?.trim()?.ifBlank { null }
)
initialBookingProfile = snapshot
_state.update {
it.copy(
fromCity = snapshot.fromCity.orEmpty(),
fromCitySuggestions = emptyList(),
isFromCitySearchLoading = false,
toCity = snapshot.toCity.orEmpty(),
toCitySuggestions = emptyList(),
isToCitySearchLoading = false,
memberRelation = snapshot.memberRelation.orEmpty(),
transportMode = snapshot.transportMode.orEmpty(),
childCount = snapshot.childCount?.toString().orEmpty(),
maleCount = snapshot.maleCount?.toString().orEmpty(),
femaleCount = snapshot.femaleCount?.toString().orEmpty()
)
}
} else if (loadError == null) {
loadError = "Load failed: ${detailsResponse.code()}"
}
}
} catch (e: Exception) {
loadError = e.localizedMessage ?: "Load failed"
}
val parsedPhone = parsePhoneE164(fallbackPhone)
_state.update {
it.copy(
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() },
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
isNationalitySearchLoading = false,
isLoading = false,
error = e.localizedMessage ?: "Load failed"
error = loadError
)
}
}
}
}
fun submit(propertyId: String, guestId: String, onDone: () -> Unit) {
if (propertyId.isBlank() || guestId.isBlank()) return
fun submit(propertyId: String, bookingId: String, guestId: String, onDone: () -> Unit) {
if (propertyId.isBlank() || bookingId.isBlank() || guestId.isBlank()) return
val current = state.value
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
var submitError: String? = null
val fullPhoneE164 = composePhoneE164(current)
var matchedGuestToLinkId: String? = null
if (!fullPhoneE164.isNullOrBlank() && submitError == null) {
val searchResponse = api.searchGuests(
propertyId = propertyId,
phone = fullPhoneE164
)
if (searchResponse.isSuccessful) {
matchedGuestToLinkId = searchResponse.body()
.orEmpty()
.firstOrNull { it.id != guestId }
?.id
}
}
if (!matchedGuestToLinkId.isNullOrBlank() && submitError == null) {
val linkResponse = api.linkGuest(
propertyId = propertyId,
bookingId = bookingId,
body = BookingLinkGuestRequest(guestId = matchedGuestToLinkId)
)
if (!linkResponse.isSuccessful) {
submitError = "Link failed: ${linkResponse.code()}"
}
} else if (submitError == null) {
val countryOption = findPhoneCountryOption(current.phoneCountryCode)
val nationalNumber = current.phoneNationalNumber.trim()
val phoneE164 = if (nationalNumber.isBlank()) {
null
} else {
"+${countryOption.dialCode}$nationalNumber"
}
val response = api.updateGuest(
propertyId = propertyId,
guestId = guestId,
body = GuestUpdateRequest(
phoneE164 = current.phoneE164.trim().ifBlank { null },
phoneE164 = phoneE164,
name = current.name.trim().ifBlank { null },
nationality = current.nationality.trim().ifBlank { null },
age = current.age.trim().ifBlank { null },
addressText = current.addressText.trim().ifBlank { null }
)
)
if (response.isSuccessful) {
if (!response.isSuccessful) {
submitError = "Update failed: ${response.code()}"
}
}
if (submitError == null) {
val profilePayload = buildBookingProfilePayload(current)
if (profilePayload != null) {
val profileResponse = api.updateBookingProfile(
propertyId = propertyId,
bookingId = bookingId,
body = profilePayload
)
if (!profileResponse.isSuccessful) {
submitError = "Profile update failed: ${profileResponse.code()}"
} else {
initialBookingProfile = profileSnapshotFromState(_state.value)
}
}
}
if (submitError == null) {
roomStayRepository.refresh(propertyId = propertyId)
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
)
_state.update { it.copy(isLoading = false, error = null) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
_state.update { it.copy(isLoading = false, error = submitError) }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
}
}
}
private fun searchCountrySuggestions(value: String) {
nationalitySearchJob?.cancel()
nationalitySearchJob = null
val query = value.trim()
if (query.length < 3) {
_state.update {
it.copy(
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false
)
}
return
}
nationalitySearchJob = viewModelScope.launch {
delay(300)
_state.update { current ->
if (current.nationality.trim() != query) {
current
} else {
current.copy(isNationalitySearchLoading = true)
}
}
try {
val response = ApiClient.create().searchCountries(query = query, limit = 20)
val suggestions = if (response.isSuccessful) response.body().orEmpty() else emptyList()
_state.update { current ->
if (current.nationality.trim() != query) {
current
} else {
current.copy(
nationalitySuggestions = suggestions,
isNationalitySearchLoading = false
)
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
_state.update { current ->
if (current.nationality.trim() != query) {
current
} else {
current.copy(
nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false
)
}
}
}
}
}
private fun autoFillByPhoneIfExists(propertyId: String, currentGuestId: String) {
if (propertyId.isBlank()) return
val currentPhone = composePhoneE164(_state.value)
if (currentPhone.isNullOrBlank()) {
phoneAutofillJob?.cancel()
phoneAutofillJob = null
lastAutofilledPhoneE164 = null
return
}
if (lastAutofilledPhoneE164 == currentPhone) return
phoneAutofillJob?.cancel()
phoneAutofillJob = viewModelScope.launch {
try {
val response = ApiClient.create().searchGuests(
propertyId = propertyId,
phone = currentPhone
)
if (!response.isSuccessful) return@launch
val guests = response.body().orEmpty()
val matchedGuest = guests.firstOrNull { it.id != currentGuestId } ?: guests.firstOrNull()
if (matchedGuest == null) {
lastAutofilledPhoneE164 = currentPhone
return@launch
}
_state.update { current ->
if (composePhoneE164(current) != currentPhone) {
current
} else {
current.copy(
name = matchedGuest.name.orEmpty(),
nationality = matchedGuest.nationality.orEmpty(),
age = matchedGuest.dob.orEmpty(),
addressText = matchedGuest.addressText.orEmpty(),
vehicleNumbers = matchedGuest.vehicleNumbers,
error = null
)
}
}
lastAutofilledPhoneE164 = currentPhone
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore lookup failures; manual entry should continue uninterrupted.
}
}
}
private fun buildBookingProfilePayload(current: GuestInfoState): JsonObject? {
val currentSnapshot = profileSnapshotFromState(current)
val initialSnapshot = initialBookingProfile
val body = JsonObject()
fun putNullableStringIfChanged(
key: String,
currentValue: String?,
initialValue: String?
) {
if (currentValue == initialValue) return
if (currentValue == null) {
body.add(key, JsonNull.INSTANCE)
} else {
body.addProperty(key, currentValue)
}
}
fun putNullableIntIfChanged(
key: String,
currentValue: Int?,
initialValue: Int?
) {
if (currentValue == initialValue) return
if (currentValue == null) {
body.add(key, JsonNull.INSTANCE)
} else {
body.addProperty(key, currentValue)
}
}
if (initialSnapshot == null) {
currentSnapshot.transportMode?.let { body.addProperty("transportMode", it) }
currentSnapshot.childCount?.let { body.addProperty("childCount", it) }
currentSnapshot.maleCount?.let { body.addProperty("maleCount", it) }
currentSnapshot.femaleCount?.let { body.addProperty("femaleCount", it) }
currentSnapshot.fromCity?.let { body.addProperty("fromCity", it) }
currentSnapshot.toCity?.let { body.addProperty("toCity", it) }
currentSnapshot.memberRelation?.let { body.addProperty("memberRelation", it) }
return if (body.size() == 0) null else body
}
putNullableStringIfChanged(
key = "transportMode",
currentValue = currentSnapshot.transportMode,
initialValue = initialSnapshot.transportMode
)
putNullableIntIfChanged(
key = "childCount",
currentValue = currentSnapshot.childCount,
initialValue = initialSnapshot.childCount
)
putNullableIntIfChanged(
key = "maleCount",
currentValue = currentSnapshot.maleCount,
initialValue = initialSnapshot.maleCount
)
putNullableIntIfChanged(
key = "femaleCount",
currentValue = currentSnapshot.femaleCount,
initialValue = initialSnapshot.femaleCount
)
putNullableStringIfChanged(
key = "fromCity",
currentValue = currentSnapshot.fromCity,
initialValue = initialSnapshot.fromCity
)
putNullableStringIfChanged(
key = "toCity",
currentValue = currentSnapshot.toCity,
initialValue = initialSnapshot.toCity
)
putNullableStringIfChanged(
key = "memberRelation",
currentValue = currentSnapshot.memberRelation,
initialValue = initialSnapshot.memberRelation
)
return if (body.size() == 0) null else body
}
}
private data class ParsedPhone(
val countryCode: String,
val nationalNumber: String
)
private data class BookingProfileSnapshot(
val transportMode: String?,
val childCount: Int?,
val maleCount: Int?,
val femaleCount: Int?,
val fromCity: String?,
val toCity: String?,
val memberRelation: String?
)
private fun parsePhoneE164(phoneE164: String?): ParsedPhone {
val fallback = ParsedPhone(countryCode = "IN", nationalNumber = "")
val raw = phoneE164?.trim().orEmpty()
if (raw.isBlank()) return fallback
val util = PhoneNumberUtil.getInstance()
val parsed = runCatching { util.parse(raw, null) }.getOrNull()
if (parsed != null) {
val region = util.getRegionCodeForNumber(parsed)
if (!region.isNullOrBlank()) {
val option = findPhoneCountryOption(region)
val national = util.getNationalSignificantNumber(parsed).orEmpty()
.filter { it.isDigit() }
.take(option.maxLength)
return ParsedPhone(
countryCode = option.code,
nationalNumber = national
)
}
}
val digitsOnly = raw.filter { it.isDigit() }.take(findPhoneCountryOption("IN").maxLength)
return fallback.copy(nationalNumber = digitsOnly)
}
private fun composePhoneE164(state: GuestInfoState): String? {
val countryOption = findPhoneCountryOption(state.phoneCountryCode)
val digits = state.phoneNationalNumber.trim()
if (digits.length != countryOption.maxLength) return null
return "+${countryOption.dialCode}$digits"
}
private fun profileSnapshotFromState(state: GuestInfoState): BookingProfileSnapshot =
BookingProfileSnapshot(
transportMode = state.transportMode.trim().ifBlank { null },
childCount = state.childCount.trim().toIntOrNull(),
maleCount = state.maleCount.trim().toIntOrNull(),
femaleCount = state.femaleCount.trim().toIntOrNull(),
fromCity = state.fromCity.trim().ifBlank { null },
toCity = state.toCity.trim().ifBlank { null },
memberRelation = state.memberRelation.trim().ifBlank { null }
)

View File

@@ -8,24 +8,17 @@ import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -48,11 +41,13 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.Locale
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestSignatureScreen(
propertyId: String,
bookingId: String,
guestId: String,
onBack: () -> Unit,
onDone: () -> Unit,
@@ -66,21 +61,15 @@ fun GuestSignatureScreen(
viewModel.reset()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Guest Signature") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
BackTopBarScaffold(
title = "Guest Signature",
onBack = onBack,
actions = {
IconButton(
onClick = {
val svg = buildSignatureSvg(strokes, canvasSize.value)
if (!svg.isNullOrBlank()) {
viewModel.uploadSignature(propertyId, guestId, svg, onDone)
viewModel.uploadSignature(propertyId, bookingId, guestId, svg, onDone)
}
},
enabled = strokes.isNotEmpty() && !state.isLoading
@@ -91,18 +80,9 @@ fun GuestSignatureScreen(
Icon(Icons.Default.Done, contentDescription = "Upload")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PaddedScreenColumn(padding = padding) {
Text(
text = "Please draw the guest signature below.",
style = MaterialTheme.typography.bodyMedium

View File

@@ -1,29 +1,45 @@
package com.android.trisolarispms.ui.guest
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
class GuestSignatureViewModel : ViewModel() {
class GuestSignatureViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(GuestSignatureState())
val state: StateFlow<GuestSignatureState> = _state
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
fun reset() {
_state.value = GuestSignatureState()
}
fun uploadSignature(propertyId: String, guestId: String, svg: String, onDone: () -> Unit) {
fun uploadSignature(
propertyId: String,
bookingId: String,
guestId: String,
svg: String,
onDone: () -> Unit
) {
if (propertyId.isBlank() || guestId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Upload failed"
) {
val api = ApiClient.create()
val requestBody = svg.toRequestBody("image/svg+xml".toMediaType())
val part = MultipartBody.Part.createFormData(
@@ -33,14 +49,17 @@ class GuestSignatureViewModel : ViewModel() {
)
val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part)
if (response.isSuccessful) {
if (bookingId.isNotBlank()) {
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
)
}
_state.update { it.copy(isLoading = false, error = null) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Upload failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Upload failed") }
}
}
}
}

View File

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

View File

@@ -1,15 +1,14 @@
package com.android.trisolarispms.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.core.auth.Role
import com.android.trisolarispms.core.auth.toRoles
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class HomeJoinPropertyState(
val isLoading: Boolean = false,
@@ -34,9 +33,12 @@ class HomeJoinPropertyViewModel : ViewModel() {
_state.update { it.copy(error = "Code must be 6 digits", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null, message = null) },
setError = { current, message -> current.copy(isLoading = false, error = message, message = null) },
defaultError = "Join failed"
) {
val response = ApiClient.create().joinAccessCode(
PropertyAccessCodeJoinRequest(
propertyId = trimmedPropertyId,
@@ -57,9 +59,6 @@ class HomeJoinPropertyViewModel : ViewModel() {
} else {
_state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") }
}
}
}

View File

@@ -4,7 +4,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -14,14 +13,10 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -36,9 +31,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.core.auth.Role
import com.android.trisolarispms.ui.property.PropertyListViewModel
import androidx.compose.foundation.text.KeyboardOptions
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun HomeScreen(
userId: String?,
userName: String?,
@@ -78,11 +74,10 @@ fun HomeScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Trisolaris PMS") },
colors = TopAppBarDefaults.topAppBarColors(),
BackTopBarScaffold(
title = "Trisolaris PMS",
onBack = {},
showBack = false,
actions = {
if (isSuperAdmin) {
IconButton(onClick = onUserDirectory) {
@@ -128,16 +123,8 @@ fun HomeScreen(
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PaddedScreenColumn(padding = padding) {
val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome"
Text(text = title, style = MaterialTheme.typography.headlineMedium)
if (isSuperAdmin) {

View File

@@ -4,6 +4,11 @@ sealed interface AppRoute {
data object Home : AppRoute
data class CreateBooking(val propertyId: String) : AppRoute
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class GuestInfoFromBookingDetails(
val propertyId: String,
val bookingId: String,
val guestId: String
) : AppRoute
data class GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class ManageRoomStaySelect(
val propertyId: String,
@@ -31,17 +36,17 @@ sealed interface AppRoute {
val fromAt: String,
val toAt: String?
) : AppRoute
data class BookingRoomRequestFromBooking(
val propertyId: String,
val bookingId: String,
val guestId: String,
val fromAt: String,
val toAt: String
) : AppRoute
data class BookingRoomStays(
val propertyId: String,
val bookingId: String
) : AppRoute
data class BookingExpectedDates(
val propertyId: String,
val bookingId: String,
val status: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
) : AppRoute
data class BookingDetailsTabs(
val propertyId: String,
val bookingId: String,
@@ -68,6 +73,7 @@ sealed interface AppRoute {
val ratePlanCode: String
) : AppRoute
data class RazorpaySettings(val propertyId: String) : AppRoute
data class PropertySettings(val propertyId: String) : AppRoute
data class RazorpayQr(
val propertyId: String,
val bookingId: String,

View File

@@ -43,13 +43,20 @@ internal fun handleBackNavigation(
currentRoute.roomTypeId
)
is AppRoute.RazorpaySettings -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.PropertySettings -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.RazorpayQr -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
is AppRoute.GuestInfoFromBookingDetails -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
@@ -71,7 +78,6 @@ internal fun handleBackNavigation(
currentRoute.toAt
)
is AppRoute.BookingRoomStays -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingExpectedDates -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingDetailsTabs -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingPayments -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,

View File

@@ -2,8 +2,8 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.AuthzPolicy
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.navigation.AppRoute
import com.android.trisolarispms.ui.booking.BookingExpectedDatesScreen
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
@@ -17,31 +17,20 @@ internal fun renderBookingRoutes(
is AppRoute.BookingRoomStays -> BookingRoomStaysScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId),
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }
)
is AppRoute.BookingExpectedDates -> BookingExpectedDatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = currentRoute.status,
expectedCheckInAt = currentRoute.expectedCheckInAt,
expectedCheckOutAt = currentRoute.expectedCheckOutAt,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onDone = { refs.openActiveRoomStays(currentRoute.propertyId) }
)
is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onEditCheckout = { expectedCheckInAt, expectedCheckOutAt ->
refs.route.value = AppRoute.BookingExpectedDates(
onEditGuestInfo = { targetGuestId ->
refs.route.value = AppRoute.GuestInfoFromBookingDetails(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = "CHECKED_IN",
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt
guestId = targetGuestId
)
},
onEditSignature = { guestId ->
@@ -65,7 +54,31 @@ internal fun renderBookingRoutes(
bookingId = currentRoute.bookingId
)
},
canManageDocuments = authz.canManageGuestDocuments(currentRoute.propertyId)
canManageDocuments = authz.canManageGuestDocuments(currentRoute.propertyId),
canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId),
canCheckOutBooking = authz.canCheckOutBooking(currentRoute.propertyId)
)
is AppRoute.GuestInfoFromBookingDetails -> GuestInfoScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
initialGuest = refs.selectedGuest.value,
initialPhone = refs.selectedGuestPhone.value,
onBack = {
refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
},
onSave = {
refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.BookingPayments -> BookingPaymentsScreen(

View File

@@ -2,10 +2,12 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.Role
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
import com.android.trisolarispms.ui.navigation.AppRoute
import com.android.trisolarispms.ui.auth.AuthUiState
import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.booking.BookingCreateScreen
import com.android.trisolarispms.ui.booking.BookingRoomRequestScreen
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.home.HomeScreen
@@ -70,21 +72,55 @@ internal fun renderHomeGuestRoutes(
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
?: response.expectedCheckInAt.orEmpty()
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
if (isFutureBookingCheckIn(response.expectedCheckInAt)) {
if (fromAt.isNotBlank() && !toAt.isNullOrBlank()) {
refs.route.value = AppRoute.BookingRoomRequestFromBooking(
propertyId = currentRoute.propertyId,
bookingId = bookingId,
guestId = guestId,
fromAt = fromAt,
toAt = toAt
)
} else {
refs.route.value = AppRoute.GuestInfo(
propertyId = currentRoute.propertyId,
bookingId = bookingId,
guestId = guestId
)
}
} else {
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
propertyId = currentRoute.propertyId,
bookingId = bookingId,
guestId = guestId,
fromAt = fromAt,
toAt = toAt
)
}
} else {
refs.route.value = AppRoute.Home
}
}
)
is AppRoute.BookingRoomRequestFromBooking -> BookingRoomRequestScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onDone = {
refs.route.value = AppRoute.GuestInfo(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId
)
}
)
is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
initialGuest = refs.selectedGuest.value,
initialPhone = refs.selectedGuestPhone.value,
@@ -100,6 +136,7 @@ internal fun renderHomeGuestRoutes(
is AppRoute.GuestSignature -> GuestSignatureScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = {
refs.route.value = AppRoute.GuestInfo(

View File

@@ -8,8 +8,10 @@ import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.razorpay.RazorpayQrScreen
import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.settings.PropertySettingsScreen
@Composable
internal fun renderStayFlowRoutes(
@@ -18,31 +20,64 @@ internal fun renderStayFlowRoutes(
authViewModel: AuthViewModel,
authz: AuthzPolicy
): Boolean {
@Composable
fun renderManageRoomStaySelectRoute(
propertyId: String,
fromAt: String,
toAt: String?,
onNext: (List<ManageRoomStaySelection>) -> Unit
) {
ManageRoomStaySelectScreen(
propertyId = propertyId,
bookingFromAt = fromAt,
bookingToAt = toAt,
onBack = { refs.openActiveRoomStays(propertyId) },
onNext = { rooms ->
refs.selectedManageRooms.value = rooms
onNext(rooms)
}
)
}
@Composable
fun renderManageRoomStayRatesRoute(
propertyId: String,
bookingId: String,
fromAt: String,
toAt: String?,
onBack: () -> Unit,
onDone: () -> Unit
) {
ManageRoomStayRatesScreen(
propertyId = propertyId,
bookingId = bookingId,
checkInAt = fromAt,
checkOutAt = toAt,
selectedRooms = refs.selectedManageRooms.value,
onBack = onBack,
onDone = onDone
)
}
when (val currentRoute = refs.currentRoute) {
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
propertyId = currentRoute.propertyId,
propertyName = currentRoute.propertyName,
onBack = {
val blockBack = authz.shouldBlockBackToHome(
propertyId = currentRoute.propertyId,
propertyCount = state.propertyRoles.size
)
val blockBack = shouldBlockHomeBack(authz, state, currentRoute.propertyId)
if (!blockBack) {
refs.route.value = AppRoute.Home
}
},
showBack = !authz.shouldBlockBackToHome(
propertyId = currentRoute.propertyId,
propertyCount = state.propertyRoles.size
),
showBack = !shouldBlockHomeBack(authz, state, currentRoute.propertyId),
onViewRooms = { refs.route.value = AppRoute.Rooms(currentRoute.propertyId) },
onOpenSettings = { refs.route.value = AppRoute.PropertySettings(currentRoute.propertyId) },
onCreateBooking = { refs.route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId),
showRazorpaySettings = authz.canManageRazorpaySettings(currentRoute.propertyId),
onRazorpaySettings = { refs.route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) },
showUserAdmin = authz.canManagePropertyUsers(currentRoute.propertyId),
onUserAdmin = { refs.route.value = AppRoute.PropertyUsers(currentRoute.propertyId) },
onLogout = authViewModel::signOut,
onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty()
@@ -77,6 +112,14 @@ internal fun renderStayFlowRoutes(
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }
)
is AppRoute.PropertySettings -> PropertySettingsScreen(
propertyId = currentRoute.propertyId,
canManageCancellationPolicy = authz.canManageCancellationPolicy(currentRoute.propertyId),
canManageBillingPolicy = authz.canManageBillingPolicy(currentRoute.propertyId),
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onLogout = authViewModel::signOut
)
is AppRoute.RazorpayQr -> RazorpayQrScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
@@ -91,13 +134,11 @@ internal fun renderStayFlowRoutes(
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
is AppRoute.ManageRoomStaySelect -> renderManageRoomStaySelectRoute(
propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onNext = { rooms ->
refs.selectedManageRooms.value = rooms
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
) {
refs.route.value = AppRoute.ManageRoomStayRates(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
@@ -105,15 +146,12 @@ internal fun renderStayFlowRoutes(
toAt = currentRoute.toAt
)
}
)
is AppRoute.ManageRoomStaySelectFromBooking -> ManageRoomStaySelectScreen(
is AppRoute.ManageRoomStaySelectFromBooking -> renderManageRoomStaySelectRoute(
propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt,
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onNext = { rooms ->
refs.selectedManageRooms.value = rooms
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
) {
refs.route.value = AppRoute.ManageRoomStayRatesFromBooking(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
@@ -122,31 +160,28 @@ internal fun renderStayFlowRoutes(
toAt = currentRoute.toAt
)
}
)
is AppRoute.ManageRoomStayRates -> ManageRoomStayRatesScreen(
is AppRoute.ManageRoomStayRates -> renderManageRoomStayRatesRoute(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt,
selectedRooms = refs.selectedManageRooms.value,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt,
onBack = {
refs.route.value = AppRoute.ManageRoomStaySelect(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.fromAt,
currentRoute.toAt
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
)
},
onDone = { refs.openActiveRoomStays(currentRoute.propertyId) }
)
is AppRoute.ManageRoomStayRatesFromBooking -> ManageRoomStayRatesScreen(
is AppRoute.ManageRoomStayRatesFromBooking -> renderManageRoomStayRatesRoute(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt,
selectedRooms = refs.selectedManageRooms.value,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt,
onBack = {
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
currentRoute.propertyId,
@@ -169,3 +204,9 @@ internal fun renderStayFlowRoutes(
}
return true
}
private fun shouldBlockHomeBack(authz: AuthzPolicy, state: AuthUiState, propertyId: String): Boolean =
authz.shouldBlockBackToHome(
propertyId = propertyId,
propertyCount = state.propertyRoles.size
)

View File

@@ -12,23 +12,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -42,11 +37,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.PaymentDto
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingPaymentsScreen(
propertyId: String,
bookingId: String,
@@ -61,23 +56,27 @@ fun BookingPaymentsScreen(
val refundTarget = remember { mutableStateOf<PaymentDto?>(null) }
val refundAmount = rememberSaveable { mutableStateOf("") }
val refundNotes = rememberSaveable { mutableStateOf("") }
val amountValue = amountInput.value.toLongOrNull()
val pendingBalance = state.pendingBalance
val isAmountExceedingPending = pendingBalance != null && amountValue != null && amountValue > pendingBalance
val canAddCashPayment = !state.isLoading &&
amountValue != null &&
amountValue > 0L &&
(pendingBalance == null || amountValue <= pendingBalance) &&
(pendingBalance == null || pendingBalance > 0L)
LaunchedEffect(propertyId, bookingId) {
viewModel.load(propertyId, bookingId)
}
LaunchedEffect(state.message) {
if (state.message == "Cash payment added") {
amountInput.value = ""
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Payments") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
BackTopBarScaffold(
title = "Payments",
onBack = onBack
) { padding ->
Column(
modifier = Modifier
@@ -86,6 +85,12 @@ fun BookingPaymentsScreen(
.padding(16.dp)
) {
if (canAddCash) {
Text(
text = "Pending balance: ${pendingBalance?.toString() ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = amountInput.value,
onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } },
@@ -93,14 +98,20 @@ fun BookingPaymentsScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
if (isAmountExceedingPending) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Amount cannot be greater than pending balance",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val amount = amountInput.value.toLongOrNull() ?: 0L
viewModel.addCashPayment(propertyId, bookingId, amount)
amountInput.value = ""
viewModel.addCashPayment(propertyId, bookingId, amountValue ?: 0L)
},
enabled = !state.isLoading,
enabled = canAddCashPayment,
modifier = Modifier.fillMaxWidth()
) {
Text("Add cash payment")
@@ -218,7 +229,7 @@ private fun PaymentCard(
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val dateText = date?.format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a"))
val isCash = payment.method == "CASH"
val isCash = payment.method.equals("CASH", ignoreCase = true)
Card(
colors = CardDefaults.cardColors(
containerColor = if (isCash) {
@@ -243,6 +254,7 @@ private fun PaymentCard(
val hasRefundableAmount = (payment.amount ?: 0L) > 0L
if (
canRefund &&
!isCash &&
hasRefundableAmount &&
(!payment.id.isNullOrBlank() || !payment.gatewayPaymentId.isNullOrBlank())
) {

View File

@@ -6,5 +6,6 @@ data class BookingPaymentsState(
val isLoading: Boolean = false,
val error: String? = null,
val message: String? = null,
val payments: List<PaymentDto> = emptyList()
val payments: List<PaymentDto> = emptyList(),
val pendingBalance: Long? = null
)

View File

@@ -1,52 +1,43 @@
package com.android.trisolarispms.ui.payment
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RazorpayRefundRequest
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
import com.android.trisolarispms.data.local.payment.PaymentRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.Response
class BookingPaymentsViewModel : ViewModel() {
class BookingPaymentsViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingPaymentsState())
val state: StateFlow<BookingPaymentsState> = _state
private val bookingRepository = BookingDetailsRepository(
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
)
private val paymentRepository = PaymentRepository(
dao = LocalDatabaseProvider.get(application).paymentCacheDao()
)
private var observeKey: String? = null
private var observePaymentsJob: Job? = null
private var observeBookingJob: Job? = null
fun load(propertyId: String, bookingId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.listPayments(propertyId, bookingId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
payments = body,
error = null,
message = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed",
message = null
)
}
}
if (propertyId.isBlank() || bookingId.isBlank()) return
observeCaches(propertyId = propertyId, bookingId = bookingId)
runPaymentAction(defaultError = "Load failed") {
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update { it.copy(isLoading = false, error = null, message = null) }
}
}
@@ -55,10 +46,14 @@ class BookingPaymentsViewModel : ViewModel() {
_state.update { it.copy(error = "Amount must be greater than 0", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
runPaymentAction(defaultError = "Create failed") { api ->
val latestPending = api.getBookingBalance(propertyId, bookingId).body()?.pending
?: _state.value.pendingBalance
val validationError = validateCashAmount(amount = amount, pendingBalance = latestPending)
if (validationError != null) {
_state.update { it.copy(isLoading = false, error = validationError, message = null) }
return@runPaymentAction
}
val response = api.createPayment(
propertyId = propertyId,
bookingId = bookingId,
@@ -66,71 +61,38 @@ class BookingPaymentsViewModel : ViewModel() {
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update {
it.copy(
isLoading = false,
payments = listOf(body) + current.payments,
error = null,
message = "Cash payment added"
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Create failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Create failed",
message = null
)
}
setActionFailure("Create", response)
}
}
}
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
runPaymentAction(defaultError = "Delete failed") { api ->
val response = api.deletePayment(
propertyId = propertyId,
bookingId = bookingId,
paymentId = paymentId
)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update {
it.copy(
isLoading = false,
payments = current.payments.filterNot { it.id == paymentId },
error = null,
message = "Cash payment deleted"
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Delete failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Delete failed",
message = null
)
}
setActionFailure("Delete", response)
}
}
}
@@ -151,10 +113,7 @@ class BookingPaymentsViewModel : ViewModel() {
_state.update { it.copy(error = "Missing payment ID", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
runPaymentAction(defaultError = "Refund failed") { api ->
val response = api.refundRazorpayPayment(
propertyId = propertyId,
bookingId = bookingId,
@@ -167,6 +126,7 @@ class BookingPaymentsViewModel : ViewModel() {
)
val body = response.body()
if (response.isSuccessful && body != null) {
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update {
it.copy(
isLoading = false,
@@ -174,25 +134,88 @@ class BookingPaymentsViewModel : ViewModel() {
message = "Refund processed"
)
}
load(propertyId, bookingId)
} else {
_state.update {
it.copy(
isLoading = false,
error = "Refund failed: ${response.code()}",
message = null
)
setActionFailure("Refund", response)
}
}
}
private fun runPaymentAction(
defaultError: String,
block: suspend (ApiService) -> Unit
) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
block(ApiClient.create())
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Refund failed",
error = e.localizedMessage ?: defaultError,
message = null
)
}
}
}
}
private fun setActionFailure(action: String, response: Response<*>) {
_state.update {
it.copy(
isLoading = false,
error = "$action failed: ${response.code()}",
message = null
)
}
}
private fun validateCashAmount(amount: Long, pendingBalance: Long?): String? {
if (pendingBalance == null) return null
if (pendingBalance <= 0L) return "No pending balance to collect"
if (amount > pendingBalance) return "Amount cannot be greater than pending balance ($pendingBalance)"
return null
}
override fun onCleared() {
super.onCleared()
observePaymentsJob?.cancel()
observeBookingJob?.cancel()
}
private fun observeCaches(propertyId: String, bookingId: String) {
val key = "$propertyId:$bookingId"
if (observeKey == key &&
observePaymentsJob?.isActive == true &&
observeBookingJob?.isActive == true
) {
return
}
observePaymentsJob?.cancel()
observeBookingJob?.cancel()
observeKey = key
observePaymentsJob = viewModelScope.launch {
paymentRepository.observeByBooking(propertyId = propertyId, bookingId = bookingId).collect { items ->
_state.update { current -> current.copy(payments = items) }
}
}
observeBookingJob = viewModelScope.launch {
bookingRepository.observeBookingDetails(
propertyId = propertyId,
bookingId = bookingId
).collect { details ->
_state.update { current ->
current.copy(pendingBalance = details?.pending ?: current.pendingBalance)
}
}
}
}
private suspend fun refreshPaymentCaches(propertyId: String, bookingId: String) {
paymentRepository.refresh(propertyId = propertyId, bookingId = bookingId).getOrThrow()
bookingRepository.refreshBookingDetails(
propertyId = propertyId,
bookingId = bookingId
).getOrThrow()
}
}

View File

@@ -3,27 +3,18 @@ package com.android.trisolarispms.ui.property
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -44,6 +35,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -70,26 +63,11 @@ fun AddPropertyScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Property") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
BackTopBarScaffold(
title = "Add Property",
onBack = onBack
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PaddedScreenColumn(padding = padding) {
Spacer(modifier = Modifier.height(8.dp))
ExposedDropdownMenuBox(

View File

@@ -1,13 +1,12 @@
package com.android.trisolarispms.ui.property
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.PropertyCreateRequest
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AddPropertyViewModel : ViewModel() {
private val _state = MutableStateFlow(AddPropertyState())
@@ -56,9 +55,12 @@ class AddPropertyViewModel : ViewModel() {
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Create failed"
) {
val api = ApiClient.create()
val body = PropertyCreateRequest(
code = null,
@@ -79,9 +81,6 @@ class AddPropertyViewModel : ViewModel() {
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
}
}
}

View File

@@ -4,27 +4,19 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PropertyHomeScreen(
propertyId: String,
onBack: () -> Unit,
@@ -33,26 +25,11 @@ fun PropertyHomeScreen(
onRoomTypes: () -> Unit,
canManageRooms: Boolean
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Property") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
BackTopBarScaffold(
title = "Property",
onBack = onBack
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PaddedScreenColumn(padding = padding) {
PropertyTile(title = "Checked-in Rooms", subtitle = "Active stays", onClick = onActiveStays)
Spacer(modifier = Modifier.height(12.dp))
PropertyTile(title = "Available Rooms", subtitle = "Room list", onClick = onRooms)

View File

@@ -1,21 +1,23 @@
package com.android.trisolarispms.ui.property
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PropertyListViewModel : ViewModel() {
private val _state = MutableStateFlow(PropertyListState())
val state: StateFlow<PropertyListState> = _state
fun refresh() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create()
val response = api.listProperties()
if (response.isSuccessful) {
@@ -29,9 +31,6 @@ class PropertyListViewModel : ViewModel() {
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
}

View File

@@ -16,19 +16,14 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -49,9 +44,9 @@ import com.android.trisolarispms.BuildConfig
import androidx.compose.ui.platform.LocalContext
import android.content.Intent
import android.net.Uri
import com.android.trisolarispms.ui.common.BackTopBarScaffold
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RazorpayQrScreen(
propertyId: String,
bookingId: String,
@@ -93,23 +88,14 @@ fun RazorpayQrScreen(
exitAndRefresh()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Generate Payment Links | QR") },
navigationIcon = {
IconButton(onClick = {
BackTopBarScaffold(
title = "Generate Payment Links | QR",
onBack = {
if (isViewingQr) {
exitAndRefresh()
} else {
onBack()
}
}) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
if (state.isCredited) {

View File

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

View File

@@ -11,20 +11,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -37,9 +29,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import com.android.trisolarispms.ui.common.BackTopBarScaffold
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RazorpaySettingsScreen(
propertyId: String,
onBack: () -> Unit,
@@ -52,18 +44,9 @@ fun RazorpaySettingsScreen(
viewModel.load(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Razorpay Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
BackTopBarScaffold(
title = "Razorpay Settings",
onBack = onBack
) { padding ->
Column(
modifier = Modifier

View File

@@ -4,30 +4,24 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -38,6 +32,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel
@Composable
@@ -72,25 +68,17 @@ fun RoomFormScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
SaveTopBarScaffold(
title = title,
onBack = onBack,
onSave = {
if (roomId == null) {
viewModel.submitCreate(propertyId, onSave)
} else {
viewModel.submitUpdate(propertyId, roomId, onSave)
}
}) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
actions = {
if (roomId != null) {
IconButton(onClick = { onViewImages(roomId) }) {
Icon(Icons.Default.PhotoLibrary, contentDescription = "Images")
@@ -99,18 +87,9 @@ fun RoomFormScreen(
Icon(Icons.Default.Delete, contentDescription = "Delete Room")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PaddedScreenColumn(padding = padding) {
OutlinedTextField(
value = state.roomNumber,
onValueChange = viewModel::onRoomNumberChange,

View File

@@ -1,14 +1,14 @@
package com.android.trisolarispms.ui.room
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchApiMutation
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RoomCreateRequest
import com.android.trisolarispms.data.api.model.RoomUpdateRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.Response
class RoomFormViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomFormState())
@@ -58,70 +58,22 @@ class RoomFormViewModel : ViewModel() {
fun onNotesChange(value: String) = _state.update { it.copy(notes = value, error = null) }
fun submitCreate(propertyId: String, onDone: () -> Unit) {
val roomNumberText = state.value.roomNumber.trim()
val roomTypeCode = state.value.roomTypeCode.trim()
val roomNumber = roomNumberText.toIntOrNull()
if (roomNumber == null || roomTypeCode.isBlank()) {
_state.update { it.copy(error = "Room number must be a number and room type is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val body = RoomCreateRequest(
roomNumber = roomNumber,
floor = state.value.floor.trim().toIntOrNull(),
roomTypeCode = roomTypeCode,
hasNfc = state.value.hasNfc,
active = state.value.active,
maintenance = state.value.maintenance,
notes = state.value.notes.takeIf { it.isNotBlank() }
)
val response = api.createRoom(propertyId, body)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
}
val input = readValidatedInput() ?: return
submitMutation(
action = "Create",
onDone = onDone
) { api ->
api.createRoom(propertyId, input.toCreateRequest())
}
}
fun submitUpdate(propertyId: String, roomId: String, onDone: () -> Unit) {
val roomNumberText = state.value.roomNumber.trim()
val roomTypeCode = state.value.roomTypeCode.trim()
val roomNumber = roomNumberText.toIntOrNull()
if (roomNumber == null || roomTypeCode.isBlank()) {
_state.update { it.copy(error = "Room number must be a number and room type is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val body = RoomUpdateRequest(
roomNumber = roomNumber,
floor = state.value.floor.trim().toIntOrNull(),
roomTypeCode = roomTypeCode,
hasNfc = state.value.hasNfc,
active = state.value.active,
maintenance = state.value.maintenance,
notes = state.value.notes.takeIf { it.isNotBlank() }
)
val response = api.updateRoom(propertyId, roomId, body)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
}
val input = readValidatedInput() ?: return
submitMutation(
action = "Update",
onDone = onDone
) { api ->
api.updateRoom(propertyId, roomId, input.toUpdateRequest())
}
}
@@ -130,20 +82,76 @@ class RoomFormViewModel : ViewModel() {
_state.update { it.copy(error = "Room ID is missing") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteRoom(propertyId, roomId)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
submitMutation(
action = "Delete",
onDone = onDone
) { api ->
api.deleteRoom(propertyId, roomId)
}
}
private data class ValidRoomInput(
val roomNumber: Int,
val floor: Int?,
val roomTypeCode: String,
val hasNfc: Boolean,
val active: Boolean,
val maintenance: Boolean,
val notes: String?
) {
fun toCreateRequest(): RoomCreateRequest = RoomCreateRequest(
roomNumber = roomNumber,
floor = floor,
roomTypeCode = roomTypeCode,
hasNfc = hasNfc,
active = active,
maintenance = maintenance,
notes = notes
)
fun toUpdateRequest(): RoomUpdateRequest = RoomUpdateRequest(
roomNumber = roomNumber,
floor = floor,
roomTypeCode = roomTypeCode,
hasNfc = hasNfc,
active = active,
maintenance = maintenance,
notes = notes
)
}
private fun readValidatedInput(): ValidRoomInput? {
val current = state.value
val roomNumber = current.roomNumber.trim().toIntOrNull()
val roomTypeCode = current.roomTypeCode.trim()
if (roomNumber == null || roomTypeCode.isBlank()) {
_state.update { it.copy(error = "Room number must be a number and room type is required") }
return null
}
return ValidRoomInput(
roomNumber = roomNumber,
floor = current.floor.trim().toIntOrNull(),
roomTypeCode = roomTypeCode,
hasNfc = current.hasNfc,
active = current.active,
maintenance = current.maintenance,
notes = current.notes.takeIf { it.isNotBlank() }
)
}
private fun submitMutation(
action: String,
onDone: () -> Unit,
call: suspend (ApiService) -> Response<*>
) {
launchApiMutation(
state = _state,
action = action,
setLoading = { it.copy(isLoading = true, error = null) },
setSuccess = { it.copy(isLoading = false, success = true) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
onDone = onDone,
call = call
)
}
}

View File

@@ -1,12 +1,11 @@
package com.android.trisolarispms.ui.room
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RoomListViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomListState())
@@ -14,16 +13,19 @@ class RoomListViewModel : ViewModel() {
fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) {
if (propertyId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create()
val trimmedCode = roomTypeCode?.trim().orEmpty()
val response = if (trimmedCode.isNotBlank()) {
api.listRoomsByType(
propertyId = propertyId,
roomTypeCode = trimmedCode,
availableOnly = if (showAll) false else true
availableOnly = !showAll
)
} else if (showAll) {
api.listRooms(propertyId)
@@ -41,9 +43,6 @@ class RoomListViewModel : ViewModel() {
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -15,24 +14,19 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Hotel
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -40,15 +34,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import android.nfc.NfcAdapter
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.LoadingAndError
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.layout.Box
@Composable
@@ -79,15 +76,9 @@ fun RoomsScreen(
roomTypeListViewModel.load(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Available Rooms") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
BackTopBarScaffold(
title = "Available Rooms",
onBack = onBack,
actions = {
if (canManageRooms) {
IconButton(onClick = onViewRoomTypes) {
@@ -102,26 +93,10 @@ fun RoomsScreen(
Icon(Icons.Default.Add, contentDescription = "Add Room")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
PaddedScreenColumn(padding = padding) {
LoadingAndError(isLoading = state.isLoading, error = state.error)
if (!state.isLoading && state.error == null) {
Row(
modifier = Modifier.fillMaxWidth(),

View File

@@ -1,80 +1,20 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddImageTagScreen(
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: ImageTagFormViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.reset()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitCreate(onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
ImageTagEditorScreen(
title = "Add Tag",
setupKey = Unit,
onBack = onBack,
onSaveClick = { viewModel.submitCreate(onSave) },
setupForm = { viewModel.reset() },
viewModel = viewModel
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -1,82 +1,22 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomImageTagDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditImageTagScreen(
tag: RoomImageTagDto,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: ImageTagFormViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(tag.id) {
viewModel.setTag(tag)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitUpdate(tag.id.orEmpty(), onSave) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
ImageTagEditorScreen(
title = "Edit Tag",
setupKey = tag.id,
onBack = onBack,
onSaveClick = { viewModel.submitUpdate(tag.id.orEmpty(), onSave) },
setupForm = { viewModel.setTag(tag) },
viewModel = viewModel
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -0,0 +1,31 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@Composable
internal fun ImageTagEditorScreen(
title: String,
setupKey: Any?,
onBack: () -> Unit,
onSaveClick: () -> Unit,
setupForm: () -> Unit,
viewModel: ImageTagFormViewModel
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(setupKey) {
setupForm()
}
ImageTagFormScreen(
title = title,
name = state.name,
error = state.error,
onNameChange = viewModel::onNameChange,
onBack = onBack,
onSave = onSaveClick
)
}

View File

@@ -0,0 +1,44 @@
package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
@Composable
internal fun ImageTagFormScreen(
title: String,
name: String,
error: String?,
onNameChange: (String) -> Unit,
onBack: () -> Unit,
onSave: () -> Unit
) {
SaveTopBarScaffold(
title = title,
onBack = onBack,
onSave = onSave
) { padding ->
PaddedScreenColumn(padding = padding) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -1,13 +1,13 @@
package com.android.trisolarispms.ui.roomimage
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchApiMutation
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RoomImageTagDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.Response
class ImageTagFormViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagFormState())
@@ -26,48 +26,53 @@ class ImageTagFormViewModel : ViewModel() {
}
fun submitCreate(onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.createImageTag(RoomImageTagDto(name = name))
if (response.isSuccessful) {
_state.update { ImageTagFormState(success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
}
val payload = readValidatedPayload() ?: return
submitMutation(
action = "Create",
onDone = onDone,
resetOnSuccess = true
) { api ->
api.createImageTag(payload)
}
}
fun submitUpdate(tagId: String, onDone: () -> Unit) {
val payload = readValidatedPayload() ?: return
submitMutation(
action = "Update",
onDone = onDone,
resetOnSuccess = false
) { api ->
api.updateImageTag(tagId, payload)
}
}
private fun readValidatedPayload(): RoomImageTagDto? {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateImageTag(tagId, RoomImageTagDto(name = name))
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
return null
}
return RoomImageTagDto(name = name)
}
private fun submitMutation(
action: String,
onDone: () -> Unit,
resetOnSuccess: Boolean,
call: suspend (ApiService) -> Response<*>
) {
launchApiMutation(
state = _state,
action = action,
setLoading = { it.copy(isLoading = true, error = null) },
setSuccess = {
if (resetOnSuccess) ImageTagFormState(success = true)
else it.copy(isLoading = false, success = true)
},
setError = { current, message -> current.copy(isLoading = false, error = message) },
onDone = onDone,
call = call
)
}
}

View File

@@ -1,21 +1,23 @@
package com.android.trisolarispms.ui.roomimage
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ImageTagViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagState())
val state: StateFlow<ImageTagState> = _state
fun load() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create()
val response = api.listImageTags()
if (response.isSuccessful) {
@@ -29,17 +31,17 @@ class ImageTagViewModel : ViewModel() {
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
fun delete(tagId: String) {
if (tagId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
launchRequest(
state = _state,
setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Delete failed"
) {
val api = ApiClient.create()
val response = api.deleteImageTag(tagId)
if (response.isSuccessful) {
@@ -53,9 +55,6 @@ class ImageTagViewModel : ViewModel() {
} else {
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
}
}
}
}

View File

@@ -2,27 +2,18 @@ package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,9 +22,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomImageTagDto
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.LoadingAndError
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ImageTagsScreen(
onBack: () -> Unit,
onAdd: () -> Unit,
@@ -46,39 +39,20 @@ fun ImageTagsScreen(
viewModel.load()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Image Tags") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
BackTopBarScaffold(
title = "Image Tags",
onBack = onBack,
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Tag")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
PaddedScreenColumn(padding = padding) {
LoadingAndError(
isLoading = state.isLoading,
error = state.error
)
if (!state.isLoading && state.error == null) {
if (state.tags.isEmpty()) {
Text(text = "No tags")

View File

@@ -10,27 +10,20 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -42,12 +35,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.LoadingAndError
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RoomImagesScreen(
propertyId: String,
roomId: String,
@@ -86,15 +81,9 @@ fun RoomImagesScreen(
originalOrderIds.value = state.images.mapNotNull { it.id }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Room Images") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
BackTopBarScaffold(
title = "Room Images",
onBack = onBack,
actions = {
IconButton(onClick = {
pickerLauncher.launch(
@@ -103,27 +92,10 @@ fun RoomImagesScreen(
}) {
Icon(Icons.Default.UploadFile, contentDescription = "Pick Image")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
PaddedScreenColumn(padding = padding) {
LoadingAndError(isLoading = state.isLoading, error = state.error)
Text(text = "Upload", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -1,9 +1,9 @@
package com.android.trisolarispms.ui.roomstay
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -13,27 +13,24 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.material3.LinearProgressIndicator
@@ -41,30 +38,29 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import java.time.Duration
import java.time.OffsetDateTime
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ActiveRoomStaysScreen(
propertyId: String,
propertyName: String,
onBack: () -> Unit,
showBack: Boolean,
onViewRooms: () -> Unit,
onOpenSettings: () -> Unit,
onCreateBooking: () -> Unit,
canCreateBooking: Boolean,
showRazorpaySettings: Boolean,
onRazorpaySettings: () -> Unit,
showUserAdmin: Boolean,
onUserAdmin: () -> Unit,
onLogout: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit,
@@ -74,34 +70,38 @@ fun ActiveRoomStaysScreen(
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
val menuExpanded = remember { mutableStateOf(false) }
BackHandler(enabled = state.showOpenBookings) {
viewModel.hideOpenBookings()
}
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(propertyName) },
navigationIcon = {
if (showBack) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
BackTopBarScaffold(
title = propertyName,
onBack = {
if (state.showOpenBookings) {
viewModel.hideOpenBookings()
} else {
onBack()
}
},
showBack = showBack,
actions = {
IconButton(onClick = onViewRooms) {
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
}
if (showRazorpaySettings) {
IconButton(onClick = onRazorpaySettings) {
Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings")
}
}
if (showUserAdmin) {
IconButton(onClick = onUserAdmin) {
Icon(Icons.Default.People, contentDescription = "Property Users")
IconButton(onClick = viewModel::toggleShowOpenBookings) {
Icon(
Icons.Default.CalendarMonth,
contentDescription = "Show Open Bookings",
tint = if (state.showOpenBookings) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
IconButton(onClick = { menuExpanded.value = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
@@ -111,16 +111,34 @@ fun ActiveRoomStaysScreen(
onDismissRequest = { menuExpanded.value = false }
) {
DropdownMenuItem(
text = { Text("Logout") },
text = { Text("Settings") },
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
onClick = {
menuExpanded.value = false
onLogout()
onOpenSettings()
}
)
if (showRazorpaySettings) {
DropdownMenuItem(
text = { Text("Razorpay Settings") },
leadingIcon = { Icon(Icons.Default.Payment, contentDescription = null) },
onClick = {
menuExpanded.value = false
onRazorpaySettings()
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors()
if (showUserAdmin) {
DropdownMenuItem(
text = { Text("Property Users") },
leadingIcon = { Icon(Icons.Default.People, contentDescription = null) },
onClick = {
menuExpanded.value = false
onUserAdmin()
}
)
}
}
},
floatingActionButton = {
if (canCreateBooking) {
@@ -130,13 +148,7 @@ fun ActiveRoomStaysScreen(
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PaddedScreenColumn(padding = padding) {
Spacer(modifier = Modifier.height(8.dp))
if (state.isLoading) {
@@ -150,8 +162,18 @@ fun ActiveRoomStaysScreen(
}
if (!state.isLoading && state.error == null) {
if (state.checkedInBookings.isNotEmpty()) {
Text(text = "Checked-in bookings", style = MaterialTheme.typography.titleMedium)
val shownBookings = if (state.showOpenBookings) {
state.openBookings
} else {
state.checkedInBookings
}
if (shownBookings.isNotEmpty()) {
val sectionTitle = if (state.showOpenBookings) {
"Open bookings"
} else {
"Checked-in bookings"
}
Text(text = sectionTitle, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
@@ -159,7 +181,7 @@ fun ActiveRoomStaysScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.checkedInBookings) { booking ->
items(shownBookings) { booking ->
CheckedInBookingCard(
booking = booking,
onClick = { onOpenBookingDetails(booking) })
@@ -167,7 +189,12 @@ fun ActiveRoomStaysScreen(
}
Spacer(modifier = Modifier.height(16.dp))
} else {
Text(text = "No checked-in bookings")
val emptyLabel = if (state.showOpenBookings) {
"No open bookings"
} else {
"No checked-in bookings"
}
Text(text = emptyLabel)
}
}
}
@@ -226,10 +253,6 @@ private fun CheckedInBookingCard(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val source = booking.source?.takeIf { it.isNotBlank() }
if (source != null) {
Text(text = source, style = MaterialTheme.typography.bodySmall)
}
val expectedCount = booking.expectedGuestCount
val totalCount = booking.totalGuestCount
val countLine = when {
@@ -254,6 +277,14 @@ private fun CheckedInBookingCard(
color = MaterialTheme.colorScheme.error
)
}
val vehicleNumbers = booking.vehicleNumbers.filter { it.isNotBlank() }
if (vehicleNumbers.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = vehicleNumbers.joinToString(", "),
style = MaterialTheme.typography.bodySmall
)
}
val checkInAt = booking.checkInAt?.takeIf { it.isNotBlank() }
val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() }
if (checkInAt != null && checkOutAt != null) {
@@ -263,7 +294,6 @@ private fun CheckedInBookingCard(
if (start != null && end != null) {
val total = Duration.between(start, end).toMinutes().coerceAtLeast(0)
val remaining = Duration.between(now, end).toMinutes().coerceAtLeast(0)
val hoursLeft = (remaining / 60).coerceAtLeast(0)
Spacer(modifier = Modifier.height(8.dp))
if (now.isAfter(end)) {
Text(
@@ -277,7 +307,14 @@ private fun CheckedInBookingCard(
} else {
0f
}
Text(text = "$hoursLeft hours", style = MaterialTheme.typography.bodySmall)
val remainingHours = remaining / 60
val remainingMinutes = remaining % 60
val remainingText = if (remainingHours > 0) {
"${remainingHours}h ${remainingMinutes}m"
} else {
"${remainingMinutes}m"
}
Text(text = remainingText, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = { progress },

View File

@@ -7,5 +7,7 @@ data class ActiveRoomStaysState(
val isLoading: Boolean = false,
val error: String? = null,
val items: List<ActiveRoomStayDto> = emptyList(),
val checkedInBookings: List<BookingListItem> = emptyList()
val checkedInBookings: List<BookingListItem> = emptyList(),
val openBookings: List<BookingListItem> = emptyList(),
val showOpenBookings: Boolean = false
)

Some files were not shown because too many files have changed in this diff Show More