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 # TrisolarisPMS API Usage
## API Docs Path
- `/home/androidlover5842/IdeaProjects/TrisolarisServer/docs`
## 1) Booking ## 1) Booking
### Create booking ### Create booking
@@ -534,6 +538,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
### Non-negotiable coding rules ### Non-negotiable coding rules
- Duplicate code is forbidden.
- Never add duplicate business logic in multiple files. - Never add duplicate business logic in multiple files.
- Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns. - Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns.
- If similar logic appears 2+ times, extract shared function/class immediately. - 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) ### Required project structure (current baseline)
- `core/` -> cross-cutting business primitives/policies (e.g., auth policy, role enum). - `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/core/` -> API client, constants, token providers, aggregated API service.
- `data/api/service/` -> Retrofit endpoint interfaces only. - `data/api/service/` -> Retrofit endpoint interfaces only.
- `data/api/model/` -> DTO/request/response models. - `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. 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). 6. If navigation changes, update `ui/navigation` only (single source of truth).
7. Before finishing, remove any newly introduced duplication and compile-check. 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 ### PR/refactor acceptance checklist
@@ -567,6 +576,14 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
- Imports/packages follow the structure above. - Imports/packages follow the structure above.
- Build passes: `./gradlew :app:compileDebugKotlin`. - Build passes: `./gradlew :app:compileDebugKotlin`.
### Room DB synchronization rule (mandatory)
- For any editable API-backed field, follow this write path only: `UI action -> server request -> Room DB update -> UI reacts from Room observers`.
- Server is source of truth; do not bypass server by writing final business state directly from UI.
- UI must render from Room-backed state, not from one-off API responses or direct text mutation.
- Avoid forced full refresh after each mutation unless strictly required by backend limitations; prefer targeted Room updates for linked entities.
- On mutation failure, keep prior DB state unchanged and surface error state to UI.
### Guest Documents Authorization (mandatory) ### Guest Documents Authorization (mandatory)
- View access: `ADMIN`, `MANAGER` (and super admin). - View access: `ADMIN`, `MANAGER` (and super admin).

View File

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

View File

@@ -18,6 +18,10 @@ class AuthzPolicy(
fun canManageRazorpaySettings(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN) 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 canDeleteCashPayment(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canAddBookingPayment(propertyId: String): Boolean = fun canAddBookingPayment(propertyId: String): Boolean =
@@ -34,6 +38,12 @@ class AuthzPolicy(
fun canCreateBookingFor(propertyId: String): Boolean = fun canCreateBookingFor(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF) 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 canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER) 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.AmenityApi
import com.android.trisolarispms.data.api.service.AuthApi 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.BookingApi
import com.android.trisolarispms.data.api.service.CancellationPolicyApi
import com.android.trisolarispms.data.api.service.CardApi import com.android.trisolarispms.data.api.service.CardApi
import com.android.trisolarispms.data.api.service.GuestApi import com.android.trisolarispms.data.api.service.GuestApi
import com.android.trisolarispms.data.api.service.GuestDocumentApi 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.ImageTagApi
import com.android.trisolarispms.data.api.service.InboundEmailApi import com.android.trisolarispms.data.api.service.InboundEmailApi
import com.android.trisolarispms.data.api.service.PropertyApi import com.android.trisolarispms.data.api.service.PropertyApi
@@ -20,6 +23,7 @@ import com.android.trisolarispms.data.api.service.UserAdminApi
interface ApiService : interface ApiService :
AuthApi, AuthApi,
BillingPolicyApi,
PropertyApi, PropertyApi,
RoomTypeApi, RoomTypeApi,
RoomApi, RoomApi,
@@ -30,9 +34,11 @@ interface ApiService :
CardApi, CardApi,
GuestApi, GuestApi,
GuestDocumentApi, GuestDocumentApi,
GeoApi,
TransportApi, TransportApi,
InboundEmailApi, InboundEmailApi,
AmenityApi, AmenityApi,
RatePlanApi, RatePlanApi,
RazorpaySettingsApi, RazorpaySettingsApi,
CancellationPolicyApi,
UserAdminApi 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 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( data class BookingCheckInRequest(
val roomIds: List<String>, val roomIds: List<String>,
val checkInAt: String? = null, val checkInAt: String? = null,
@@ -11,6 +26,8 @@ data class BookingCheckInRequest(
data class BookingCreateRequest( data class BookingCreateRequest(
val expectedCheckInAt: String, val expectedCheckInAt: String,
val expectedCheckOutAt: String, val expectedCheckOutAt: String,
val billingMode: BookingBillingMode? = null,
val billingCheckoutTime: String? = null,
val source: String? = null, val source: String? = null,
val guestPhoneE164: String? = null, val guestPhoneE164: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
@@ -39,6 +56,7 @@ data class BookingListItem(
val guestId: String? = null, val guestId: String? = null,
val guestName: String? = null, val guestName: String? = null,
val guestPhone: String? = null, val guestPhone: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(), val roomNumbers: List<Int> = emptyList(),
val source: String? = null, val source: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
@@ -57,11 +75,16 @@ data class BookingListItem(
val expectedGuestCount: Int? = null, val expectedGuestCount: Int? = null,
val notes: String? = 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( data class BookingBulkCheckInRequest(
val stays: List<BookingBulkCheckInStayRequest> val stays: List<BookingBulkCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
) )
data class BookingBulkCheckInStayRequest( data class BookingBulkCheckInStayRequest(
@@ -79,6 +102,40 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null 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( data class BookingDetailsResponse(
val id: String? = null, val id: String? = null,
val status: String? = null, val status: String? = null,
@@ -95,6 +152,7 @@ data class BookingDetailsResponse(
val guestSignatureUrl: String? = null, val guestSignatureUrl: String? = null,
val vehicleNumbers: List<String> = emptyList(), val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(), val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
val toCity: String? = null, val toCity: String? = null,
val memberRelation: String? = null, val memberRelation: String? = null,
@@ -110,11 +168,21 @@ data class BookingDetailsResponse(
val totalGuestCount: Int? = null, val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null, val expectedGuestCount: Int? = null,
val totalNightlyRate: Long? = null, val totalNightlyRate: Long? = null,
val notes: String? = null,
val registeredByName: String? = null, val registeredByName: String? = null,
val registeredByPhone: String? = null, val registeredByPhone: String? = null,
val expectedPay: Long? = null, val expectedPay: Long? = null,
val amountCollected: 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( data class BookingLinkGuestRequest(
@@ -126,6 +194,11 @@ data class BookingCheckOutRequest(
val notes: String? = null val notes: String? = null
) )
data class BookingRoomStayCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest( data class BookingCancelRequest(
val cancelledAt: String? = null, val cancelledAt: String? = null,
val reason: String? = null val reason: String? = null
@@ -136,39 +209,12 @@ data class BookingNoShowRequest(
val reason: String? = null val reason: String? = null
) )
data class BookingRoomStayCreateRequest( data class BookingBalanceResponse(
val roomId: String, val expectedPay: Long? = null,
val fromAt: String, val amountCollected: Long? = null,
val toAt: String, val pending: Long? = null
val notes: String? = null
) )
// Room Stays data class RoomStayVoidRequest(
val reason: String
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
) )

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

View File

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

View File

@@ -12,5 +12,7 @@ data class ActiveRoomStayDto(
val roomTypeName: String? = null, val roomTypeName: String? = null,
val fromAt: String? = null, val fromAt: String? = null,
val checkinAt: 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.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckInRequest import com.android.trisolarispms.data.api.model.BookingCheckInRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest 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.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest 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.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest 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.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse 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.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest import com.android.trisolarispms.data.api.model.RazorpayQrRequest
@@ -59,6 +66,40 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest @Body body: BookingExpectedDatesRequest
): Response<Unit> ): 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}") @GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails( suspend fun getBookingDetails(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -70,7 +111,7 @@ interface BookingApi {
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest @Body body: BookingLinkGuestRequest
): Response<BookingCreateResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in") @POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn( suspend fun checkIn(
@@ -84,28 +125,35 @@ interface BookingApi {
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingCheckOutRequest @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") @POST("properties/{propertyId}/bookings/{bookingId}/cancel")
suspend fun cancelBooking( suspend fun cancelBooking(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingCancelRequest @Body body: BookingCancelRequest
): Response<ActionResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/no-show") @POST("properties/{propertyId}/bookings/{bookingId}/no-show")
suspend fun noShow( suspend fun noShow(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingNoShowRequest @Body body: BookingNoShowRequest
): Response<ActionResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays") @GET("properties/{propertyId}/bookings/{bookingId}/balance")
suspend fun preAssignRoomStay( suspend fun getBookingBalance(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String
@Body body: BookingRoomStayCreateRequest ): Response<BookingBalanceResponse>
): Response<RoomStayDto>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr") @POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr")
suspend fun generateRazorpayQr( 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.RoomAvailabilityRangeResponse
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse 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.RoomBoardDto
import com.android.trisolarispms.data.api.model.RoomCreateRequest import com.android.trisolarispms.data.api.model.RoomCreateRequest
import com.android.trisolarispms.data.api.model.RoomDto import com.android.trisolarispms.data.api.model.RoomDto
@@ -55,7 +54,8 @@ interface RoomApi {
suspend fun getRoomAvailabilityRange( suspend fun getRoomAvailabilityRange(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Query("from") from: String, @Query("from") from: String,
@Query("to") to: String @Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailabilityRangeResponse>> ): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available") @GET("properties/{propertyId}/rooms/available")
@@ -63,14 +63,6 @@ interface RoomApi {
@Path("propertyId") propertyId: String @Path("propertyId") propertyId: String
): Response<List<RoomDto>> ): 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}") @GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType( suspend fun listRoomsByType(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -1,8 +1,7 @@
package com.android.trisolarispms.data.api.service 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.ActiveRoomStayDto
import com.android.trisolarispms.data.api.model.RoomStayVoidRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -10,13 +9,13 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
interface RoomStayApi { 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") @GET("properties/{propertyId}/room-stays/active")
suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response<List<ActiveRoomStayDto>> 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 package com.android.trisolarispms.ui.auth
import androidx.activity.ComponentActivity 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExposedDropdownMenuBox import com.android.trisolarispms.ui.common.PhoneNumberCountryField
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 kotlinx.coroutines.delay import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AuthScreen(viewModel: AuthViewModel = viewModel()) { fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity 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 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) { LaunchedEffect(state.resendAvailableAt) {
while (state.resendAvailableAt != null) { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -66,69 +87,12 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium) Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode) PhoneNumberCountryField(
Row( phoneCountryCode = state.phoneCountryCode,
modifier = Modifier.fillMaxWidth(), onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
horizontalArrangement = Arrangement.spacedBy(8.dp) phoneNationalNumber = state.phoneNationalNumber,
) { onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
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()
) )
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)) Spacer(modifier = Modifier.height(12.dp))
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true 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) { if (state.noProperties) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( 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.tasks.await
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AuthViewModel( class AuthViewModel(
@@ -68,6 +69,15 @@ class AuthViewModel(
_state.update { it.copy(error = null) } _state.update { it.copy(error = null) }
} }
fun retryAfterConnectivityIssue() {
val currentUser = auth.currentUser
if (currentUser != null) {
verifyExistingSession(currentUser.uid)
} else {
clearError()
}
}
fun reportError(message: String) { fun reportError(message: String) {
setError(message) setError(message)
} }
@@ -96,7 +106,7 @@ class AuthViewModel(
} }
override fun onVerificationFailed(e: FirebaseException) { override fun onVerificationFailed(e: FirebaseException) {
setError(e.localizedMessage ?: "Verification failed") setError(mapThrowableToMessage(e, fallback = "Verification failed"))
} }
override fun onCodeSent( override fun onCodeSent(
@@ -168,7 +178,7 @@ class AuthViewModel(
val response = api.verifyAuth() val response = api.verifyAuth()
handleVerifyResponse(userId, response) handleVerifyResponse(userId, response)
} catch (e: Exception) { } 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() val response = api.verifyAuth()
handleVerifyResponse(userId, response) handleVerifyResponse(userId, response)
} catch (e: Exception) { } catch (e: Exception) {
setError(e.localizedMessage ?: "Session verify failed") setError(mapThrowableToMessage(e, fallback = "Session verify failed"))
} }
} }
} }
@@ -251,7 +261,7 @@ class AuthViewModel(
noProperties = false, noProperties = false,
unauthorized = false, unauthorized = false,
propertyRoles = emptyMap(), propertyRoles = emptyMap(),
error = "API verify failed: ${response.code()}" error = mapHttpError(response.code(), "API verify failed")
) )
} }
} }
@@ -309,10 +319,10 @@ class AuthViewModel(
) )
} }
} else { } else {
setError("Update failed: ${response.code()}") setError(mapHttpError(response.code(), "Update failed"))
} }
} catch (e: Exception) { } 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 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.kizitonwose.calendar.compose.HorizontalCalendar import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.kizitonwose.calendar.compose.rememberCalendarState import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.kizitonwose.calendar.core.CalendarDay import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.kizitonwose.calendar.core.CalendarMonth import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import com.kizitonwose.calendar.core.DayPosition import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import com.kizitonwose.calendar.core.daysOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Composable @Composable
@@ -68,54 +49,56 @@ fun BookingCreateScreen(
viewModel: BookingCreateViewModel = viewModel() viewModel: BookingCreateViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val showCheckInPicker = remember { mutableStateOf(false) } val showCheckInDatePicker = remember { mutableStateOf(false) }
val showCheckOutPicker = 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 checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) } val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") } val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11: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 relationMenuExpanded = remember { mutableStateOf(false) }
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) } 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 displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val phoneCountryMenuExpanded = remember { mutableStateOf(false) } val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") } 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) { LaunchedEffect(propertyId) {
viewModel.reset() viewModel.reset()
viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now() 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) val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate checkOutDate.value = defaultCheckoutDate
checkOutTime.value = "11:00" checkOutTime.value = "11:00"
viewModel.onExpectedCheckOutAtChange(formatIso(defaultCheckoutDate, checkOutTime.value)) viewModel.onExpectedCheckOutAtChange(formatBookingIso(defaultCheckoutDate, checkOutTime.value))
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
} }
Scaffold( LaunchedEffect(state.expectedCheckOutAt) {
topBar = { val parsed = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull() ?: return@LaunchedEffect
TopAppBar( checkOutDate.value = parsed.toLocalDate()
title = { Text("Create Booking") }, checkOutTime.value = parsed.format(timeFormatter)
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()
)
} }
SaveTopBarScaffold(
title = "Create Booking",
onBack = onBack,
onSave = { viewModel.submit(propertyId, onCreated) }
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -125,59 +108,76 @@ fun BookingCreateScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
Row( val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
modifier = Modifier.fillMaxWidth(), runCatching { OffsetDateTime.parse(it) }.getOrNull()
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 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)) Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = billingModeMenuExpanded.value,
onExpandedChange = { billingModeMenuExpanded.value = !billingModeMenuExpanded.value }
) {
OutlinedTextField( OutlinedTextField(
value = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let { value = state.billingMode.name,
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Expected Check-out") }, label = { Text("Billing Mode") },
trailingIcon = { trailingIcon = {
IconButton(onClick = { showCheckOutPicker.value = true }) { ExposedDropdownMenuDefaults.TrailingIcon(expanded = billingModeMenuExpanded.value)
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date") },
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() modifier = Modifier.fillMaxWidth()
) )
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode) val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
val phoneDigitsLength = state.phoneNationalNumber.length val phoneDigitsLength = state.phoneNationalNumber.length
@@ -224,115 +224,31 @@ fun BookingCreateScreen(
} }
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( PhoneNumberCountryField(
modifier = Modifier.fillMaxWidth(), phoneCountryCode = state.phoneCountryCode,
horizontalArrangement = Arrangement.spacedBy(8.dp), onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
verticalAlignment = Alignment.Top phoneNationalNumber = state.phoneNationalNumber,
) { onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
ExposedDropdownMenuBox( countryWeight = 0.3f,
expanded = phoneCountryMenuExpanded.value, numberWeight = 0.7f
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()
) )
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)) Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox( CityAutocompleteField(
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(
value = state.fromCity, value = state.fromCity,
onValueChange = viewModel::onFromCityChange, onValueChange = viewModel::onFromCityChange,
label = { Text("From City (optional)") }, label = "From City (optional)",
modifier = Modifier.fillMaxWidth() suggestions = state.fromCitySuggestions,
isLoading = state.isFromCitySearchLoading,
onSuggestionSelected = viewModel::onFromCitySuggestionSelected
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField( CityAutocompleteField(
value = state.toCity, value = state.toCity,
onValueChange = viewModel::onToCityChange, onValueChange = viewModel::onToCityChange,
label = { Text("To City (optional)") }, label = "To City (optional)",
modifier = Modifier.fillMaxWidth() suggestions = state.toCitySuggestions,
isLoading = state.isToCitySearchLoading,
onSuggestionSelected = viewModel::onToCitySuggestionSelected
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
@@ -355,7 +271,7 @@ fun BookingCreateScreen(
expanded = relationMenuExpanded.value, expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false } onDismissRequest = { relationMenuExpanded.value = false }
) { ) {
relationOptions.forEach { option -> BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(option) }, text = { Text(option) },
onClick = { onClick = {
@@ -372,7 +288,7 @@ fun BookingCreateScreen(
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value } onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.transportMode, value = state.transportMode.ifBlank { "Not set" },
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Transport Mode") }, label = { Text("Transport Mode") },
@@ -387,9 +303,10 @@ fun BookingCreateScreen(
expanded = transportMenuExpanded.value, expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false } onDismissRequest = { transportMenuExpanded.value = false }
) { ) {
transportOptions.forEach { option -> BookingProfileOptions.transportModes.forEach { option ->
val optionLabel = option.ifBlank { "Not set" }
DropdownMenuItem( DropdownMenuItem(
text = { Text(option) }, text = { Text(optionLabel) },
onClick = { onClick = {
transportMenuExpanded.value = false transportMenuExpanded.value = false
viewModel.onTransportModeChange(option) viewModel.onTransportModeChange(option)
@@ -456,187 +373,51 @@ fun BookingCreateScreen(
} }
} }
if (showCheckInPicker.value) { if (showCheckInDatePicker.value) {
DateTimePickerDialog( BookingDatePickerDialog(
title = "Select check-in", initialDate = checkInDate.value ?: LocalDate.now(),
initialDate = checkInDate.value,
initialTime = checkInTime.value,
minDate = LocalDate.now(), minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false }, onDismiss = { showCheckInDatePicker.value = false },
onConfirm = { date, time -> onDateSelected = { selectedDate ->
checkInDate.value = date applyCheckInSelection(selectedDate, checkInTime.value)
checkInTime.value = time
val formatted = formatIso(date, time)
viewModel.onExpectedCheckInAtChange(formatted)
showCheckInPicker.value = false
} }
) )
} }
if (showCheckOutPicker.value) { if (showCheckInTimePicker.value) {
DateTimePickerDialog( BookingTimePickerDialog(
title = "Select check-out", initialTime = checkInTime.value,
initialDate = checkOutDate.value, onDismiss = { showCheckInTimePicker.value = false },
initialTime = checkOutTime.value, 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(), minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false }, onDismiss = { showCheckOutDatePicker.value = false },
onConfirm = { date, time -> onDateSelected = { selectedDate ->
checkOutDate.value = date checkOutDate.value = selectedDate
checkOutTime.value = time val formatted = formatBookingIso(selectedDate, checkOutTime.value)
val formatted = formatIso(date, time) 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) 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 package com.android.trisolarispms.ui.booking
import com.android.trisolarispms.data.api.model.BookingBillingMode
data class BookingCreateState( data class BookingCreateState(
val phoneCountryCode: String = "IN", val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "", val phoneNationalNumber: String = "",
@@ -8,11 +10,18 @@ data class BookingCreateState(
val phoneVisitCountPhone: String? = null, val phoneVisitCountPhone: String? = null,
val expectedCheckInAt: String = "", val expectedCheckInAt: String = "",
val expectedCheckOutAt: String = "", val expectedCheckOutAt: String = "",
val source: String = "WALKIN", val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
val billingCheckoutTime: String = "",
val source: String = "",
val fromCity: String = "", val fromCity: String = "",
val fromCitySuggestions: List<String> = emptyList(),
val isFromCitySearchLoading: Boolean = false,
val toCity: String = "", val toCity: String = "",
val toCitySuggestions: List<String> = emptyList(),
val isToCitySearchLoading: Boolean = false,
val memberRelation: String = "", val memberRelation: String = "",
val transportMode: String = "CAR", val transportMode: String = "CAR",
val isTransportModeAuto: Boolean = true,
val childCount: String = "", val childCount: String = "",
val maleCount: String = "", val maleCount: String = "",
val femaleCount: String = "", val femaleCount: String = "",

View File

@@ -1,32 +1,184 @@
package com.android.trisolarispms.ui.booking package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.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.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse 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.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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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()) private val _state = MutableStateFlow(BookingCreateState())
val state: StateFlow<BookingCreateState> = _state 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() { fun reset() {
expectedCheckoutPreviewRequestId = 0
fromCitySearch.cancel()
toCitySearch.cancel()
_state.value = BookingCreateState() _state.value = BookingCreateState()
} }
fun onExpectedCheckInAtChange(value: String) { 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) { fun onExpectedCheckOutAtChange(value: String) {
_state.update { it.copy(expectedCheckOutAt = value, error = null) } _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) { fun onPhoneCountryChange(value: String) {
val option = findPhoneCountryOption(value) val option = findPhoneCountryOption(value)
_state.update { current -> _state.update { current ->
@@ -90,10 +242,36 @@ class BookingCreateViewModel : ViewModel() {
fun onFromCityChange(value: String) { fun onFromCityChange(value: String) {
_state.update { it.copy(fromCity = value, error = null) } _state.update { it.copy(fromCity = value, error = null) }
fromCitySearch.onQueryChanged(value)
} }
fun onToCityChange(value: String) { fun onToCityChange(value: String) {
_state.update { it.copy(toCity = value, error = null) } _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) { fun onMemberRelationChange(value: String) {
@@ -101,19 +279,34 @@ class BookingCreateViewModel : ViewModel() {
} }
fun onTransportModeChange(value: String) { 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) { 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) { 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) { 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) { fun onExpectedGuestCountChange(value: String) {
@@ -132,6 +325,14 @@ class BookingCreateViewModel : ViewModel() {
_state.update { it.copy(error = "Check-in and check-out are required") } _state.update { it.copy(error = "Check-in and check-out are required") }
return 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 childCount = current.childCount.toIntOrNull()
val maleCount = current.maleCount.toIntOrNull() val maleCount = current.maleCount.toIntOrNull()
val femaleCount = current.femaleCount.toIntOrNull() val femaleCount = current.femaleCount.toIntOrNull()
@@ -152,6 +353,12 @@ class BookingCreateViewModel : ViewModel() {
body = BookingCreateRequest( body = BookingCreateRequest(
expectedCheckInAt = checkIn, expectedCheckInAt = checkIn,
expectedCheckOutAt = checkOut, 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 }, source = current.source.trim().ifBlank { null },
guestPhoneE164 = phone, guestPhoneE164 = phone,
fromCity = current.fromCity.trim().ifBlank { null }, fromCity = current.fromCity.trim().ifBlank { null },
@@ -167,6 +374,10 @@ class BookingCreateViewModel : ViewModel() {
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
syncCreateBookingCaches(
propertyId = propertyId,
bookingId = body.id
)
_state.update { it.copy(isLoading = false, error = null) } _state.update { it.copy(isLoading = false, error = null) }
onDone(body, null, phone) onDone(body, null, phone)
} else { } 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf 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.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -101,18 +95,9 @@ fun CardInfoScreen(
} }
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Card Details",
TopAppBar( onBack = onBack
title = { Text("Card Details") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState 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.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.android.trisolarispms.ui.common.BackTopBarScaffold
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -97,18 +91,9 @@ fun IssueTemporaryCardScreen(
} }
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Issue Temporary Card",
TopAppBar( onBack = onBack
title = { Text("Issue Temporary Card") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier 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 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -27,11 +12,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestInfoScreen( fun GuestInfoScreen(
propertyId: String, propertyId: String,
bookingId: String,
guestId: String, guestId: String,
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?, initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
initialPhone: String?, initialPhone: String?,
@@ -41,70 +28,75 @@ fun GuestInfoScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
LaunchedEffect(guestId) { LaunchedEffect(propertyId, bookingId, guestId) {
viewModel.reset() viewModel.reset()
viewModel.setInitial(initialGuest, initialPhone) viewModel.setInitial(initialGuest, initialPhone)
viewModel.loadGuest(propertyId, guestId, initialPhone) viewModel.loadGuest(
propertyId = propertyId,
bookingId = bookingId,
guestId = guestId,
fallbackPhone = initialPhone
)
} }
Scaffold( SaveTopBarScaffold(
topBar = { title = "Guest Info",
TopAppBar( onBack = onBack,
title = { Text("Guest Info") }, onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
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()
)
}
) { padding -> ) { padding ->
Column( PaddedScreenColumn(
modifier = Modifier padding = padding,
.fillMaxSize() scrollable = true
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) { ) {
OutlinedTextField( GuestInfoFormFields(
value = state.phoneE164, phoneCountryCode = state.phoneCountryCode,
onValueChange = viewModel::onPhoneChange, onPhoneCountryCodeChange = { code ->
label = { Text("Phone E164 (optional)") }, viewModel.onPhoneCountryChange(
modifier = Modifier.fillMaxWidth() value = code,
propertyId = propertyId,
guestId = guestId
) )
Spacer(modifier = Modifier.height(12.dp)) },
OutlinedTextField( phoneNationalNumber = state.phoneNationalNumber,
value = state.name, onPhoneNationalNumberChange = { number ->
onValueChange = viewModel::onNameChange, viewModel.onPhoneNationalNumberChange(
label = { Text("Name (optional)") }, value = number,
modifier = Modifier.fillMaxWidth() propertyId = propertyId,
guestId = guestId
) )
Spacer(modifier = Modifier.height(12.dp)) },
OutlinedTextField( name = state.name,
value = state.nationality, onNameChange = viewModel::onNameChange,
onValueChange = viewModel::onNationalityChange, nationality = state.nationality,
label = { Text("Nationality (optional)") }, onNationalityChange = viewModel::onNationalityChange,
modifier = Modifier.fillMaxWidth() nationalitySuggestions = state.nationalitySuggestions,
) isNationalitySearchLoading = state.isNationalitySearchLoading,
Spacer(modifier = Modifier.height(12.dp)) onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
OutlinedTextField( age = state.age,
value = state.age, onAgeChange = viewModel::onAgeChange,
onValueChange = viewModel::onAgeChange, addressText = state.addressText,
label = { Text("DOB (dd/MM/yyyy)") }, onAddressChange = viewModel::onAddressChange,
modifier = Modifier.fillMaxWidth() fromCity = state.fromCity,
) onFromCityChange = viewModel::onFromCityChange,
Spacer(modifier = Modifier.height(12.dp)) fromCitySuggestions = state.fromCitySuggestions,
OutlinedTextField( isFromCitySearchLoading = state.isFromCitySearchLoading,
value = state.addressText, onFromCitySuggestionSelected = viewModel::onFromCitySuggestionSelected,
onValueChange = viewModel::onAddressChange, toCity = state.toCity,
label = { Text("Address (optional)") }, onToCityChange = viewModel::onToCityChange,
modifier = Modifier.fillMaxWidth() 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) { if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@@ -1,11 +1,25 @@
package com.android.trisolarispms.ui.guest package com.android.trisolarispms.ui.guest
data class GuestInfoState( data class GuestInfoState(
val phoneE164: String = "", val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
val name: String = "", val name: String = "",
val nationality: String = "", val nationality: String = "",
val nationalitySuggestions: List<String> = emptyList(),
val isNationalitySearchLoading: Boolean = false,
val age: String = "", val age: String = "",
val addressText: 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 vehicleNumbers: List<String> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null val error: String? = null

View File

@@ -1,25 +1,103 @@
package com.android.trisolarispms.ui.guest package com.android.trisolarispms.ui.guest
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.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.GuestDto
import com.android.trisolarispms.data.api.model.GuestUpdateRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class GuestInfoViewModel : ViewModel() { class GuestInfoViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(GuestInfoState()) private val _state = MutableStateFlow(GuestInfoState())
val state: StateFlow<GuestInfoState> = _state 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() { fun reset() {
nationalitySearchJob?.cancel()
nationalitySearchJob = null
phoneAutofillJob?.cancel()
phoneAutofillJob = null
lastAutofilledPhoneE164 = null
fromCitySearch.cancel()
toCitySearch.cancel()
initialBookingProfile = null
_state.value = GuestInfoState() _state.value = GuestInfoState()
} }
fun onPhoneChange(value: String) { fun onPhoneCountryChange(value: String, propertyId: String, guestId: String) {
_state.update { it.copy(phoneE164 = value, error = null) } 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) { fun onNameChange(value: String) {
@@ -28,6 +106,19 @@ class GuestInfoViewModel : ViewModel() {
fun onNationalityChange(value: String) { fun onNationalityChange(value: String) {
_state.update { it.copy(nationality = value, error = null) } _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) { fun onAgeChange(value: String) {
@@ -38,13 +129,71 @@ class GuestInfoViewModel : ViewModel() {
_state.update { it.copy(addressText = value, error = null) } _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 { _state.update {
it.copy( 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(), name = guest?.name.orEmpty(),
nationality = guest?.nationality.orEmpty(), nationality = guest?.nationality.orEmpty(),
age = guest?.age.orEmpty(), nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
age = guest?.dob.orEmpty(),
addressText = guest?.addressText.orEmpty(), addressText = guest?.addressText.orEmpty(),
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(), vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
error = null error = null
@@ -52,75 +201,421 @@ class GuestInfoViewModel : ViewModel() {
} }
} }
fun loadGuest(propertyId: String, guestId: String, fallbackPhone: String?) { fun loadGuest(propertyId: String, bookingId: String, guestId: String, fallbackPhone: String?) {
if (propertyId.isBlank() || guestId.isBlank()) return if (propertyId.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
var loadError: String? = null
try { try {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.getGuest(propertyId = propertyId, guestId = guestId) if (guestId.isNotBlank()) {
val guest = response.body() val guestResponse = api.getGuest(propertyId = propertyId, guestId = guestId)
if (response.isSuccessful && guest != null) { val guest = guestResponse.body()
if (guestResponse.isSuccessful && guest != null) {
val parsedPhone = parsePhoneE164(guest.phoneE164 ?: fallbackPhone)
_state.update { _state.update {
it.copy( it.copy(
phoneE164 = guest.phoneE164 ?: fallbackPhone.orEmpty(), phoneCountryCode = parsedPhone.countryCode,
phoneNationalNumber = parsedPhone.nationalNumber,
name = guest.name.orEmpty(), name = guest.name.orEmpty(),
nationality = guest.nationality.orEmpty(), nationality = guest.nationality.orEmpty(),
age = guest.age.orEmpty(), nationalitySuggestions = emptyList(),
isNationalitySearchLoading = false,
age = guest.dob.orEmpty(),
addressText = guest.addressText.orEmpty(), addressText = guest.addressText.orEmpty(),
vehicleNumbers = guest.vehicleNumbers ?: emptyList(), vehicleNumbers = guest.vehicleNumbers,
isLoading = false,
error = null error = null
) )
} }
} else { } else {
val parsedPhone = parsePhoneE164(fallbackPhone)
_state.update { _state.update {
it.copy( it.copy(
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() }, phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
isLoading = false, phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
error = "Load failed: ${response.code()}" 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) { } catch (e: Exception) {
loadError = e.localizedMessage ?: "Load failed"
}
val parsedPhone = parsePhoneE164(fallbackPhone)
_state.update { _state.update {
it.copy( it.copy(
phoneE164 = it.phoneE164.ifBlank { fallbackPhone.orEmpty() }, phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
isNationalitySearchLoading = false,
isLoading = false, isLoading = false,
error = e.localizedMessage ?: "Load failed" error = loadError
) )
} }
} }
} }
}
fun submit(propertyId: String, guestId: String, onDone: () -> Unit) { fun submit(propertyId: String, bookingId: String, guestId: String, onDone: () -> Unit) {
if (propertyId.isBlank() || guestId.isBlank()) return if (propertyId.isBlank() || bookingId.isBlank() || guestId.isBlank()) return
val current = state.value val current = state.value
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
try { try {
val api = ApiClient.create() 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( val response = api.updateGuest(
propertyId = propertyId, propertyId = propertyId,
guestId = guestId, guestId = guestId,
body = GuestUpdateRequest( body = GuestUpdateRequest(
phoneE164 = current.phoneE164.trim().ifBlank { null }, phoneE164 = phoneE164,
name = current.name.trim().ifBlank { null }, name = current.name.trim().ifBlank { null },
nationality = current.nationality.trim().ifBlank { null }, nationality = current.nationality.trim().ifBlank { null },
age = current.age.trim().ifBlank { null }, age = current.age.trim().ifBlank { null },
addressText = current.addressText.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) } _state.update { it.copy(isLoading = false, error = null) }
onDone() onDone()
} else { } else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } _state.update { it.copy(isLoading = false, error = submitError) }
} }
} catch (e: Exception) { } 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -48,11 +41,13 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.Locale import java.util.Locale
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestSignatureScreen( fun GuestSignatureScreen(
propertyId: String, propertyId: String,
bookingId: String,
guestId: String, guestId: String,
onBack: () -> Unit, onBack: () -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
@@ -66,21 +61,15 @@ fun GuestSignatureScreen(
viewModel.reset() viewModel.reset()
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Guest Signature",
TopAppBar( onBack = onBack,
title = { Text("Guest Signature") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = { actions = {
IconButton( IconButton(
onClick = { onClick = {
val svg = buildSignatureSvg(strokes, canvasSize.value) val svg = buildSignatureSvg(strokes, canvasSize.value)
if (!svg.isNullOrBlank()) { if (!svg.isNullOrBlank()) {
viewModel.uploadSignature(propertyId, guestId, svg, onDone) viewModel.uploadSignature(propertyId, bookingId, guestId, svg, onDone)
} }
}, },
enabled = strokes.isNotEmpty() && !state.isLoading enabled = strokes.isNotEmpty() && !state.isLoading
@@ -91,18 +80,9 @@ fun GuestSignatureScreen(
Icon(Icons.Default.Done, contentDescription = "Upload") Icon(Icons.Default.Done, contentDescription = "Upload")
} }
} }
},
colors = TopAppBarDefaults.topAppBarColors()
)
} }
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
Text( Text(
text = "Please draw the guest signature below.", text = "Please draw the guest signature below.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium

View File

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

View File

@@ -1,15 +1,14 @@
package com.android.trisolarispms.ui.home package com.android.trisolarispms.ui.home
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.core.auth.Role import com.android.trisolarispms.core.auth.Role
import com.android.trisolarispms.core.auth.toRoles import com.android.trisolarispms.core.auth.toRoles
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class HomeJoinPropertyState( data class HomeJoinPropertyState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -34,9 +33,12 @@ class HomeJoinPropertyViewModel : ViewModel() {
_state.update { it.copy(error = "Code must be 6 digits", message = null) } _state.update { it.copy(error = "Code must be 6 digits", message = null) }
return return
} }
viewModelScope.launch { launchRequest(
_state.update { it.copy(isLoading = true, error = null, message = null) } state = _state,
try { 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( val response = ApiClient.create().joinAccessCode(
PropertyAccessCodeJoinRequest( PropertyAccessCodeJoinRequest(
propertyId = trimmedPropertyId, propertyId = trimmedPropertyId,
@@ -57,9 +59,6 @@ class HomeJoinPropertyViewModel : ViewModel() {
} else { } else {
_state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") } _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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.core.auth.Role
import com.android.trisolarispms.ui.property.PropertyListViewModel import com.android.trisolarispms.ui.property.PropertyListViewModel
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun HomeScreen( fun HomeScreen(
userId: String?, userId: String?,
userName: String?, userName: String?,
@@ -78,11 +74,10 @@ fun HomeScreen(
} }
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Trisolaris PMS",
TopAppBar( onBack = {},
title = { Text("Trisolaris PMS") }, showBack = false,
colors = TopAppBarDefaults.topAppBarColors(),
actions = { actions = {
if (isSuperAdmin) { if (isSuperAdmin) {
IconButton(onClick = onUserDirectory) { IconButton(onClick = onUserDirectory) {
@@ -128,16 +123,8 @@ fun HomeScreen(
} }
} }
} }
)
}
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome" val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome"
Text(text = title, style = MaterialTheme.typography.headlineMedium) Text(text = title, style = MaterialTheme.typography.headlineMedium)
if (isSuperAdmin) { if (isSuperAdmin) {

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,12 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.Role 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.navigation.AppRoute
import com.android.trisolarispms.ui.auth.AuthUiState import com.android.trisolarispms.ui.auth.AuthUiState
import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.booking.BookingCreateScreen 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.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.home.HomeScreen
@@ -70,21 +72,55 @@ internal fun renderHomeGuestRoutes(
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() } val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
?: response.expectedCheckInAt.orEmpty() ?: response.expectedCheckInAt.orEmpty()
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() } 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, propertyId = currentRoute.propertyId,
bookingId = bookingId, bookingId = bookingId,
guestId = guestId, guestId = guestId,
fromAt = fromAt, fromAt = fromAt,
toAt = toAt 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 { } else {
refs.route.value = AppRoute.Home 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( is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,
initialGuest = refs.selectedGuest.value, initialGuest = refs.selectedGuest.value,
initialPhone = refs.selectedGuestPhone.value, initialPhone = refs.selectedGuestPhone.value,
@@ -100,6 +136,7 @@ internal fun renderHomeGuestRoutes(
is AppRoute.GuestSignature -> GuestSignatureScreen( is AppRoute.GuestSignature -> GuestSignatureScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,
onBack = { onBack = {
refs.route.value = AppRoute.GuestInfo( 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.RazorpayQrScreen
import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen 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.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.settings.PropertySettingsScreen
@Composable @Composable
internal fun renderStayFlowRoutes( internal fun renderStayFlowRoutes(
@@ -18,31 +20,64 @@ internal fun renderStayFlowRoutes(
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
authz: AuthzPolicy authz: AuthzPolicy
): Boolean { ): 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) { when (val currentRoute = refs.currentRoute) {
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen( is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
propertyName = currentRoute.propertyName, propertyName = currentRoute.propertyName,
onBack = { onBack = {
val blockBack = authz.shouldBlockBackToHome( val blockBack = shouldBlockHomeBack(authz, state, currentRoute.propertyId)
propertyId = currentRoute.propertyId,
propertyCount = state.propertyRoles.size
)
if (!blockBack) { if (!blockBack) {
refs.route.value = AppRoute.Home refs.route.value = AppRoute.Home
} }
}, },
showBack = !authz.shouldBlockBackToHome( showBack = !shouldBlockHomeBack(authz, state, currentRoute.propertyId),
propertyId = currentRoute.propertyId,
propertyCount = state.propertyRoles.size
),
onViewRooms = { refs.route.value = AppRoute.Rooms(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) }, onCreateBooking = { refs.route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId), canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId),
showRazorpaySettings = authz.canManageRazorpaySettings(currentRoute.propertyId), showRazorpaySettings = authz.canManageRazorpaySettings(currentRoute.propertyId),
onRazorpaySettings = { refs.route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) }, onRazorpaySettings = { refs.route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) },
showUserAdmin = authz.canManagePropertyUsers(currentRoute.propertyId), showUserAdmin = authz.canManagePropertyUsers(currentRoute.propertyId),
onUserAdmin = { refs.route.value = AppRoute.PropertyUsers(currentRoute.propertyId) }, onUserAdmin = { refs.route.value = AppRoute.PropertyUsers(currentRoute.propertyId) },
onLogout = authViewModel::signOut,
onManageRoomStay = { booking -> onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty() ?: booking.expectedCheckInAt.orEmpty()
@@ -77,6 +112,14 @@ internal fun renderStayFlowRoutes(
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) } 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( is AppRoute.RazorpayQr -> RazorpayQrScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
@@ -91,13 +134,11 @@ internal fun renderStayFlowRoutes(
} }
) )
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( is AppRoute.ManageRoomStaySelect -> renderManageRoomStaySelectRoute(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt, fromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt, toAt = currentRoute.toAt
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }, ) {
onNext = { rooms ->
refs.selectedManageRooms.value = rooms
refs.route.value = AppRoute.ManageRoomStayRates( refs.route.value = AppRoute.ManageRoomStayRates(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
@@ -105,15 +146,12 @@ internal fun renderStayFlowRoutes(
toAt = currentRoute.toAt toAt = currentRoute.toAt
) )
} }
)
is AppRoute.ManageRoomStaySelectFromBooking -> ManageRoomStaySelectScreen( is AppRoute.ManageRoomStaySelectFromBooking -> renderManageRoomStaySelectRoute(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt, fromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt, toAt = currentRoute.toAt
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }, ) {
onNext = { rooms ->
refs.selectedManageRooms.value = rooms
refs.route.value = AppRoute.ManageRoomStayRatesFromBooking( refs.route.value = AppRoute.ManageRoomStayRatesFromBooking(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
@@ -122,31 +160,28 @@ internal fun renderStayFlowRoutes(
toAt = currentRoute.toAt toAt = currentRoute.toAt
) )
} }
)
is AppRoute.ManageRoomStayRates -> ManageRoomStayRatesScreen( is AppRoute.ManageRoomStayRates -> renderManageRoomStayRatesRoute(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt, fromAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt, toAt = currentRoute.toAt,
selectedRooms = refs.selectedManageRooms.value,
onBack = { onBack = {
refs.route.value = AppRoute.ManageRoomStaySelect( refs.route.value = AppRoute.ManageRoomStaySelect(
currentRoute.propertyId, propertyId = currentRoute.propertyId,
currentRoute.bookingId, bookingId = currentRoute.bookingId,
currentRoute.fromAt, fromAt = currentRoute.fromAt,
currentRoute.toAt toAt = currentRoute.toAt
) )
}, },
onDone = { refs.openActiveRoomStays(currentRoute.propertyId) } onDone = { refs.openActiveRoomStays(currentRoute.propertyId) }
) )
is AppRoute.ManageRoomStayRatesFromBooking -> ManageRoomStayRatesScreen( is AppRoute.ManageRoomStayRatesFromBooking -> renderManageRoomStayRatesRoute(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt, fromAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt, toAt = currentRoute.toAt,
selectedRooms = refs.selectedManageRooms.value,
onBack = { onBack = {
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking( refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
currentRoute.propertyId, currentRoute.propertyId,
@@ -169,3 +204,9 @@ internal fun renderStayFlowRoutes(
} }
return true 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.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -42,11 +37,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.PaymentDto import com.android.trisolarispms.data.api.model.PaymentDto
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingPaymentsScreen( fun BookingPaymentsScreen(
propertyId: String, propertyId: String,
bookingId: String, bookingId: String,
@@ -61,23 +56,27 @@ fun BookingPaymentsScreen(
val refundTarget = remember { mutableStateOf<PaymentDto?>(null) } val refundTarget = remember { mutableStateOf<PaymentDto?>(null) }
val refundAmount = rememberSaveable { mutableStateOf("") } val refundAmount = rememberSaveable { mutableStateOf("") }
val refundNotes = 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) { LaunchedEffect(propertyId, bookingId) {
viewModel.load(propertyId, bookingId) viewModel.load(propertyId, bookingId)
} }
LaunchedEffect(state.message) {
if (state.message == "Cash payment added") {
amountInput.value = ""
}
}
Scaffold( BackTopBarScaffold(
topBar = { title = "Payments",
TopAppBar( onBack = onBack
title = { Text("Payments") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -86,6 +85,12 @@ fun BookingPaymentsScreen(
.padding(16.dp) .padding(16.dp)
) { ) {
if (canAddCash) { if (canAddCash) {
Text(
text = "Pending balance: ${pendingBalance?.toString() ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = amountInput.value, value = amountInput.value,
onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } }, onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } },
@@ -93,14 +98,20 @@ fun BookingPaymentsScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth() 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)) Spacer(modifier = Modifier.height(8.dp))
Button( Button(
onClick = { onClick = {
val amount = amountInput.value.toLongOrNull() ?: 0L viewModel.addCashPayment(propertyId, bookingId, amountValue ?: 0L)
viewModel.addCashPayment(propertyId, bookingId, amount)
amountInput.value = ""
}, },
enabled = !state.isLoading, enabled = canAddCashPayment,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Add cash payment") Text("Add cash payment")
@@ -218,7 +229,7 @@ private fun PaymentCard(
runCatching { OffsetDateTime.parse(it) }.getOrNull() runCatching { OffsetDateTime.parse(it) }.getOrNull()
} }
val dateText = date?.format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")) 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( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (isCash) { containerColor = if (isCash) {
@@ -243,6 +254,7 @@ private fun PaymentCard(
val hasRefundableAmount = (payment.amount ?: 0L) > 0L val hasRefundableAmount = (payment.amount ?: 0L) > 0L
if ( if (
canRefund && canRefund &&
!isCash &&
hasRefundableAmount && hasRefundableAmount &&
(!payment.id.isNullOrBlank() || !payment.gatewayPaymentId.isNullOrBlank()) (!payment.id.isNullOrBlank() || !payment.gatewayPaymentId.isNullOrBlank())
) { ) {

View File

@@ -6,5 +6,6 @@ data class BookingPaymentsState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val message: 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 package com.android.trisolarispms.ui.payment
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RazorpayRefundRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.Response
class BookingPaymentsViewModel : ViewModel() { class BookingPaymentsViewModel(
application: Application
) : AndroidViewModel(application) {
private val _state = MutableStateFlow(BookingPaymentsState()) private val _state = MutableStateFlow(BookingPaymentsState())
val state: StateFlow<BookingPaymentsState> = _state 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) { fun load(propertyId: String, bookingId: String) {
viewModelScope.launch { if (propertyId.isBlank() || bookingId.isBlank()) return
_state.update { it.copy(isLoading = true, error = null, message = null) } observeCaches(propertyId = propertyId, bookingId = bookingId)
try { runPaymentAction(defaultError = "Load failed") {
val api = ApiClient.create() refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
val response = api.listPayments(propertyId, bookingId) _state.update { it.copy(isLoading = false, error = null, message = null) }
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
)
}
}
} }
} }
@@ -55,10 +46,14 @@ class BookingPaymentsViewModel : ViewModel() {
_state.update { it.copy(error = "Amount must be greater than 0", message = null) } _state.update { it.copy(error = "Amount must be greater than 0", message = null) }
return return
} }
viewModelScope.launch { runPaymentAction(defaultError = "Create failed") { api ->
_state.update { it.copy(isLoading = true, error = null, message = null) } val latestPending = api.getBookingBalance(propertyId, bookingId).body()?.pending
try { ?: _state.value.pendingBalance
val api = ApiClient.create() 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( val response = api.createPayment(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId, bookingId = bookingId,
@@ -66,71 +61,38 @@ class BookingPaymentsViewModel : ViewModel() {
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
_state.update { current -> refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
current.copy( _state.update {
it.copy(
isLoading = false, isLoading = false,
payments = listOf(body) + current.payments,
error = null, error = null,
message = "Cash payment added" message = "Cash payment added"
) )
} }
} else { } else {
_state.update { setActionFailure("Create", response)
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
)
}
} }
} }
} }
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) { fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
viewModelScope.launch { runPaymentAction(defaultError = "Delete failed") { api ->
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.deletePayment( val response = api.deletePayment(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId, bookingId = bookingId,
paymentId = paymentId paymentId = paymentId
) )
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { current -> refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
current.copy( _state.update {
it.copy(
isLoading = false, isLoading = false,
payments = current.payments.filterNot { it.id == paymentId },
error = null, error = null,
message = "Cash payment deleted" message = "Cash payment deleted"
) )
} }
} else { } else {
_state.update { setActionFailure("Delete", response)
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
)
}
} }
} }
} }
@@ -151,10 +113,7 @@ class BookingPaymentsViewModel : ViewModel() {
_state.update { it.copy(error = "Missing payment ID", message = null) } _state.update { it.copy(error = "Missing payment ID", message = null) }
return return
} }
viewModelScope.launch { runPaymentAction(defaultError = "Refund failed") { api ->
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.refundRazorpayPayment( val response = api.refundRazorpayPayment(
propertyId = propertyId, propertyId = propertyId,
bookingId = bookingId, bookingId = bookingId,
@@ -167,6 +126,7 @@ class BookingPaymentsViewModel : ViewModel() {
) )
val body = response.body() val body = response.body()
if (response.isSuccessful && body != null) { if (response.isSuccessful && body != null) {
refreshPaymentCaches(propertyId = propertyId, bookingId = bookingId)
_state.update { _state.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@@ -174,25 +134,88 @@ class BookingPaymentsViewModel : ViewModel() {
message = "Refund processed" message = "Refund processed"
) )
} }
load(propertyId, bookingId)
} else { } else {
_state.update { setActionFailure("Refund", response)
it.copy(
isLoading = false,
error = "Refund failed: ${response.code()}",
message = null
)
} }
} }
}
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) { } catch (e: Exception) {
_state.update { _state.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
error = e.localizedMessage ?: "Refund failed", error = e.localizedMessage ?: defaultError,
message = null 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -44,6 +35,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -70,26 +63,11 @@ fun AddPropertyScreen(
} }
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Add Property",
TopAppBar( onBack = onBack
title = { Text("Add Property") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ExposedDropdownMenuBox( ExposedDropdownMenuBox(

View File

@@ -1,13 +1,12 @@
package com.android.trisolarispms.ui.property package com.android.trisolarispms.ui.property
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.PropertyCreateRequest import com.android.trisolarispms.data.api.model.PropertyCreateRequest
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AddPropertyViewModel : ViewModel() { class AddPropertyViewModel : ViewModel() {
private val _state = MutableStateFlow(AddPropertyState()) private val _state = MutableStateFlow(AddPropertyState())
@@ -56,9 +55,12 @@ class AddPropertyViewModel : ViewModel() {
return return
} }
viewModelScope.launch { launchRequest(
_state.update { it.copy(isLoading = true, error = null) } state = _state,
try { setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Create failed"
) {
val api = ApiClient.create() val api = ApiClient.create()
val body = PropertyCreateRequest( val body = PropertyCreateRequest(
code = null, code = null,
@@ -79,9 +81,6 @@ class AddPropertyViewModel : ViewModel() {
} else { } else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } _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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PropertyHomeScreen( fun PropertyHomeScreen(
propertyId: String, propertyId: String,
onBack: () -> Unit, onBack: () -> Unit,
@@ -33,26 +25,11 @@ fun PropertyHomeScreen(
onRoomTypes: () -> Unit, onRoomTypes: () -> Unit,
canManageRooms: Boolean canManageRooms: Boolean
) { ) {
Scaffold( BackTopBarScaffold(
topBar = { title = "Property",
TopAppBar( onBack = onBack
title = { Text("Property") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
PropertyTile(title = "Checked-in Rooms", subtitle = "Active stays", onClick = onActiveStays) PropertyTile(title = "Checked-in Rooms", subtitle = "Active stays", onClick = onActiveStays)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
PropertyTile(title = "Available Rooms", subtitle = "Room list", onClick = onRooms) PropertyTile(title = "Available Rooms", subtitle = "Room list", onClick = onRooms)

View File

@@ -1,21 +1,23 @@
package com.android.trisolarispms.ui.property package com.android.trisolarispms.ui.property
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PropertyListViewModel : ViewModel() { class PropertyListViewModel : ViewModel() {
private val _state = MutableStateFlow(PropertyListState()) private val _state = MutableStateFlow(PropertyListState())
val state: StateFlow<PropertyListState> = _state val state: StateFlow<PropertyListState> = _state
fun refresh() { fun refresh() {
viewModelScope.launch { launchRequest(
_state.update { it.copy(isLoading = true, error = null) } state = _state,
try { setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.listProperties() val response = api.listProperties()
if (response.isSuccessful) { if (response.isSuccessful) {
@@ -29,9 +31,6 @@ class PropertyListViewModel : ViewModel() {
} else { } else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } _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.text.KeyboardOptions
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -49,9 +44,9 @@ import com.android.trisolarispms.BuildConfig
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import com.android.trisolarispms.ui.common.BackTopBarScaffold
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RazorpayQrScreen( fun RazorpayQrScreen(
propertyId: String, propertyId: String,
bookingId: String, bookingId: String,
@@ -93,23 +88,14 @@ fun RazorpayQrScreen(
exitAndRefresh() exitAndRefresh()
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Generate Payment Links | QR",
TopAppBar( onBack = {
title = { Text("Generate Payment Links | QR") },
navigationIcon = {
IconButton(onClick = {
if (isViewingQr) { if (isViewingQr) {
exitAndRefresh() exitAndRefresh()
} else { } else {
onBack() onBack()
} }
}) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
} }
) { padding -> ) { padding ->
if (state.isCredited) { if (state.isCredited) {

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.ui.razorpay package com.android.trisolarispms.ui.razorpay
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.core.ApiConstants import com.android.trisolarispms.data.api.core.ApiConstants
@@ -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.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest
import com.android.trisolarispms.data.api.model.RazorpayQrRequest 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 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.Request
import okhttp3.sse.EventSource import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources import okhttp3.sse.EventSources
import 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 gson = Gson()
private val _state = MutableStateFlow( private val _state = MutableStateFlow(
RazorpayQrState(deviceInfo = buildDeviceInfo()) RazorpayQrState(deviceInfo = buildDeviceInfo())
) )
val state: StateFlow<RazorpayQrState> = _state 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 qrEventSource: EventSource? = null
private var lastQrId: String? = null private var lastQrId: String? = null
private var qrPollJob: Job? = 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() { fun reset() {
stopQrEventStream() stopQrEventStream()
stopQrEventPolling() stopQrEventPolling()
stopRequestObservation()
stopQrEventObservation()
_state.value = RazorpayQrState(deviceInfo = buildDeviceInfo()) _state.value = RazorpayQrState(deviceInfo = buildDeviceInfo())
} }
fun exitQrView() { fun exitQrView() {
stopQrEventStream() stopQrEventStream()
stopQrEventPolling() stopQrEventPolling()
stopQrEventObservation()
_state.update { _state.update {
it.copy( it.copy(
qrId = null, qrId = null,
@@ -96,10 +120,11 @@ class RazorpayQrViewModel : ViewModel() {
currency = body.currency, currency = body.currency,
imageUrl = body.imageUrl, imageUrl = body.imageUrl,
isClosed = false, isClosed = false,
isCredited = false,
error = null error = null
) )
} }
loadQrList(propertyId, bookingId) refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
startQrEventStream(propertyId, bookingId, body.qrId) startQrEventStream(propertyId, bookingId, body.qrId)
} else { } else {
_state.update { _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) { fun loadQrList(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
observeRequestCache(propertyId = propertyId, bookingId = bookingId)
viewModelScope.launch { viewModelScope.launch {
try { refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
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
}
} }
} }
@@ -256,10 +165,7 @@ class RazorpayQrViewModel : ViewModel() {
body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId) body = RazorpayCloseRequest(qrId = qrId, paymentLinkId = paymentLinkId)
) )
if (response.isSuccessful) { if (response.isSuccessful) {
_state.update { current -> refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
current.copy(qrList = current.qrList.filterNot { it.requestId == item.requestId })
}
loadQrList(propertyId, bookingId)
} }
} catch (_: Exception) { } catch (_: Exception) {
// ignore close errors // ignore close errors
@@ -313,7 +219,7 @@ class RazorpayQrViewModel : ViewModel() {
error = null error = null
) )
} }
loadQrList(propertyId, bookingId) refreshRequestCache(propertyId = propertyId, bookingId = bookingId)
} else { } else {
_state.update { _state.update {
it.copy( 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 { private fun buildDeviceInfo(): String {
val release = android.os.Build.VERSION.RELEASE val release = android.os.Build.VERSION.RELEASE
val model = android.os.Build.MODEL val model = android.os.Build.MODEL
@@ -342,5 +442,8 @@ class RazorpayQrViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
stopQrEventStream() 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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.Button
import androidx.compose.material3.CircularProgressIndicator 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -37,9 +29,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import com.android.trisolarispms.ui.common.BackTopBarScaffold
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RazorpaySettingsScreen( fun RazorpaySettingsScreen(
propertyId: String, propertyId: String,
onBack: () -> Unit, onBack: () -> Unit,
@@ -52,18 +44,9 @@ fun RazorpaySettingsScreen(
viewModel.load(propertyId) viewModel.load(propertyId)
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Razorpay Settings",
TopAppBar( onBack = onBack
title = { Text("Razorpay Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

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

View File

@@ -1,14 +1,14 @@
package com.android.trisolarispms.ui.room package com.android.trisolarispms.ui.room
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import com.android.trisolarispms.core.viewmodel.launchApiMutation
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RoomCreateRequest import com.android.trisolarispms.data.api.model.RoomCreateRequest
import com.android.trisolarispms.data.api.model.RoomUpdateRequest import com.android.trisolarispms.data.api.model.RoomUpdateRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import retrofit2.Response
class RoomFormViewModel : ViewModel() { class RoomFormViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomFormState()) 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 onNotesChange(value: String) = _state.update { it.copy(notes = value, error = null) }
fun submitCreate(propertyId: String, onDone: () -> Unit) { fun submitCreate(propertyId: String, onDone: () -> Unit) {
val roomNumberText = state.value.roomNumber.trim() val input = readValidatedInput() ?: return
val roomTypeCode = state.value.roomTypeCode.trim() submitMutation(
val roomNumber = roomNumberText.toIntOrNull() action = "Create",
if (roomNumber == null || roomTypeCode.isBlank()) { onDone = onDone
_state.update { it.copy(error = "Room number must be a number and room type is required") } ) { api ->
return api.createRoom(propertyId, input.toCreateRequest())
}
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") }
}
} }
} }
fun submitUpdate(propertyId: String, roomId: String, onDone: () -> Unit) { fun submitUpdate(propertyId: String, roomId: String, onDone: () -> Unit) {
val roomNumberText = state.value.roomNumber.trim() val input = readValidatedInput() ?: return
val roomTypeCode = state.value.roomTypeCode.trim() submitMutation(
val roomNumber = roomNumberText.toIntOrNull() action = "Update",
if (roomNumber == null || roomTypeCode.isBlank()) { onDone = onDone
_state.update { it.copy(error = "Room number must be a number and room type is required") } ) { api ->
return api.updateRoom(propertyId, roomId, input.toUpdateRequest())
}
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") }
}
} }
} }
@@ -130,20 +82,76 @@ class RoomFormViewModel : ViewModel() {
_state.update { it.copy(error = "Room ID is missing") } _state.update { it.copy(error = "Room ID is missing") }
return return
} }
viewModelScope.launch { submitMutation(
_state.update { it.copy(isLoading = true, error = null) } action = "Delete",
try { onDone = onDone
val api = ApiClient.create() ) { api ->
val response = api.deleteRoom(propertyId, roomId) 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") }
} }
} }
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 package com.android.trisolarispms.ui.room
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RoomListViewModel : ViewModel() { class RoomListViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomListState()) private val _state = MutableStateFlow(RoomListState())
@@ -14,16 +13,19 @@ class RoomListViewModel : ViewModel() {
fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) { fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) {
if (propertyId.isBlank()) return if (propertyId.isBlank()) return
viewModelScope.launch { launchRequest(
_state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) } state = _state,
try { 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 api = ApiClient.create()
val trimmedCode = roomTypeCode?.trim().orEmpty() val trimmedCode = roomTypeCode?.trim().orEmpty()
val response = if (trimmedCode.isNotBlank()) { val response = if (trimmedCode.isNotBlank()) {
api.listRoomsByType( api.listRoomsByType(
propertyId = propertyId, propertyId = propertyId,
roomTypeCode = trimmedCode, roomTypeCode = trimmedCode,
availableOnly = if (showAll) false else true availableOnly = !showAll
) )
} else if (showAll) { } else if (showAll) {
api.listRooms(propertyId) api.listRooms(propertyId)
@@ -41,9 +43,6 @@ class RoomListViewModel : ViewModel() {
} else { } else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } _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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons 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.Add
import androidx.compose.material.icons.filled.Category import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.CreditCard 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.Hotel
import androidx.compose.material.icons.filled.Layers 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -40,15 +34,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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 com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@Composable @Composable
@@ -79,15 +76,9 @@ fun RoomsScreen(
roomTypeListViewModel.load(propertyId) roomTypeListViewModel.load(propertyId)
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Available Rooms",
TopAppBar( onBack = onBack,
title = { Text("Available Rooms") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = { actions = {
if (canManageRooms) { if (canManageRooms) {
IconButton(onClick = onViewRoomTypes) { IconButton(onClick = onViewRoomTypes) {
@@ -102,26 +93,10 @@ fun RoomsScreen(
Icon(Icons.Default.Add, contentDescription = "Add Room") Icon(Icons.Default.Add, contentDescription = "Add Room")
} }
} }
},
colors = TopAppBarDefaults.topAppBarColors()
)
} }
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier LoadingAndError(isLoading = state.isLoading, error = state.error)
.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))
}
if (!state.isLoading && state.error == null) { if (!state.isLoading && state.error == null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@@ -1,80 +1,20 @@
package com.android.trisolarispms.ui.roomimage 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.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 import androidx.lifecycle.viewmodel.compose.viewModel
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddImageTagScreen( fun AddImageTagScreen(
onBack: () -> Unit, onBack: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
viewModel: ImageTagFormViewModel = viewModel() viewModel: ImageTagFormViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() ImageTagEditorScreen(
title = "Add Tag",
LaunchedEffect(Unit) { setupKey = Unit,
viewModel.reset() onBack = onBack,
} onSaveClick = { viewModel.submitCreate(onSave) },
setupForm = { viewModel.reset() },
Scaffold( viewModel = viewModel
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()
) )
}
) { 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 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.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 androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomImageTagDto import com.android.trisolarispms.data.api.model.RoomImageTagDto
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditImageTagScreen( fun EditImageTagScreen(
tag: RoomImageTagDto, tag: RoomImageTagDto,
onBack: () -> Unit, onBack: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
viewModel: ImageTagFormViewModel = viewModel() viewModel: ImageTagFormViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() ImageTagEditorScreen(
title = "Edit Tag",
LaunchedEffect(tag.id) { setupKey = tag.id,
viewModel.setTag(tag) onBack = onBack,
} onSaveClick = { viewModel.submitUpdate(tag.id.orEmpty(), onSave) },
setupForm = { viewModel.setTag(tag) },
Scaffold( viewModel = viewModel
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()
) )
}
) { 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 package com.android.trisolarispms.ui.roomimage
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import com.android.trisolarispms.core.viewmodel.launchApiMutation
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiService
import com.android.trisolarispms.data.api.model.RoomImageTagDto import com.android.trisolarispms.data.api.model.RoomImageTagDto
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import retrofit2.Response
class ImageTagFormViewModel : ViewModel() { class ImageTagFormViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagFormState()) private val _state = MutableStateFlow(ImageTagFormState())
@@ -26,48 +26,53 @@ class ImageTagFormViewModel : ViewModel() {
} }
fun submitCreate(onDone: () -> Unit) { fun submitCreate(onDone: () -> Unit) {
val name = state.value.name.trim() val payload = readValidatedPayload() ?: return
if (name.isBlank()) { submitMutation(
_state.update { it.copy(error = "Name is required") } action = "Create",
return onDone = onDone,
} resetOnSuccess = true
viewModelScope.launch { ) { api ->
_state.update { it.copy(isLoading = true, error = null) } api.createImageTag(payload)
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") }
}
} }
} }
fun submitUpdate(tagId: String, onDone: () -> Unit) { 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() val name = state.value.name.trim()
if (name.isBlank()) { if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") } _state.update { it.copy(error = "Name is required") }
return return null
}
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 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 package com.android.trisolarispms.ui.roomimage
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ImageTagViewModel : ViewModel() { class ImageTagViewModel : ViewModel() {
private val _state = MutableStateFlow(ImageTagState()) private val _state = MutableStateFlow(ImageTagState())
val state: StateFlow<ImageTagState> = _state val state: StateFlow<ImageTagState> = _state
fun load() { fun load() {
viewModelScope.launch { launchRequest(
_state.update { it.copy(isLoading = true, error = null) } state = _state,
try { setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Load failed"
) {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.listImageTags() val response = api.listImageTags()
if (response.isSuccessful) { if (response.isSuccessful) {
@@ -29,17 +31,17 @@ class ImageTagViewModel : ViewModel() {
} else { } else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } _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) { fun delete(tagId: String) {
if (tagId.isBlank()) return if (tagId.isBlank()) return
viewModelScope.launch { launchRequest(
_state.update { it.copy(isLoading = true, error = null) } state = _state,
try { setLoading = { it.copy(isLoading = true, error = null) },
setError = { current, message -> current.copy(isLoading = false, error = message) },
defaultError = "Delete failed"
) {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.deleteImageTag(tagId) val response = api.deleteImageTag(tagId)
if (response.isSuccessful) { if (response.isSuccessful) {
@@ -53,9 +55,6 @@ class ImageTagViewModel : ViewModel() {
} else { } 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") }
}
} }
} }
} }

View File

@@ -2,27 +2,18 @@ package com.android.trisolarispms.ui.roomimage
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -31,9 +22,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomImageTagDto 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 @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ImageTagsScreen( fun ImageTagsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
@@ -46,39 +39,20 @@ fun ImageTagsScreen(
viewModel.load() viewModel.load()
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Image Tags",
TopAppBar( onBack = onBack,
title = { Text("Image Tags") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = { actions = {
IconButton(onClick = onAdd) { IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Tag") Icon(Icons.Default.Add, contentDescription = "Add Tag")
} }
},
colors = TopAppBarDefaults.topAppBarColors()
)
} }
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier LoadingAndError(
.fillMaxSize() isLoading = state.isLoading,
.padding(padding) error = state.error
.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))
}
if (!state.isLoading && state.error == null) { if (!state.isLoading && state.error == null) {
if (state.tags.isEmpty()) { if (state.tags.isEmpty()) {
Text(text = "No tags") 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -42,12 +35,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun RoomImagesScreen( fun RoomImagesScreen(
propertyId: String, propertyId: String,
roomId: String, roomId: String,
@@ -86,15 +81,9 @@ fun RoomImagesScreen(
originalOrderIds.value = state.images.mapNotNull { it.id } originalOrderIds.value = state.images.mapNotNull { it.id }
} }
Scaffold( BackTopBarScaffold(
topBar = { title = "Room Images",
TopAppBar( onBack = onBack,
title = { Text("Room Images") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
pickerLauncher.launch( pickerLauncher.launch(
@@ -103,27 +92,10 @@ fun RoomImagesScreen(
}) { }) {
Icon(Icons.Default.UploadFile, contentDescription = "Pick Image") Icon(Icons.Default.UploadFile, contentDescription = "Pick Image")
} }
},
colors = TopAppBarDefaults.topAppBarColors()
)
} }
) { padding -> ) { padding ->
Column( PaddedScreenColumn(padding = padding) {
modifier = Modifier LoadingAndError(isLoading = state.isLoading, error = state.error)
.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))
}
Text(text = "Upload", style = MaterialTheme.typography.titleMedium) Text(text = "Upload", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

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

View File

@@ -7,5 +7,7 @@ data class ActiveRoomStaysState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val items: List<ActiveRoomStayDto> = emptyList(), 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