Compare commits
22 Commits
8c790fbce0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69a01a460 | ||
|
|
1000f2411c | ||
|
|
e9c3b4f669 | ||
|
|
90c2b6fb9f | ||
|
|
1e5f412f82 | ||
|
|
a67eacd77f | ||
|
|
f9b09e2376 | ||
|
|
e1250a0f32 | ||
|
|
d69ed60a6e | ||
|
|
56f13f5e79 | ||
|
|
9555ae2e40 | ||
|
|
9d942d6411 | ||
|
|
3a90aa848d | ||
|
|
eab5517f9b | ||
|
|
b0c28d0aa4 | ||
|
|
dcaaba92dd | ||
|
|
52a6d379b0 | ||
|
|
4e5f368256 | ||
|
|
d6c8e522de | ||
|
|
18c5cb814d | ||
|
|
a691e84fd8 | ||
|
|
d54a9af5ee |
17
AGENTS.md
17
AGENTS.md
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.android.trisolarispms.data.api.model
|
||||||
|
|
||||||
|
data class CitySearchItemDto(
|
||||||
|
val city: String? = null,
|
||||||
|
val state: String? = null
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>>
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.android.trisolarispms.data.local.booking
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface BookingDetailsCacheDao {
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM booking_details_cache
|
||||||
|
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun observe(propertyId: String, bookingId: String): Flow<BookingDetailsCacheEntity?>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(entity: BookingDetailsCacheEntity)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM booking_details_cache
|
||||||
|
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun delete(propertyId: String, bookingId: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.android.trisolarispms.data.local.booking
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "booking_details_cache",
|
||||||
|
primaryKeys = ["propertyId", "bookingId"]
|
||||||
|
)
|
||||||
|
data class BookingDetailsCacheEntity(
|
||||||
|
val propertyId: String,
|
||||||
|
val bookingId: String,
|
||||||
|
val detailsId: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
val guestId: String? = null,
|
||||||
|
val guestName: String? = null,
|
||||||
|
val guestPhone: String? = null,
|
||||||
|
val guestNationality: String? = null,
|
||||||
|
val guestAge: String? = null,
|
||||||
|
val guestAddressText: String? = null,
|
||||||
|
val guestSignatureUrl: String? = null,
|
||||||
|
val vehicleNumbers: List<String> = emptyList(),
|
||||||
|
val roomNumbers: List<Int> = emptyList(),
|
||||||
|
val source: String? = null,
|
||||||
|
val fromCity: String? = null,
|
||||||
|
val toCity: String? = null,
|
||||||
|
val memberRelation: String? = null,
|
||||||
|
val transportMode: String? = null,
|
||||||
|
val expectedCheckInAt: String? = null,
|
||||||
|
val expectedCheckOutAt: String? = null,
|
||||||
|
val checkInAt: String? = null,
|
||||||
|
val checkOutAt: String? = null,
|
||||||
|
val adultCount: Int? = null,
|
||||||
|
val maleCount: Int? = null,
|
||||||
|
val femaleCount: Int? = null,
|
||||||
|
val childCount: Int? = null,
|
||||||
|
val totalGuestCount: Int? = null,
|
||||||
|
val expectedGuestCount: Int? = null,
|
||||||
|
val totalNightlyRate: Long? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val registeredByName: String? = null,
|
||||||
|
val registeredByPhone: String? = null,
|
||||||
|
val expectedPay: Long? = null,
|
||||||
|
val amountCollected: Long? = null,
|
||||||
|
val pending: Long? = null,
|
||||||
|
val billableNights: Long? = null,
|
||||||
|
val billingMode: String? = null,
|
||||||
|
val billingCheckinTime: String? = null,
|
||||||
|
val billingCheckoutTime: String? = null,
|
||||||
|
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun BookingDetailsResponse.toCacheEntity(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String
|
||||||
|
): BookingDetailsCacheEntity = BookingDetailsCacheEntity(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
detailsId = id,
|
||||||
|
status = status,
|
||||||
|
guestId = guestId,
|
||||||
|
guestName = guestName,
|
||||||
|
guestPhone = guestPhone,
|
||||||
|
guestNationality = guestNationality,
|
||||||
|
guestAge = guestAge,
|
||||||
|
guestAddressText = guestAddressText,
|
||||||
|
guestSignatureUrl = guestSignatureUrl,
|
||||||
|
vehicleNumbers = vehicleNumbers,
|
||||||
|
roomNumbers = roomNumbers,
|
||||||
|
source = source,
|
||||||
|
fromCity = fromCity,
|
||||||
|
toCity = toCity,
|
||||||
|
memberRelation = memberRelation,
|
||||||
|
transportMode = transportMode,
|
||||||
|
expectedCheckInAt = expectedCheckInAt,
|
||||||
|
expectedCheckOutAt = expectedCheckOutAt,
|
||||||
|
checkInAt = checkInAt,
|
||||||
|
checkOutAt = checkOutAt,
|
||||||
|
adultCount = adultCount,
|
||||||
|
maleCount = maleCount,
|
||||||
|
femaleCount = femaleCount,
|
||||||
|
childCount = childCount,
|
||||||
|
totalGuestCount = totalGuestCount,
|
||||||
|
expectedGuestCount = expectedGuestCount,
|
||||||
|
totalNightlyRate = totalNightlyRate,
|
||||||
|
notes = notes,
|
||||||
|
registeredByName = registeredByName,
|
||||||
|
registeredByPhone = registeredByPhone,
|
||||||
|
expectedPay = expectedPay,
|
||||||
|
amountCollected = amountCollected,
|
||||||
|
pending = pending,
|
||||||
|
billableNights = billableNights,
|
||||||
|
billingMode = billingMode,
|
||||||
|
billingCheckinTime = billingCheckinTime,
|
||||||
|
billingCheckoutTime = billingCheckoutTime
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun BookingDetailsCacheEntity.toApiModel(): BookingDetailsResponse = BookingDetailsResponse(
|
||||||
|
id = detailsId ?: bookingId,
|
||||||
|
status = status,
|
||||||
|
guestId = guestId,
|
||||||
|
guestName = guestName,
|
||||||
|
guestPhone = guestPhone,
|
||||||
|
guestNationality = guestNationality,
|
||||||
|
guestAge = guestAge,
|
||||||
|
guestAddressText = guestAddressText,
|
||||||
|
guestSignatureUrl = guestSignatureUrl,
|
||||||
|
vehicleNumbers = vehicleNumbers,
|
||||||
|
roomNumbers = roomNumbers,
|
||||||
|
source = source,
|
||||||
|
fromCity = fromCity,
|
||||||
|
toCity = toCity,
|
||||||
|
memberRelation = memberRelation,
|
||||||
|
transportMode = transportMode,
|
||||||
|
expectedCheckInAt = expectedCheckInAt,
|
||||||
|
expectedCheckOutAt = expectedCheckOutAt,
|
||||||
|
checkInAt = checkInAt,
|
||||||
|
checkOutAt = checkOutAt,
|
||||||
|
adultCount = adultCount,
|
||||||
|
maleCount = maleCount,
|
||||||
|
femaleCount = femaleCount,
|
||||||
|
childCount = childCount,
|
||||||
|
totalGuestCount = totalGuestCount,
|
||||||
|
expectedGuestCount = expectedGuestCount,
|
||||||
|
totalNightlyRate = totalNightlyRate,
|
||||||
|
notes = notes,
|
||||||
|
registeredByName = registeredByName,
|
||||||
|
registeredByPhone = registeredByPhone,
|
||||||
|
expectedPay = expectedPay,
|
||||||
|
amountCollected = amountCollected,
|
||||||
|
pending = pending,
|
||||||
|
billableNights = billableNights,
|
||||||
|
billingMode = billingMode,
|
||||||
|
billingCheckinTime = billingCheckinTime,
|
||||||
|
billingCheckoutTime = billingCheckoutTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.android.trisolarispms.data.local.booking
|
||||||
|
|
||||||
|
import com.android.trisolarispms.data.api.core.ApiClient
|
||||||
|
import com.android.trisolarispms.data.api.core.ApiService
|
||||||
|
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||||
|
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class BookingDetailsRepository(
|
||||||
|
private val dao: BookingDetailsCacheDao,
|
||||||
|
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||||
|
) {
|
||||||
|
fun observeBookingDetails(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String
|
||||||
|
): Flow<BookingDetailsResponse?> =
|
||||||
|
dao.observe(propertyId = propertyId, bookingId = bookingId).map { cached ->
|
||||||
|
cached?.toApiModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshBookingDetails(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String
|
||||||
|
): Result<Unit> = runCatching {
|
||||||
|
val response = createApi().getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IllegalStateException("Load failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
val body = response.body() ?: throw IllegalStateException("Load failed: empty response")
|
||||||
|
dao.upsert(body.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateExpectedDates(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String,
|
||||||
|
body: BookingExpectedDatesRequest
|
||||||
|
): Result<Unit> = runCatching {
|
||||||
|
val api = createApi()
|
||||||
|
val response = api.updateExpectedDates(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
body = body
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IllegalStateException("Update failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
refreshBookingDetails(propertyId = propertyId, bookingId = bookingId).getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun storeSnapshot(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String,
|
||||||
|
details: BookingDetailsResponse
|
||||||
|
) {
|
||||||
|
dao.upsert(details.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.android.trisolarispms.data.local.core
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
class RoomConverters {
|
||||||
|
private val gson = Gson()
|
||||||
|
private val stringListType = object : TypeToken<List<String>>() {}.type
|
||||||
|
private val intListType = object : TypeToken<List<Int>>() {}.type
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromStringList(value: List<String>?): String = gson.toJson(value.orEmpty())
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toStringList(value: String?): List<String> {
|
||||||
|
if (value.isNullOrBlank()) return emptyList()
|
||||||
|
return runCatching { gson.fromJson<List<String>>(value, stringListType) }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun fromIntList(value: List<Int>?): String = gson.toJson(value.orEmpty())
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toIntList(value: String?): List<Int> {
|
||||||
|
if (value.isNullOrBlank()) return emptyList()
|
||||||
|
return runCatching { gson.fromJson<List<Int>>(value, intListType) }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.android.trisolarispms.data.local.roomstay
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ActiveRoomStayCacheDao {
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM active_room_stay_cache
|
||||||
|
WHERE propertyId = :propertyId
|
||||||
|
ORDER BY roomNumber ASC, roomStayId ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayCacheEntity>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM active_room_stay_cache
|
||||||
|
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||||
|
ORDER BY roomNumber ASC, roomStayId ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayCacheEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(items: List<ActiveRoomStayCacheEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId")
|
||||||
|
suspend fun deleteByProperty(propertyId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId AND roomStayId = :roomStayId")
|
||||||
|
suspend fun deleteByRoomStay(propertyId: String, roomStayId: String)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE active_room_stay_cache
|
||||||
|
SET expectedCheckoutAt = :expectedCheckoutAt,
|
||||||
|
updatedAtEpochMs = :updatedAtEpochMs
|
||||||
|
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun updateExpectedCheckoutAtForBooking(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String,
|
||||||
|
expectedCheckoutAt: String?,
|
||||||
|
updatedAtEpochMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun replaceForProperty(propertyId: String, items: List<ActiveRoomStayCacheEntity>) {
|
||||||
|
deleteByProperty(propertyId)
|
||||||
|
if (items.isNotEmpty()) {
|
||||||
|
upsertAll(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.android.trisolarispms.data.local.roomstay
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "active_room_stay_cache",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["propertyId"]),
|
||||||
|
Index(value = ["propertyId", "bookingId"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class ActiveRoomStayCacheEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val roomStayId: String,
|
||||||
|
val propertyId: String,
|
||||||
|
val bookingId: String? = null,
|
||||||
|
val guestId: String? = null,
|
||||||
|
val guestName: String? = null,
|
||||||
|
val guestPhone: String? = null,
|
||||||
|
val roomId: String? = null,
|
||||||
|
val roomNumber: Int? = null,
|
||||||
|
val roomTypeCode: String? = null,
|
||||||
|
val roomTypeName: String? = null,
|
||||||
|
val fromAt: String? = null,
|
||||||
|
val checkinAt: String? = null,
|
||||||
|
val expectedCheckoutAt: String? = null,
|
||||||
|
val nightlyRate: Long? = null,
|
||||||
|
val currency: String? = null,
|
||||||
|
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ActiveRoomStayDto.toCacheEntity(propertyId: String): ActiveRoomStayCacheEntity? {
|
||||||
|
val stayId = roomStayId?.trim()?.ifBlank { null } ?: return null
|
||||||
|
return ActiveRoomStayCacheEntity(
|
||||||
|
roomStayId = stayId,
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
guestId = guestId,
|
||||||
|
guestName = guestName,
|
||||||
|
guestPhone = guestPhone,
|
||||||
|
roomId = roomId,
|
||||||
|
roomNumber = roomNumber,
|
||||||
|
roomTypeCode = roomTypeCode,
|
||||||
|
roomTypeName = roomTypeName,
|
||||||
|
fromAt = fromAt,
|
||||||
|
checkinAt = checkinAt,
|
||||||
|
expectedCheckoutAt = expectedCheckoutAt,
|
||||||
|
nightlyRate = nightlyRate,
|
||||||
|
currency = currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ActiveRoomStayCacheEntity.toApiModel(): ActiveRoomStayDto = ActiveRoomStayDto(
|
||||||
|
roomStayId = roomStayId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
guestId = guestId,
|
||||||
|
guestName = guestName,
|
||||||
|
guestPhone = guestPhone,
|
||||||
|
roomId = roomId,
|
||||||
|
roomNumber = roomNumber,
|
||||||
|
roomTypeCode = roomTypeCode,
|
||||||
|
roomTypeName = roomTypeName,
|
||||||
|
fromAt = fromAt,
|
||||||
|
checkinAt = checkinAt,
|
||||||
|
expectedCheckoutAt = expectedCheckoutAt,
|
||||||
|
nightlyRate = nightlyRate,
|
||||||
|
currency = currency
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.android.trisolarispms.data.local.roomstay
|
||||||
|
|
||||||
|
import com.android.trisolarispms.data.api.core.ApiClient
|
||||||
|
import com.android.trisolarispms.data.api.core.ApiService
|
||||||
|
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class ActiveRoomStayRepository(
|
||||||
|
private val dao: ActiveRoomStayCacheDao,
|
||||||
|
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||||
|
) {
|
||||||
|
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayDto>> =
|
||||||
|
dao.observeByProperty(propertyId = propertyId).map { rows ->
|
||||||
|
rows.map { it.toApiModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayDto>> =
|
||||||
|
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||||
|
rows.map { it.toApiModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refresh(propertyId: String): Result<Unit> = runCatching {
|
||||||
|
val response = createApi().listActiveRoomStays(propertyId = propertyId)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IllegalStateException("Load failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
val rows = response.body().orEmpty().mapNotNull { it.toCacheEntity(propertyId = propertyId) }
|
||||||
|
dao.replaceForProperty(propertyId = propertyId, items = rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeFromCache(propertyId: String, roomStayId: String) {
|
||||||
|
dao.deleteByRoomStay(propertyId = propertyId, roomStayId = roomStayId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun patchExpectedCheckoutForBooking(
|
||||||
|
propertyId: String,
|
||||||
|
bookingId: String,
|
||||||
|
expectedCheckoutAt: String?
|
||||||
|
) {
|
||||||
|
dao.updateExpectedCheckoutAtForBooking(
|
||||||
|
propertyId = propertyId,
|
||||||
|
bookingId = bookingId,
|
||||||
|
expectedCheckoutAt = expectedCheckoutAt,
|
||||||
|
updatedAtEpochMs = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user