Compare commits

..

36 Commits

Author SHA1 Message Date
androidlover5842
f69a01a460 ai added more room db stuff 2026-02-08 19:54:35 +05:30
androidlover5842
1000f2411c add room db 2026-02-08 19:21:07 +05:30
androidlover5842
e9c3b4f669 booking: ability to edit more guest info 2026-02-07 22:33:43 +05:30
androidlover5842
90c2b6fb9f roomStays: show rates 2026-02-05 13:05:43 +05:30
androidlover5842
1e5f412f82 ablity to checkout stay 2026-02-05 10:31:15 +05:30
androidlover5842
a67eacd77f createbooking: improve logic for future bookings 2026-02-04 17:55:35 +05:30
androidlover5842
f9b09e2376 ability to cancel future bookings 2026-02-04 16:45:15 +05:30
androidlover5842
e1250a0f32 stay: improve checkin and checkout time editor 2026-02-04 16:28:50 +05:30
androidlover5842
d69ed60a6e agents -_- 2026-02-04 15:32:44 +05:30
androidlover5842
56f13f5e79 ability to see open bookings list 2026-02-04 15:20:17 +05:30
androidlover5842
9555ae2e40 activeScreen:improve menu 2026-02-04 15:07:27 +05:30
androidlover5842
9d942d6411 createBooking: change checkout date based on property policy while editing checking date 2026-02-04 14:58:16 +05:30
androidlover5842
3a90aa848d billing: add billable nights view and improve payment ledger cash handle logic 2026-02-04 14:12:19 +05:30
androidlover5842
eab5517f9b extend checkout time improve ui 2026-02-04 13:41:06 +05:30
androidlover5842
b0c28d0aa4 add missing screen 2026-02-03 10:21:07 +05:30
androidlover5842
dcaaba92dd AI:remove more boilerplate 2026-02-03 10:20:48 +05:30
androidlover5842
52a6d379b0 AI:remove more boilerplate 2026-02-03 09:55:40 +05:30
androidlover5842
4e5f368256 AI:remove more boilerplate 2026-02-03 09:38:30 +05:30
androidlover5842
d6c8e522de AI:remove boilerplate 2026-02-03 09:28:23 +05:30
androidlover5842
18c5cb814d policy:time related 2026-02-02 11:35:30 +05:30
androidlover5842
a691e84fd8 ai removed boilerplate and orgnized code even more 2026-02-02 07:00:56 +05:30
androidlover5842
d54a9af5ee improve auth screen login 2026-02-02 06:29:49 +05:30
androidlover5842
8c790fbce0 improve auth policy 2026-02-02 06:21:18 +05:30
androidlover5842
f97834291d manage packages 2026-02-02 06:10:34 +05:30
androidlover5842
0f0db0dcf5 mainscreen: split codes 2026-02-02 06:02:38 +05:30
androidlover5842
99ce18a435 remove auth boilerplate 2026-02-02 05:39:20 +05:30
androidlover5842
342ff6a237 roles: fix permissions 2026-02-02 05:27:06 +05:30
androidlover5842
86307a66c8 users and permission manage 2026-02-01 23:33:15 +05:30
androidlover5842
3219e40a02 payments: ability to refund 2026-02-01 16:16:44 +05:30
androidlover5842
4c31a20af4 razorpay: add payment link support 2026-02-01 14:37:05 +05:30
androidlover5842
8f62459d5e fix razorpay qr credit log 2026-02-01 14:03:19 +05:30
androidlover5842
43ee7311e8 remove that crap payu 2026-02-01 13:27:55 +05:30
androidlover5842
2b0f352ced guestInfo: improve ui 2026-02-01 02:07:52 +05:30
androidlover5842
53300a6a84 guest docs : sse 2026-01-31 00:59:00 +05:30
androidlover5842
9ac0b55b89 show guest docs images 2026-01-31 00:16:12 +05:30
androidlover5842
4fc080f146 admins can delete cash payments 2026-01-30 11:40:49 +05:30
194 changed files with 12743 additions and 4925 deletions

View File

@@ -1,5 +1,9 @@
# TrisolarisPMS API Usage # TrisolarisPMS API Usage
## API Docs Path
- `/home/androidlover5842/IdeaProjects/TrisolarisServer/docs`
## 1) Booking ## 1) Booking
### Create booking ### Create booking
@@ -527,3 +531,72 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
## 7) Compose Notes ## 7) Compose Notes
- Use `androidx.compose.foundation.text.KeyboardOptions` for keyboard options imports. - Use `androidx.compose.foundation.text.KeyboardOptions` for keyboard options imports.
---
## 8) Engineering Structure & Anti-Boilerplate Rules
### Non-negotiable coding rules
- Duplicate code is forbidden.
- Never add duplicate business logic in multiple files.
- Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns.
- If similar logic appears 2+ times, extract shared function/class immediately.
- Prefer typed models/enums over raw strings for roles/status/flags.
- Keep files small and purpose-driven; split before a file becomes hard to scan.
### Required project structure (current baseline)
- `core/` -> cross-cutting business primitives/policies (e.g., auth policy, role enum).
- `core/viewmodel/` -> shared ViewModel execution helpers (loading/error wrappers, common request runners).
- `data/api/core/` -> API client, constants, token providers, aggregated API service.
- `data/api/service/` -> Retrofit endpoint interfaces only.
- `data/api/model/` -> DTO/request/response models.
- `ui/navigation/` -> route model, navigation orchestrators, back-navigation rules.
- `ui/<feature>/` -> screen + state + viewmodel for that feature.
### How to implement future logic (mandatory workflow)
1. Define/extend domain type first (enum/data model/policy) instead of raw literals.
2. Add/extend API contract in `data/api/service` and models in `data/api/model`.
3. Add shared logic once (policy/helper/mapper) in `core` or feature-common layer.
4. Keep ViewModel thin: orchestrate calls, state, and errors only.
5. Keep UI dumb: consume state and callbacks; avoid business rules in composables.
6. If navigation changes, update `ui/navigation` only (single source of truth).
7. Before finishing, remove any newly introduced duplication and compile-check.
8. If 2+ ViewModels repeat loading/error coroutine flow, extract/use shared helper in `core/viewmodel`.
9. If Add/Edit screens differ only by initialization + submit callback, extract a feature-local shared form screen.
10. Prefer dedupe/organization improvements even if net LOC does not decrease, as long as behavior remains unchanged.
### PR/refactor acceptance checklist
- No repeated role/permission checks across screens.
- No repeated model mapping blocks (extract mapper/helper).
- No giant god-file when it can be split by domain responsibility.
- Imports/packages follow the structure above.
- Build passes: `./gradlew :app:compileDebugKotlin`.
### Room DB synchronization rule (mandatory)
- For any editable API-backed field, follow this write path only: `UI action -> server request -> Room DB update -> UI reacts from Room observers`.
- Server is source of truth; do not bypass server by writing final business state directly from UI.
- UI must render from Room-backed state, not from one-off API responses or direct text mutation.
- Avoid forced full refresh after each mutation unless strictly required by backend limitations; prefer targeted Room updates for linked entities.
- On mutation failure, keep prior DB state unchanged and surface error state to UI.
### Guest Documents Authorization (mandatory)
- View access: `ADMIN`, `MANAGER` (and super admin).
- Modify access (upload/delete): allowed only when booking status is `OPEN` or `CHECKED_IN`.
- For `CHECKED_OUT`, `CANCELLED`, `NO_SHOW`: documents are read-only.
- Never couple guest document permissions with Razorpay/settings permissions.
### Permission design guardrail
- Do not reuse one feature's permission gate for another unrelated feature.
- Add explicit policy methods in `core/auth/AuthzPolicy` for each feature capability.
### Refactor safety rule
- Any package/file movement must include import updates in same change.
- After refactor, compile check is mandatory: `./gradlew :app:compileDebugKotlin`.

View File

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

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature <uses-feature

View File

@@ -3,48 +3,15 @@ package com.android.trisolarispms
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.compose.BackHandler
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
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.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.AppRoute
import com.android.trisolarispms.ui.auth.AuthScreen import com.android.trisolarispms.ui.auth.AuthScreen
import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.auth.NameScreen import com.android.trisolarispms.ui.auth.NameScreen
import com.android.trisolarispms.ui.auth.UnauthorizedScreen import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.booking.BookingCreateScreen import com.android.trisolarispms.ui.navigation.MainRouteContent
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.booking.BookingExpectedDatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen
import com.android.trisolarispms.ui.room.RoomsScreen
import com.android.trisolarispms.ui.card.IssueTemporaryCardScreen
import com.android.trisolarispms.ui.card.CardInfoScreen
import com.android.trisolarispms.ui.roomimage.RoomImagesScreen
import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
import com.android.trisolarispms.ui.roomimage.EditImageTagScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.payu.PayuSettingsScreen
import com.android.trisolarispms.ui.payu.PayuQrScreen
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
import com.android.trisolarispms.ui.roomtype.EditAmenityScreen
import com.android.trisolarispms.ui.roomtype.EditRoomTypeScreen
import com.android.trisolarispms.ui.roomtype.RatePlanCalendarScreen
import com.android.trisolarispms.ui.roomtype.RoomTypesScreen
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -56,585 +23,17 @@ class MainActivity : ComponentActivity() {
val authViewModel: AuthViewModel = viewModel() val authViewModel: AuthViewModel = viewModel()
val state by authViewModel.state.collectAsState() val state by authViewModel.state.collectAsState()
if (state.unauthorized) { when {
UnauthorizedScreen( state.unauthorized -> UnauthorizedScreen(
message = state.error ?: "Not authorized. Contact admin.", message = state.error ?: "Not authorized. Contact admin.",
onSignOut = authViewModel::signOut onSignOut = authViewModel::signOut
) )
} else if (state.apiVerified && state.needsName) { state.apiVerified && state.needsName -> NameScreen(viewModel = authViewModel)
NameScreen(viewModel = authViewModel) state.apiVerified -> MainRouteContent(
} else if (state.apiVerified) { state = state,
val route = remember { mutableStateOf<AppRoute>(AppRoute.Home) } authViewModel = authViewModel
val refreshKey = remember { mutableStateOf(0) } )
val selectedPropertyId = remember { mutableStateOf<String?>(null) } else -> AuthScreen(viewModel = authViewModel)
val selectedPropertyName = remember { mutableStateOf<String?>(null) }
val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(null) }
val selectedAmenity = remember { mutableStateOf<com.android.trisolarispms.data.api.model.AmenityDto?>(null) }
val selectedGuest = remember { mutableStateOf<com.android.trisolarispms.data.api.model.GuestDto?>(null) }
val selectedGuestPhone = remember { mutableStateOf<String?>(null) }
val selectedImageTag = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomImageTagDto?>(null) }
val selectedManageRooms = remember { mutableStateOf<List<ManageRoomStaySelection>>(emptyList()) }
val roomFormKey = remember { mutableStateOf(0) }
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
val currentRoute = route.value
val canManageProperty: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true)
}
val canViewCardInfo: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER" || it == "STAFF"
} == true
}
val canManagePayuSettings: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER"
} == true
}
BackHandler(enabled = currentRoute != AppRoute.Home) {
when (currentRoute) {
AppRoute.Home -> Unit
AppRoute.AddProperty -> route.value = AppRoute.Home
is AppRoute.ActiveRoomStays -> route.value = AppRoute.Home
is AppRoute.Rooms -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.AddRoom -> route.value = AppRoute.Rooms(currentRoute.propertyId)
is AppRoute.EditRoom -> route.value = AppRoute.Rooms(currentRoute.propertyId)
is AppRoute.RoomTypes -> route.value = AppRoute.Rooms(currentRoute.propertyId)
is AppRoute.AddRoomType -> route.value = AppRoute.RoomTypes(currentRoute.propertyId)
is AppRoute.EditRoomType -> route.value = AppRoute.RoomTypes(currentRoute.propertyId)
AppRoute.Amenities -> route.value = amenitiesReturnRoute.value
AppRoute.AddAmenity -> route.value = AppRoute.Amenities
is AppRoute.EditAmenity -> route.value = AppRoute.Amenities
AppRoute.ImageTags -> route.value = AppRoute.Home
AppRoute.AddImageTag -> route.value = AppRoute.ImageTags
is AppRoute.EditImageTag -> route.value = AppRoute.ImageTags
is AppRoute.RoomImages -> route.value = AppRoute.EditRoom(
currentRoute.propertyId,
currentRoute.roomId
)
is AppRoute.IssueTemporaryCard -> route.value = AppRoute.Rooms(currentRoute.propertyId)
is AppRoute.CardInfo -> route.value = AppRoute.Rooms(currentRoute.propertyId)
is AppRoute.RatePlanCalendar -> route.value = AppRoute.EditRoomType(
currentRoute.propertyId,
currentRoute.roomTypeId
)
is AppRoute.PayuSettings -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.PayuQr -> route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.GuestInfo -> route.value = AppRoute.Home
is AppRoute.GuestSignature -> route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
is AppRoute.ManageRoomStaySelect -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.ManageRoomStayRates -> route.value = AppRoute.ManageRoomStaySelect(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.fromAt,
currentRoute.toAt
)
is AppRoute.ManageRoomStaySelectFromBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.ManageRoomStayRatesFromBooking -> route.value = AppRoute.ManageRoomStaySelectFromBooking(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId,
currentRoute.fromAt,
currentRoute.toAt
)
is AppRoute.BookingRoomStays -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.BookingExpectedDates -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.BookingDetailsTabs -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.BookingPayments -> route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
}
}
when (currentRoute) {
AppRoute.Home -> HomeScreen(
userId = state.userId,
userName = state.userName,
isSuperAdmin = state.isSuperAdmin,
onAddProperty = { route.value = AppRoute.AddProperty },
onAmenities = {
amenitiesReturnRoute.value = AppRoute.Home
route.value = AppRoute.Amenities
},
onImageTags = { route.value = AppRoute.ImageTags },
refreshKey = refreshKey.value,
selectedPropertyId = selectedPropertyId.value,
onSelectProperty = { id, name ->
selectedPropertyId.value = id
selectedPropertyName.value = name
route.value = AppRoute.ActiveRoomStays(id, name)
},
onRefreshProfile = authViewModel::refreshMe
)
AppRoute.AddProperty -> AddPropertyScreen(
onBack = { route.value = AppRoute.Home },
onCreated = {
refreshKey.value++
route.value = AppRoute.Home
}
)
is AppRoute.CreateBooking -> BookingCreateScreen(
propertyId = currentRoute.propertyId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onCreated = { response, guest, phone ->
val bookingId = response.id.orEmpty()
val guestId = (guest?.id ?: response.guestId).orEmpty()
selectedGuest.value = guest
selectedGuestPhone.value = phone
if (bookingId.isNotBlank() && guestId.isNotBlank()) {
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
?: response.expectedCheckInAt.orEmpty()
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
route.value = AppRoute.ManageRoomStaySelectFromBooking(
propertyId = currentRoute.propertyId,
bookingId = bookingId,
guestId = guestId,
fromAt = fromAt,
toAt = toAt
)
} else {
route.value = AppRoute.Home
}
}
)
is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId,
guestId = currentRoute.guestId,
initialGuest = selectedGuest.value,
initialPhone = selectedGuestPhone.value,
onBack = { route.value = AppRoute.Home },
onSave = {
route.value = AppRoute.GuestSignature(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.GuestSignature -> GuestSignatureScreen(
propertyId = currentRoute.propertyId,
guestId = currentRoute.guestId,
onBack = {
route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
propertyId = currentRoute.propertyId,
propertyName = currentRoute.propertyName,
onBack = { route.value = AppRoute.Home },
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
showPayuSettings = canManagePayuSettings(currentRoute.propertyId),
onPayuSettings = { route.value = AppRoute.PayuSettings(currentRoute.propertyId) },
onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty()
val toAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() }
?: booking.checkOutAt?.takeIf { it.isNotBlank() }
if (fromAt.isNotBlank()) {
route.value = AppRoute.ManageRoomStaySelect(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
fromAt = fromAt,
toAt = toAt
)
}
},
onViewBookingStays = { booking ->
route.value = AppRoute.BookingRoomStays(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty()
)
},
onOpenBookingDetails = { booking ->
route.value = AppRoute.BookingDetailsTabs(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
guestId = booking.guestId
)
}
)
is AppRoute.PayuSettings -> PayuSettingsScreen(
propertyId = currentRoute.propertyId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.PayuQr -> PayuQrScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
pendingAmount = currentRoute.pendingAmount,
guestPhone = currentRoute.guestPhone,
onBack = {
route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onNext = { rooms ->
selectedManageRooms.value = rooms
route.value = AppRoute.ManageRoomStayRates(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
)
}
)
is AppRoute.ManageRoomStaySelectFromBooking -> ManageRoomStaySelectScreen(
propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onNext = { rooms ->
selectedManageRooms.value = rooms
route.value = AppRoute.ManageRoomStayRatesFromBooking(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
)
}
)
is AppRoute.ManageRoomStayRates -> ManageRoomStayRatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt,
selectedRooms = selectedManageRooms.value,
onBack = {
route.value = AppRoute.ManageRoomStaySelect(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.fromAt,
currentRoute.toAt
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.ManageRoomStayRatesFromBooking -> ManageRoomStayRatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt,
selectedRooms = selectedManageRooms.value,
onBack = {
route.value = AppRoute.ManageRoomStaySelectFromBooking(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId,
currentRoute.fromAt,
currentRoute.toAt
)
},
onDone = {
route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.BookingRoomStays -> BookingRoomStaysScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.BookingExpectedDates -> BookingExpectedDatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = currentRoute.status,
expectedCheckInAt = currentRoute.expectedCheckInAt,
expectedCheckOutAt = currentRoute.expectedCheckOutAt,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onEditCheckout = { expectedCheckInAt, expectedCheckOutAt ->
route.value = AppRoute.BookingExpectedDates(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = "CHECKED_IN",
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt
)
},
onEditSignature = { guestId ->
route.value = AppRoute.GuestSignature(
currentRoute.propertyId,
currentRoute.bookingId,
guestId
)
},
onOpenPayuQr = { pendingAmount, guestPhone ->
route.value = AppRoute.PayuQr(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
pendingAmount = pendingAmount,
guestPhone = guestPhone
)
},
onOpenPayments = {
route.value = AppRoute.BookingPayments(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId
)
}
)
is AppRoute.BookingPayments -> BookingPaymentsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
canAddCash = canManagePayuSettings(currentRoute.propertyId),
onBack = {
route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
}
)
is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onAddRoom = {
roomFormKey.value++
route.value = AppRoute.AddRoom(currentRoute.propertyId)
},
onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) },
canManageRooms = canManageProperty(currentRoute.propertyId),
canViewCardInfo = canViewCardInfo(currentRoute.propertyId),
onEditRoom = {
selectedRoom.value = it
roomFormKey.value++
route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "")
},
onIssueTemporaryCard = {
if (it.id != null) {
selectedRoom.value = it
route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id)
}
}
)
is AppRoute.RoomTypes -> RoomTypesScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddRoomType(currentRoute.propertyId) },
canManageRoomTypes = canManageProperty(currentRoute.propertyId),
onEdit = {
selectedRoomType.value = it
route.value = AppRoute.EditRoomType(currentRoute.propertyId, it.id ?: "")
}
)
is AppRoute.AddRoomType -> AddRoomTypeScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }
)
is AppRoute.EditRoomType -> EditRoomTypeScreen(
propertyId = currentRoute.propertyId,
roomType = selectedRoomType.value
?: com.android.trisolarispms.data.api.model.RoomTypeDto(id = currentRoute.roomTypeId, code = "", name = ""),
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onOpenRatePlanCalendar = { ratePlanId, ratePlanCode ->
route.value = AppRoute.RatePlanCalendar(
currentRoute.propertyId,
currentRoute.roomTypeId,
ratePlanId,
ratePlanCode
)
}
)
AppRoute.Amenities -> AmenitiesScreen(
onBack = { route.value = amenitiesReturnRoute.value },
onAdd = { route.value = AppRoute.AddAmenity },
canManageAmenities = state.isSuperAdmin,
onEdit = {
selectedAmenity.value = it
route.value = AppRoute.EditAmenity(it.id ?: "")
}
)
AppRoute.AddAmenity -> AddAmenityScreen(
onBack = { route.value = AppRoute.Amenities },
onSave = { route.value = AppRoute.Amenities }
)
is AppRoute.EditAmenity -> EditAmenityScreen(
amenity = selectedAmenity.value
?: com.android.trisolarispms.data.api.model.AmenityDto(id = currentRoute.amenityId, name = ""),
onBack = { route.value = AppRoute.Amenities },
onSave = { route.value = AppRoute.Amenities }
)
AppRoute.ImageTags -> ImageTagsScreen(
onBack = { route.value = AppRoute.Home },
onAdd = { route.value = AppRoute.AddImageTag },
onEdit = {
selectedImageTag.value = it
route.value = AppRoute.EditImageTag(it.id ?: "")
}
)
AppRoute.AddImageTag -> AddImageTagScreen(
onBack = { route.value = AppRoute.ImageTags },
onSave = { route.value = AppRoute.ImageTags }
)
is AppRoute.EditImageTag -> EditImageTagScreen(
tag = selectedImageTag.value
?: com.android.trisolarispms.data.api.model.RoomImageTagDto(id = currentRoute.tagId, name = ""),
onBack = { route.value = AppRoute.ImageTags },
onSave = { route.value = AppRoute.ImageTags }
)
is AppRoute.AddRoom -> RoomFormScreen(
title = "Add Room",
propertyId = currentRoute.propertyId,
roomId = null,
roomData = null,
formKey = roomFormKey.value,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onViewImages = { }
)
is AppRoute.EditRoom -> RoomFormScreen(
title = "Modify Room",
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,
roomData = selectedRoom.value,
formKey = roomFormKey.value,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onViewImages = { roomId ->
route.value = AppRoute.RoomImages(currentRoute.propertyId, roomId)
}
)
is AppRoute.IssueTemporaryCard -> IssueTemporaryCardScreen(
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,
roomNumber = selectedRoom.value?.roomNumber?.toString(),
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
)
is AppRoute.CardInfo -> CardInfoScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
)
is AppRoute.RatePlanCalendar -> RatePlanCalendarScreen(
propertyId = currentRoute.propertyId,
ratePlanId = currentRoute.ratePlanId,
ratePlanCode = currentRoute.ratePlanCode,
onBack = { route.value = AppRoute.EditRoomType(currentRoute.propertyId, currentRoute.roomTypeId) }
)
is AppRoute.RoomImages -> RoomImagesScreen(
propertyId = currentRoute.propertyId,
roomId = currentRoute.roomId,
onBack = { route.value = AppRoute.EditRoom(currentRoute.propertyId, currentRoute.roomId) }
)
}
} else {
AuthScreen(viewModel = authViewModel)
} }
} }
} }

View File

@@ -0,0 +1,72 @@
package com.android.trisolarispms.core.auth
class AuthzPolicy(
private val isSuperAdmin: Boolean,
propertyRoles: Map<String, List<Role>>
) {
private val rolesByProperty: Map<String, Set<Role>> = propertyRoles.mapValues { it.value.toSet() }
fun isPropertyAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageProperty(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canIssueTemporaryCard(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER)
fun canViewCardInfo(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
fun canManageRazorpaySettings(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageCancellationPolicy(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageBillingPolicy(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDeleteCashPayment(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canAddBookingPayment(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
fun canRefundBookingPayment(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER)
fun canManageGuestDocuments(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER)
fun canManagePropertyUsers(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canCreateBookingFor(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
fun canCheckOutRoomStay(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER)
fun canCheckOutBooking(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF)
fun canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER)
fun allowedAccessCodeRoles(propertyId: String): List<Role> = when {
isSuperAdmin || hasRole(propertyId, Role.ADMIN) -> listOf(Role.MANAGER, Role.STAFF, Role.AGENT)
hasRole(propertyId, Role.MANAGER) -> listOf(Role.STAFF, Role.AGENT)
else -> emptyList()
}
fun allowedRoleAssignments(propertyId: String): List<Role> = when {
isSuperAdmin || hasRole(propertyId, Role.ADMIN) ->
listOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.AGENT)
hasRole(propertyId, Role.MANAGER) -> listOf(Role.STAFF, Role.AGENT)
else -> emptyList()
}
fun shouldBlockBackToHome(propertyId: String, propertyCount: Int): Boolean =
!isSuperAdmin && !isPropertyAdmin(propertyId) && propertyCount == 1
private fun hasRole(propertyId: String, requiredRole: Role): Boolean =
isSuperAdmin || (rolesByProperty[propertyId]?.contains(requiredRole) == true)
private fun hasAnyRole(propertyId: String, vararg requiredRoles: Role): Boolean =
isSuperAdmin || (rolesByProperty[propertyId]?.any { role -> role in requiredRoles } == true)
}

View File

@@ -0,0 +1,27 @@
package com.android.trisolarispms.core.auth
import java.util.Locale
enum class Role {
ADMIN,
MANAGER,
STAFF,
HOUSEKEEPING,
FINANCE,
GUIDE,
SUPERVISOR,
AGENT;
companion object {
fun from(value: String?): Role? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
fun Collection<String>?.toRoles(): List<Role> = this.orEmpty().mapNotNull(Role::from)
fun Collection<String>?.toRoleSet(): Set<Role> = toRoles().toSet()
fun Collection<Role>.toRoleNameList(): List<String> = map { it.name }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
package com.android.trisolarispms.data.api
interface ApiService :
AuthApi,
PropertyApi,
RoomTypeApi,
RoomApi,
RoomImageApi,
ImageTagApi,
BookingApi,
RoomStayApi,
CardApi,
GuestApi,
GuestDocumentApi,
TransportApi,
InboundEmailApi,
AmenityApi,
RatePlanApi,
PayuSettingsApi,
PayuPaymentLinkSettingsApi

View File

@@ -1,131 +0,0 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckInRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.RoomStayDto
import com.android.trisolarispms.data.api.model.PayuQrRequest
import com.android.trisolarispms.data.api.model.PayuQrResponse
import com.android.trisolarispms.data.api.model.PayuLinkRequest
import com.android.trisolarispms.data.api.model.PayuLinkResponse
import com.android.trisolarispms.data.api.model.PaymentDto
import com.android.trisolarispms.data.api.model.PaymentCreateRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface BookingApi {
@POST("properties/{propertyId}/bookings")
suspend fun createBooking(
@Path("propertyId") propertyId: String,
@Body body: BookingCreateRequest
): Response<BookingCreateResponse>
@GET("properties/{propertyId}/bookings")
suspend fun listBookings(
@Path("propertyId") propertyId: String,
@Query("status") status: String? = null
): Response<List<BookingListItem>>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in/bulk")
suspend fun bulkCheckIn(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBulkCheckInRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/expected-dates")
suspend fun updateExpectedDates(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<BookingDetailsResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest
): Response<BookingCreateResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCheckInRequest
): Response<ActionResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/check-out")
suspend fun checkOut(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCheckOutRequest
): Response<ActionResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/cancel")
suspend fun cancelBooking(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCancelRequest
): Response<ActionResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/no-show")
suspend fun noShow(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingNoShowRequest
): Response<ActionResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays")
suspend fun preAssignRoomStay(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingRoomStayCreateRequest
): Response<RoomStayDto>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/qr")
suspend fun generatePayuQr(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PayuQrRequest
): Response<PayuQrResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/link")
suspend fun generatePayuLink(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PayuLinkRequest
): Response<PayuLinkResponse>
@GET("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun listPayments(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<List<PaymentDto>>
@POST("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun createPayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PaymentCreateRequest
): Response<PaymentDto>
}

View File

@@ -1,22 +0,0 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface PayuPaymentLinkSettingsApi {
@GET("properties/{propertyId}/payu-payment-link-settings")
suspend fun getPayuPaymentLinkSettings(
@Path("propertyId") propertyId: String
): Response<PayuPaymentLinkSettingsResponse>
@PUT("properties/{propertyId}/payu-payment-link-settings")
suspend fun updatePayuPaymentLinkSettings(
@Path("propertyId") propertyId: String,
@Body body: PayuPaymentLinkSettingsRequest
): Response<PayuPaymentLinkSettingsResponse>
}

View File

@@ -1,22 +0,0 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.PayuSettingsRequest
import com.android.trisolarispms.data.api.model.PayuSettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface PayuSettingsApi {
@GET("properties/{propertyId}/payu-settings")
suspend fun getPayuSettings(
@Path("propertyId") propertyId: String
): Response<PayuSettingsResponse>
@PUT("properties/{propertyId}/payu-settings")
suspend fun updatePayuSettings(
@Path("propertyId") propertyId: String,
@Body body: PayuSettingsRequest
): Response<PayuSettingsResponse>
}

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.core
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import okhttp3.Authenticator import okhttp3.Authenticator
@@ -10,11 +10,11 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
fun create( fun createOkHttpClient(
auth: FirebaseAuth = FirebaseAuth.getInstance(), auth: FirebaseAuth = FirebaseAuth.getInstance(),
baseUrl: String = ApiConstants.BASE_URL, enableLogging: Boolean = true,
enableLogging: Boolean = true readTimeoutSeconds: Long = 30
): ApiService { ): OkHttpClient {
val tokenProvider = FirebaseAuthTokenProvider(auth) val tokenProvider = FirebaseAuthTokenProvider(auth)
val authInterceptor = Interceptor { chain -> val authInterceptor = Interceptor { chain ->
val original = chain.request() val original = chain.request()
@@ -52,14 +52,25 @@ object ApiClient {
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
} }
val client = OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.authenticator(authenticator) .authenticator(authenticator)
.addInterceptor(logging) .addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
}
fun create(
auth: FirebaseAuth = FirebaseAuth.getInstance(),
baseUrl: String = ApiConstants.BASE_URL,
enableLogging: Boolean = true
): ApiService {
val client = createOkHttpClient(
auth = auth,
enableLogging = enableLogging
)
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.core
object ApiConstants { object ApiConstants {
const val BASE_URL = "https://api.hoteltrisolaris.in/" const val BASE_URL = "https://api.hoteltrisolaris.in/"

View File

@@ -0,0 +1,44 @@
package com.android.trisolarispms.data.api.core
import com.android.trisolarispms.data.api.service.AmenityApi
import com.android.trisolarispms.data.api.service.AuthApi
import com.android.trisolarispms.data.api.service.BillingPolicyApi
import com.android.trisolarispms.data.api.service.BookingApi
import com.android.trisolarispms.data.api.service.CancellationPolicyApi
import com.android.trisolarispms.data.api.service.CardApi
import com.android.trisolarispms.data.api.service.GuestApi
import com.android.trisolarispms.data.api.service.GuestDocumentApi
import com.android.trisolarispms.data.api.service.GeoApi
import com.android.trisolarispms.data.api.service.ImageTagApi
import com.android.trisolarispms.data.api.service.InboundEmailApi
import com.android.trisolarispms.data.api.service.PropertyApi
import com.android.trisolarispms.data.api.service.RatePlanApi
import com.android.trisolarispms.data.api.service.RazorpaySettingsApi
import com.android.trisolarispms.data.api.service.RoomApi
import com.android.trisolarispms.data.api.service.RoomImageApi
import com.android.trisolarispms.data.api.service.RoomStayApi
import com.android.trisolarispms.data.api.service.RoomTypeApi
import com.android.trisolarispms.data.api.service.TransportApi
import com.android.trisolarispms.data.api.service.UserAdminApi
interface ApiService :
AuthApi,
BillingPolicyApi,
PropertyApi,
RoomTypeApi,
RoomApi,
RoomImageApi,
ImageTagApi,
BookingApi,
RoomStayApi,
CardApi,
GuestApi,
GuestDocumentApi,
GeoApi,
TransportApi,
InboundEmailApi,
AmenityApi,
RatePlanApi,
RazorpaySettingsApi,
CancellationPolicyApi,
UserAdminApi

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.core
interface AuthTokenProvider { interface AuthTokenProvider {
suspend fun token(forceRefresh: Boolean = false): String? suspend fun token(forceRefresh: Boolean = false): String?

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.core
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await

View File

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

View File

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

View File

@@ -1,5 +1,20 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import java.util.Locale
enum class BookingBillingMode {
PROPERTY_POLICY,
CUSTOM_WINDOW,
FULL_24H;
companion object {
fun from(value: String?): BookingBillingMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class BookingCheckInRequest( data class BookingCheckInRequest(
val roomIds: List<String>, val roomIds: List<String>,
val checkInAt: String? = null, val checkInAt: String? = null,
@@ -11,6 +26,8 @@ data class BookingCheckInRequest(
data class BookingCreateRequest( data class BookingCreateRequest(
val expectedCheckInAt: String, val expectedCheckInAt: String,
val expectedCheckOutAt: String, val expectedCheckOutAt: String,
val billingMode: BookingBillingMode? = null,
val billingCheckoutTime: String? = null,
val source: String? = null, val source: String? = null,
val guestPhoneE164: String? = null, val guestPhoneE164: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
@@ -39,6 +56,7 @@ data class BookingListItem(
val guestId: String? = null, val guestId: String? = null,
val guestName: String? = null, val guestName: String? = null,
val guestPhone: String? = null, val guestPhone: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(), val roomNumbers: List<Int> = emptyList(),
val source: String? = null, val source: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
@@ -57,11 +75,16 @@ data class BookingListItem(
val expectedGuestCount: Int? = null, val expectedGuestCount: Int? = null,
val notes: String? = null val notes: String? = null
, ,
val pending: Long? = null val pending: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
) )
data class BookingBulkCheckInRequest( data class BookingBulkCheckInRequest(
val stays: List<BookingBulkCheckInStayRequest> val stays: List<BookingBulkCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
) )
data class BookingBulkCheckInStayRequest( data class BookingBulkCheckInStayRequest(
@@ -79,6 +102,40 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null val expectedCheckOutAt: String? = null
) )
data class BookingRoomRequestCreateRequest(
val roomTypeCode: String,
val quantity: Int,
val fromAt: String,
val toAt: String
)
data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingBillableNightsResponse(
val bookingId: String? = null,
val status: String? = null,
val billableNights: Long? = null
)
data class BookingExpectedCheckoutPreviewRequest(
val checkInAt: String,
val billableNights: Int? = null,
val billingMode: BookingBillingMode? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingExpectedCheckoutPreviewResponse(
val expectedCheckOutAt: String? = null,
val billableNights: Int? = null,
val billingMode: BookingBillingMode? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingDetailsResponse( data class BookingDetailsResponse(
val id: String? = null, val id: String? = null,
val status: String? = null, val status: String? = null,
@@ -86,9 +143,16 @@ data class BookingDetailsResponse(
val guestName: String? = null, val guestName: String? = null,
val guestPhone: String? = null, val guestPhone: String? = null,
val guestNationality: String? = null, val guestNationality: String? = null,
@com.google.gson.annotations.SerializedName(
value = "guestAge",
alternate = ["guestDob", "guestDOB", "guest_age"]
)
val guestAge: String? = null,
val guestAddressText: String? = null, val guestAddressText: String? = null,
val guestSignatureUrl: String? = null, val guestSignatureUrl: String? = null,
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,
@@ -104,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(
@@ -120,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
@@ -130,39 +209,12 @@ data class BookingNoShowRequest(
val reason: String? = null val reason: String? = null
) )
data class BookingRoomStayCreateRequest( data class BookingBalanceResponse(
val roomId: String, val expectedPay: Long? = null,
val fromAt: String, val amountCollected: Long? = null,
val toAt: String, val pending: Long? = null
val notes: String? = null
) )
// Room Stays data class RoomStayVoidRequest(
val reason: String
data class RoomStayCreateRequest(
val roomId: String,
val guestId: String? = null,
val checkIn: String? = null,
val checkOut: String? = null
)
data class RoomStayDto(
val id: String? = null,
val bookingId: String? = null,
val roomId: String? = null,
val status: String? = null
)
data class RoomChangeRequest(
val newRoomId: String,
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: String? = null,
val newRoomStayId: String? = null,
val oldRoomId: String? = null,
val newRoomId: String? = null,
val movedAt: String? = null
) )

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
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 addressText: String? = null, val addressText: String? = null,
val vehicleNumbers: List<String> = emptyList(), val vehicleNumbers: List<String> = emptyList(),
@@ -15,6 +19,7 @@ data class GuestCreateRequest(
val phoneE164: String? = null, val phoneE164: String? = null,
val name: String? = null, val name: String? = null,
val nationality: String? = null, val nationality: String? = null,
val age: String? = null,
val addressText: String? = null val addressText: String? = null
) )
@@ -22,6 +27,7 @@ data class GuestUpdateRequest(
val phoneE164: String? = null, val phoneE164: String? = null,
val name: String? = null, val name: String? = null,
val nationality: String? = null, val nationality: String? = null,
val age: String? = null,
val addressText: String? = null val addressText: String? = null
) )

View File

@@ -23,3 +23,17 @@ data class PaymentDto(
data class PaymentCreateRequest( data class PaymentCreateRequest(
val amount: Long val amount: Long
) )
data class RazorpayRefundRequest(
val paymentId: String? = null,
val razorpayPaymentId: String? = null,
val amount: Long,
val notes: String? = null
)
data class RazorpayRefundResponse(
val refundId: String? = null,
val status: String? = null,
val amount: Long? = null,
val currency: String? = null
)

View File

@@ -1,18 +0,0 @@
package com.android.trisolarispms.data.api.model
data class PayuPaymentLinkSettingsRequest(
val merchantId: String,
val clientId: String,
val clientSecret: String,
val isTest: Boolean
)
data class PayuPaymentLinkSettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
val merchantId: String? = null,
val isTest: Boolean? = null,
val hasClientId: Boolean? = null,
val hasClientSecret: Boolean? = null,
val hasAccessToken: Boolean? = null
)

View File

@@ -1,26 +0,0 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.JsonElement
data class PayuQrRequest(
val amount: Long,
val deviceInfo: String
)
data class PayuQrResponse(
val txnid: String? = null,
val amount: Long? = null,
val currency: String? = null,
val payuResponse: JsonElement? = null
)
data class PayuLinkRequest(
val amount: Long
)
data class PayuLinkResponse(
val amount: Long? = null,
val currency: String? = null,
val paymentLink: String? = null,
val payuResponse: JsonElement? = null
)

View File

@@ -1,21 +0,0 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class PayuSettingsRequest(
val merchantKey: String,
val salt32: String? = null,
val salt256: String? = null,
@SerializedName("isTest") val isTest: Boolean,
val useSalt256: Boolean
)
data class PayuSettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
val merchantKey: String? = null,
@SerializedName("test") val isTest: Boolean? = null,
val useSalt256: Boolean? = null,
val hasSalt32: Boolean? = null,
val hasSalt256: Boolean? = null
)

View File

@@ -1,7 +1,7 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
data class PropertyCreateRequest( data class PropertyCreateRequest(
val code: String, val code: String? = null,
val name: String, val name: String,
val addressText: String? = null, val addressText: String? = null,
val timezone: String? = null, val timezone: String? = null,
@@ -36,3 +36,7 @@ data class PropertyDto(
val emailAddresses: List<String>? = null, val emailAddresses: List<String>? = null,
val allowedTransportModes: List<String>? = null val allowedTransportModes: List<String>? = null
) )
data class PropertyCodeResponse(
val code: String? = null
)

View File

@@ -0,0 +1,51 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.JsonElement
data class RazorpayQrRequest(
val amount: Long,
val deviceInfo: String
)
data class RazorpayQrResponse(
val qrId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val imageUrl: String? = null
)
data class RazorpayPaymentLinkRequest(
val amount: Long
)
data class RazorpayPaymentLinkResponse(
val amount: Long? = null,
val currency: String? = null,
val paymentLink: String? = null
)
data class RazorpayCloseRequest(
val qrId: String? = null,
val paymentLinkId: String? = null
)
data class RazorpayQrEventDto(
val event: String? = null,
val qrId: String? = null,
val status: String? = null,
val receivedAt: String? = null
)
data class RazorpayRequestListItemDto(
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
)

View File

@@ -0,0 +1,23 @@
package com.android.trisolarispms.data.api.model
data class RazorpaySettingsRequest(
val keyId: String? = null,
val keySecret: String? = null,
val webhookSecret: String? = null,
val keyIdTest: String? = null,
val keySecretTest: String? = null,
val webhookSecretTest: String? = null,
val isTest: Boolean
)
data class RazorpaySettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
@com.google.gson.annotations.SerializedName("test") val isTest: Boolean? = null,
val hasKeyId: Boolean? = null,
val hasKeySecret: Boolean? = null,
val hasWebhookSecret: Boolean? = null,
val hasKeyIdTest: Boolean? = null,
val hasKeySecretTest: Boolean? = null,
val hasWebhookSecretTest: Boolean? = null
)

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
package com.android.trisolarispms.data.api.model
data class AppUserSummaryResponse(
val id: String? = null,
val phoneE164: String? = null,
val name: String? = null,
val disabled: Boolean = false,
val superAdmin: Boolean = false
)
data class PropertyUserDetailsResponse(
val userId: String? = null,
val propertyId: String? = null,
val roles: List<String> = emptyList(),
val name: String? = null,
val phoneE164: String? = null,
val disabled: Boolean = false,
val superAdmin: Boolean = false
)
data class PropertyAccessCodeCreateRequest(
val roles: List<String>
)
data class PropertyAccessCodeResponse(
val propertyId: String? = null,
val code: String? = null,
val expiresAt: String? = null,
val roles: List<String> = emptyList()
)
data class PropertyAccessCodeJoinRequest(
val propertyId: String,
val code: String
)
data class PropertyUserResponse(
val userId: String? = null,
val propertyId: String? = null,
val roles: List<String> = emptyList()
)
data class PropertyUserDisabledRequest(
val disabled: Boolean
)

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.AmenityCreateRequest import com.android.trisolarispms.data.api.model.AmenityCreateRequest
import com.android.trisolarispms.data.api.model.AmenityDto import com.android.trisolarispms.data.api.model.AmenityDto

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.AuthVerifyResponse import com.android.trisolarispms.data.api.model.AuthVerifyResponse
import com.android.trisolarispms.data.api.model.AuthMeUpdateRequest import com.android.trisolarispms.data.api.model.AuthMeUpdateRequest

View File

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

View File

@@ -0,0 +1,218 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckInRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsRequest
import com.android.trisolarispms.data.api.model.BookingBillableNightsResponse
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
import com.google.gson.JsonObject
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
import com.android.trisolarispms.data.api.model.RazorpayQrResponse
import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkRequest
import com.android.trisolarispms.data.api.model.RazorpayPaymentLinkResponse
import com.android.trisolarispms.data.api.model.PaymentDto
import com.android.trisolarispms.data.api.model.PaymentCreateRequest
import com.android.trisolarispms.data.api.model.RazorpayRefundRequest
import com.android.trisolarispms.data.api.model.RazorpayRefundResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.DELETE
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface BookingApi {
@POST("properties/{propertyId}/bookings")
suspend fun createBooking(
@Path("propertyId") propertyId: String,
@Body body: BookingCreateRequest
): Response<BookingCreateResponse>
@GET("properties/{propertyId}/bookings")
suspend fun listBookings(
@Path("propertyId") propertyId: String,
@Query("status") status: String? = null
): Response<List<BookingListItem>>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in/bulk")
suspend fun bulkCheckIn(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBulkCheckInRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/expected-dates")
suspend fun updateExpectedDates(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/profile")
suspend fun updateBookingProfile(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: JsonObject
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
suspend fun createRoomRequest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingRoomRequestCreateRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
suspend fun previewBillableNights(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBillableNightsRequest
): Response<BookingBillableNightsResponse>
@POST("properties/{propertyId}/bookings/expected-checkout-preview")
suspend fun previewExpectedCheckout(
@Path("propertyId") propertyId: String,
@Body body: BookingExpectedCheckoutPreviewRequest
): Response<BookingExpectedCheckoutPreviewResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/billing-policy")
suspend fun updateBookingBillingPolicy(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBillingPolicyUpdateRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<BookingDetailsResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCheckInRequest
): Response<ActionResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/check-out")
suspend fun checkOut(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCheckOutRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out")
suspend fun checkOutRoomStay(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Path("roomStayId") roomStayId: String,
@Body body: BookingRoomStayCheckOutRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/cancel")
suspend fun cancelBooking(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCancelRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/no-show")
suspend fun noShow(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingNoShowRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}/balance")
suspend fun getBookingBalance(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<BookingBalanceResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr")
suspend fun generateRazorpayQr(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: RazorpayQrRequest
): Response<RazorpayQrResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/payment-link")
suspend fun generateRazorpayPaymentLink(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: RazorpayPaymentLinkRequest
): Response<RazorpayPaymentLinkResponse>
@GET("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun listPayments(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<List<PaymentDto>>
@POST("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun createPayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PaymentCreateRequest
): Response<PaymentDto>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/refund")
suspend fun refundRazorpayPayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: RazorpayRefundRequest
): Response<RazorpayRefundResponse>
@DELETE("properties/{propertyId}/bookings/{bookingId}/payments/{paymentId}")
suspend fun deletePayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Path("paymentId") paymentId: String
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/requests")
suspend fun listRazorpayRequests(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<List<RazorpayRequestListItemDto>>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/close")
suspend fun closeRazorpayRequest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: com.android.trisolarispms.data.api.model.RazorpayCloseRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr/{qrId}/events")
suspend fun listRazorpayQrEvents(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Path("qrId") qrId: String
): Response<List<RazorpayQrEventDto>>
}

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.CardPrepareRequest import com.android.trisolarispms.data.api.model.CardPrepareRequest
import com.android.trisolarispms.data.api.model.CardPrepareResponse import com.android.trisolarispms.data.api.model.CardPrepareResponse

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.GuestDto import com.android.trisolarispms.data.api.model.GuestDto
import com.android.trisolarispms.data.api.model.GuestCreateRequest import com.android.trisolarispms.data.api.model.GuestCreateRequest

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.GuestDocumentDto import com.android.trisolarispms.data.api.model.GuestDocumentDto
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -7,9 +7,11 @@ import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.DELETE
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming import retrofit2.http.Streaming
interface GuestDocumentApi { interface GuestDocumentApi {
@@ -22,6 +24,15 @@ interface GuestDocumentApi {
@Part("bookingId") bookingId: RequestBody @Part("bookingId") bookingId: RequestBody
): Response<GuestDocumentDto> ): Response<GuestDocumentDto>
@Multipart
@POST("properties/{propertyId}/guests/{guestId}/documents")
suspend fun uploadGuestDocumentWithBooking(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Query("bookingId") bookingId: String,
@Part file: MultipartBody.Part
): Response<GuestDocumentDto>
@GET("properties/{propertyId}/guests/{guestId}/documents") @GET("properties/{propertyId}/guests/{guestId}/documents")
suspend fun listGuestDocuments( suspend fun listGuestDocuments(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -35,4 +46,11 @@ interface GuestDocumentApi {
@Path("guestId") guestId: String, @Path("guestId") guestId: String,
@Path("documentId") documentId: String @Path("documentId") documentId: String
): Response<ResponseBody> ): Response<ResponseBody>
@DELETE("properties/{propertyId}/guests/{guestId}/documents/{documentId}")
suspend fun deleteGuestDocument(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Path("documentId") documentId: String
): Response<Unit>
} }

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RoomImageTagDto import com.android.trisolarispms.data.api.model.RoomImageTagDto
import retrofit2.Response import retrofit2.Response

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.ActionResponse import com.android.trisolarispms.data.api.model.ActionResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody

View File

@@ -1,10 +1,12 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.ActionResponse import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.PropertyCreateRequest import com.android.trisolarispms.data.api.model.PropertyCreateRequest
import com.android.trisolarispms.data.api.model.PropertyDto import com.android.trisolarispms.data.api.model.PropertyDto
import com.android.trisolarispms.data.api.model.PropertyUpdateRequest import com.android.trisolarispms.data.api.model.PropertyUpdateRequest
import com.android.trisolarispms.data.api.model.UserDto import com.android.trisolarispms.data.api.model.PropertyUserResponse
import com.android.trisolarispms.data.api.model.PropertyUserDisabledRequest
import com.android.trisolarispms.data.api.model.PropertyCodeResponse
import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
@@ -28,18 +30,30 @@ interface PropertyApi {
): Response<PropertyDto> ): Response<PropertyDto>
@GET("properties/{propertyId}/users") @GET("properties/{propertyId}/users")
suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<UserDto>> suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<PropertyUserResponse>>
@PUT("properties/{propertyId}/users/{userId}/roles") @PUT("properties/{propertyId}/users/{userId}/roles")
suspend fun updateUserRoles( suspend fun updateUserRoles(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("userId") userId: String, @Path("userId") userId: String,
@Body body: UserRolesUpdateRequest @Body body: UserRolesUpdateRequest
): Response<ActionResponse> ): Response<PropertyUserResponse>
@DELETE("properties/{propertyId}/users/{userId}") @DELETE("properties/{propertyId}/users/{userId}")
suspend fun deletePropertyUser( suspend fun deletePropertyUser(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("userId") userId: String @Path("userId") userId: String
): Response<ActionResponse> ): Response<ActionResponse>
@GET("properties/{propertyId}/code")
suspend fun getPropertyCode(
@Path("propertyId") propertyId: String
): Response<PropertyCodeResponse>
@PUT("properties/{propertyId}/users/{userId}/disabled")
suspend fun updateUserDisabled(
@Path("propertyId") propertyId: String,
@Path("userId") userId: String,
@Body body: PropertyUserDisabledRequest
): Response<PropertyUserResponse>
} }

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RatePlanCalendarEntry import com.android.trisolarispms.data.api.model.RatePlanCalendarEntry
import com.android.trisolarispms.data.api.model.RatePlanCalendarUpsertRequest import com.android.trisolarispms.data.api.model.RatePlanCalendarUpsertRequest

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RazorpaySettingsRequest
import com.android.trisolarispms.data.api.model.RazorpaySettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface RazorpaySettingsApi {
@GET("properties/{propertyId}/razorpay-settings")
suspend fun getRazorpaySettings(
@Path("propertyId") propertyId: String
): Response<RazorpaySettingsResponse>
@PUT("properties/{propertyId}/razorpay-settings")
suspend fun updateRazorpaySettings(
@Path("propertyId") propertyId: String,
@Body body: RazorpaySettingsRequest
): Response<RazorpaySettingsResponse>
}

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.ImageDto import com.android.trisolarispms.data.api.model.ImageDto
import okhttp3.MultipartBody import okhttp3.MultipartBody

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.ActionResponse import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.RoomTypeCreateRequest import com.android.trisolarispms.data.api.model.RoomTypeCreateRequest

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.TransportModeDto import com.android.trisolarispms.data.api.model.TransportModeDto
import retrofit2.Response import retrofit2.Response

View File

@@ -0,0 +1,38 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.AppUserSummaryResponse
import com.android.trisolarispms.data.api.model.PropertyAccessCodeCreateRequest
import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest
import com.android.trisolarispms.data.api.model.PropertyAccessCodeResponse
import com.android.trisolarispms.data.api.model.PropertyUserDetailsResponse
import com.android.trisolarispms.data.api.model.PropertyUserResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface UserAdminApi {
@GET("users")
suspend fun listUsers(
@Query("phone") phone: String? = null
): Response<List<AppUserSummaryResponse>>
@GET("properties/{propertyId}/users/search")
suspend fun searchPropertyUsers(
@Path("propertyId") propertyId: String,
@Query("phone") phone: String? = null
): Response<List<PropertyUserDetailsResponse>>
@POST("properties/{propertyId}/access-codes")
suspend fun createAccessCode(
@Path("propertyId") propertyId: String,
@Body body: PropertyAccessCodeCreateRequest
): Response<PropertyAccessCodeResponse>
@POST("properties/access-codes/join")
suspend fun joinAccessCode(
@Body body: PropertyAccessCodeJoinRequest
): Response<PropertyUserResponse>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package com.android.trisolarispms.ui.auth package com.android.trisolarispms.ui.auth
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -17,47 +20,90 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.platform.LocalContext 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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import kotlinx.coroutines.delay
@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 now = remember { mutableStateOf(System.currentTimeMillis()) }
val hasNetwork = remember(context, state.error) { hasInternetConnection(context) }
val noNetworkError = state.error?.contains("No internet connection", ignoreCase = true) == true
val isCheckingExistingSession = state.isLoading && !state.apiVerified && state.userId != null
val shouldHideAuthForm = !hasNetwork || noNetworkError || isCheckingExistingSession
LaunchedEffect(state.resendAvailableAt) {
while (state.resendAvailableAt != null) {
now.value = System.currentTimeMillis()
delay(1000)
}
}
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()
.padding(24.dp), .padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
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))
OutlinedTextField( PhoneNumberCountryField(
value = state.phone, phoneCountryCode = state.phoneCountryCode,
onValueChange = viewModel::onPhoneChange, onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
label = { Text("Phone number") }, phoneNationalNumber = state.phoneNationalNumber,
placeholder = { Text("10-digit mobile") }, onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
prefix = { Text(state.countryCode) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Default country: India (+91)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true
val resendText = if (!state.isCodeSent) {
"Send code"
} else if (!canResend) {
val remaining = ((state.resendAvailableAt ?: 0L) - now.value) / 1000
"Resend in ${remaining.coerceAtLeast(0)}s"
} else {
"Resend code"
}
Button( Button(
onClick = { onClick = {
if (activity != null) { if (activity != null) {
@@ -66,9 +112,9 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
viewModel.reportError("Unable to access activity for phone auth") viewModel.reportError("Unable to access activity for phone auth")
} }
}, },
enabled = !state.isLoading enabled = !state.isLoading && (!state.isCodeSent || canResend)
) { ) {
Text(if (state.isCodeSent) "Resend code" else "Send code") Text(resendText)
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -118,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(
@@ -133,3 +173,10 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
} }
} }
} }
private fun hasInternetConnection(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
val network = cm.activeNetwork ?: return false
val capabilities = cm.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}

View File

@@ -1,11 +1,16 @@
package com.android.trisolarispms.ui.auth package com.android.trisolarispms.ui.auth
import com.android.trisolarispms.core.auth.Role
data class AuthUiState( data class AuthUiState(
val countryCode: String = "+91", val countryCode: String = "+91",
val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
val phone: String = "", val phone: String = "",
val code: String = "", val code: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isCodeSent: Boolean = false, val isCodeSent: Boolean = false,
val resendAvailableAt: Long? = null,
val verificationId: String? = null, val verificationId: String? = null,
val error: String? = null, val error: String? = null,
val userId: String? = null, val userId: String? = null,
@@ -16,5 +21,5 @@ data class AuthUiState(
val nameInput: String = "", val nameInput: String = "",
val needsName: Boolean = false, val needsName: Boolean = false,
val unauthorized: Boolean = false, val unauthorized: Boolean = false,
val propertyRoles: Map<String, List<String>> = emptyMap() val propertyRoles: Map<String, List<Role>> = emptyMap()
) )

View File

@@ -3,7 +3,9 @@ package com.android.trisolarispms.ui.auth
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient import com.android.trisolarispms.core.auth.Role
import com.android.trisolarispms.core.auth.toRoles
import com.android.trisolarispms.data.api.core.ApiClient
import com.google.firebase.FirebaseException import com.google.firebase.FirebaseException
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.PhoneAuthCredential import com.google.firebase.auth.PhoneAuthCredential
@@ -14,6 +16,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AuthViewModel( class AuthViewModel(
@@ -23,6 +28,7 @@ class AuthViewModel(
val state: StateFlow<AuthUiState> = _state val state: StateFlow<AuthUiState> = _state
private var resendToken: PhoneAuthProvider.ForceResendingToken? = null private var resendToken: PhoneAuthProvider.ForceResendingToken? = null
private var resendCooldownJob: Job? = null
init { init {
val user = auth.currentUser val user = auth.currentUser
@@ -31,13 +37,28 @@ class AuthViewModel(
} }
} }
fun onPhoneChange(value: String) { fun onPhoneCountryChange(value: String) {
val digits = value.filter { it.isDigit() }.take(10) val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(value)
_state.update { it.copy(phone = digits, error = null) } val trimmed = state.value.phoneNationalNumber.take(option.maxLength)
_state.update {
it.copy(
phoneCountryCode = option.code,
countryCode = "+${option.dialCode}",
phoneNationalNumber = trimmed,
error = null
)
}
}
fun onPhoneNationalNumberChange(value: String) {
val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode)
val digits = value.filter { it.isDigit() }.take(option.maxLength)
_state.update { it.copy(phoneNationalNumber = digits, error = null) }
} }
fun onCodeChange(value: String) { fun onCodeChange(value: String) {
_state.update { it.copy(code = value, error = null) } val digits = value.filter { it.isDigit() }.take(6)
_state.update { it.copy(code = digits, error = null) }
} }
fun onNameChange(value: String) { fun onNameChange(value: String) {
@@ -48,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)
} }
@@ -57,9 +87,14 @@ class AuthViewModel(
} }
fun sendCode(activity: ComponentActivity) { fun sendCode(activity: ComponentActivity) {
val now = System.currentTimeMillis()
val resendAt = state.value.resendAvailableAt
if (resendAt != null && now < resendAt) {
return
}
val phone = buildE164Phone() val phone = buildE164Phone()
if (phone == null) { if (phone == null) {
setError("Enter a valid 10-digit phone number") setError("Enter a valid phone number")
return return
} }
@@ -71,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(
@@ -83,10 +118,12 @@ class AuthViewModel(
it.copy( it.copy(
isLoading = false, isLoading = false,
isCodeSent = true, isCodeSent = true,
resendAvailableAt = System.currentTimeMillis() + 60_000,
verificationId = verificationId, verificationId = verificationId,
error = null error = null
) )
} }
startResendCooldown()
} }
} }
@@ -104,9 +141,10 @@ class AuthViewModel(
} }
private fun buildE164Phone(): String? { private fun buildE164Phone(): String? {
val digits = state.value.phone.trim() val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode)
if (digits.length != 10) return null val digits = state.value.phoneNationalNumber.trim()
return "${state.value.countryCode}$digits" if (digits.length != option.maxLength) return null
return "+${option.dialCode}$digits"
} }
fun verifyCode() { fun verifyCode() {
@@ -121,6 +159,10 @@ class AuthViewModel(
setError("Enter the code") setError("Enter the code")
return return
} }
if (code.length != 6) {
setError("Enter the 6-digit code")
return
}
val credential = PhoneAuthProvider.getCredential(verificationId, code) val credential = PhoneAuthProvider.getCredential(verificationId, code)
signInWithCredential(credential) signInWithCredential(credential)
@@ -136,11 +178,30 @@ 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"))
} }
} }
} }
private fun startResendCooldown() {
resendCooldownJob?.cancel()
resendCooldownJob = viewModelScope.launch {
while (true) {
val remaining = (state.value.resendAvailableAt ?: 0L) - System.currentTimeMillis()
if (remaining <= 0) {
_state.update { it.copy(resendAvailableAt = null) }
break
}
delay(1000)
}
}
}
override fun onCleared() {
super.onCleared()
resendCooldownJob?.cancel()
}
private fun verifyExistingSession(userId: String) { private fun verifyExistingSession(userId: String) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, userId = userId) } _state.update { it.copy(isLoading = true, error = null, userId = userId) }
@@ -149,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"))
} }
} }
} }
@@ -159,50 +220,25 @@ class AuthViewModel(
response: retrofit2.Response<com.android.trisolarispms.data.api.model.AuthVerifyResponse> response: retrofit2.Response<com.android.trisolarispms.data.api.model.AuthVerifyResponse>
) { ) {
val body = response.body() val body = response.body()
val status = body?.status
val userName = body?.user?.name val userName = body?.user?.name
val isSuperAdmin = body?.user?.superAdmin == true val isSuperAdmin = body?.user?.superAdmin == true
val propertyRoles = body?.properties val propertyRoles = body?.properties
.orEmpty() .orEmpty()
.mapNotNull { entry -> .mapNotNull { entry ->
val id = entry.propertyId val id = entry.propertyId
id?.let { it to entry.roles.orEmpty() } id?.let { it to entry.roles.toRoles() }
} }
.toMap() .toMap()
when { when {
response.isSuccessful && (body?.status == "OK" || body?.status == "SUPER_ADMIN") -> { response.isSuccessful && (status == "OK" || status == "SUPER_ADMIN" || status == "NO_PROPERTIES") ->
_state.update { setVerifiedState(
it.copy( userId = userId,
isLoading = false, userName = userName,
userId = userId, isSuperAdmin = isSuperAdmin,
apiVerified = true, propertyRoles = propertyRoles,
needsName = userName.isNullOrBlank(), noProperties = status == "NO_PROPERTIES"
nameInput = userName ?: "", )
userName = userName,
isSuperAdmin = isSuperAdmin,
noProperties = body?.status == "NO_PROPERTIES",
unauthorized = false,
propertyRoles = propertyRoles,
error = null
)
}
}
response.isSuccessful && body?.status == "NO_PROPERTIES" -> {
_state.update {
it.copy(
isLoading = false,
userId = userId,
apiVerified = true,
needsName = userName.isNullOrBlank(),
nameInput = userName ?: "",
userName = userName,
isSuperAdmin = isSuperAdmin,
noProperties = true,
unauthorized = false,
propertyRoles = propertyRoles,
error = null
)
}
}
response.code() == 401 -> { response.code() == 401 -> {
_state.update { _state.update {
it.copy( it.copy(
@@ -225,13 +261,37 @@ 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")
) )
} }
} }
} }
} }
private fun setVerifiedState(
userId: String?,
userName: String?,
isSuperAdmin: Boolean,
propertyRoles: Map<String, List<Role>>,
noProperties: Boolean
) {
_state.update {
it.copy(
isLoading = false,
userId = userId,
apiVerified = true,
needsName = userName.isNullOrBlank(),
nameInput = userName ?: "",
userName = userName,
isSuperAdmin = isSuperAdmin,
noProperties = noProperties,
unauthorized = false,
propertyRoles = propertyRoles,
error = null
)
}
}
fun signOut() { fun signOut() {
auth.signOut() auth.signOut()
_state.update { AuthUiState() } _state.update { AuthUiState() }
@@ -259,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"))
} }
} }
} }
@@ -287,4 +347,20 @@ class AuthViewModel(
} }
} }
private fun mapHttpError(code: Int, prefix: String): String {
return if (code >= 500) {
"Server down. Please try again."
} else {
"$prefix: $code"
}
}
private fun mapThrowableToMessage(throwable: Throwable, fallback: String): String {
return when {
throwable is UnknownHostException -> "No internet connection."
throwable.localizedMessage?.contains("Unable to resolve host", ignoreCase = true) == true ->
"No internet connection."
else -> throwable.localizedMessage ?: fallback
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import android.nfc.tech.MifareClassic
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.CardPrepareRequest import com.android.trisolarispms.data.api.model.CardPrepareRequest
import com.android.trisolarispms.data.api.model.IssueCardRequest import com.android.trisolarispms.data.api.model.IssueCardRequest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,10 @@
package com.android.trisolarispms.ui.guest package com.android.trisolarispms.ui.guest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -27,11 +12,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.common.PaddedScreenColumn
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestInfoScreen( fun GuestInfoScreen(
propertyId: String, propertyId: String,
bookingId: String,
guestId: String, guestId: String,
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?, initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
initialPhone: String?, initialPhone: String?,
@@ -41,63 +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,
Spacer(modifier = Modifier.height(12.dp)) guestId = guestId
OutlinedTextField( )
value = state.name, },
onValueChange = viewModel::onNameChange, phoneNationalNumber = state.phoneNationalNumber,
label = { Text("Name (optional)") }, onPhoneNationalNumberChange = { number ->
modifier = Modifier.fillMaxWidth() viewModel.onPhoneNationalNumberChange(
) value = number,
Spacer(modifier = Modifier.height(12.dp)) propertyId = propertyId,
OutlinedTextField( guestId = guestId
value = state.nationality, )
onValueChange = viewModel::onNationalityChange, },
label = { Text("Nationality (optional)") }, name = state.name,
modifier = Modifier.fillMaxWidth() onNameChange = viewModel::onNameChange,
) nationality = state.nationality,
Spacer(modifier = Modifier.height(12.dp)) onNationalityChange = viewModel::onNationalityChange,
OutlinedTextField( nationalitySuggestions = state.nationalitySuggestions,
value = state.addressText, isNationalitySearchLoading = state.isNationalitySearchLoading,
onValueChange = viewModel::onAddressChange, onNationalitySuggestionSelected = viewModel::onNationalitySuggestionSelected,
label = { Text("Address (optional)") }, age = state.age,
modifier = Modifier.fillMaxWidth() onAgeChange = viewModel::onAgeChange,
addressText = state.addressText,
onAddressChange = viewModel::onAddressChange,
fromCity = state.fromCity,
onFromCityChange = viewModel::onFromCityChange,
fromCitySuggestions = state.fromCitySuggestions,
isFromCitySearchLoading = state.isFromCitySearchLoading,
onFromCitySuggestionSelected = viewModel::onFromCitySuggestionSelected,
toCity = state.toCity,
onToCityChange = viewModel::onToCityChange,
toCitySuggestions = state.toCitySuggestions,
isToCitySearchLoading = state.isToCitySearchLoading,
onToCitySuggestionSelected = viewModel::onToCitySuggestionSelected,
memberRelation = state.memberRelation,
onMemberRelationChange = viewModel::onMemberRelationChange,
transportMode = state.transportMode,
onTransportModeChange = viewModel::onTransportModeChange,
childCount = state.childCount,
onChildCountChange = viewModel::onChildCountChange,
maleCount = state.maleCount,
onMaleCountChange = viewModel::onMaleCountChange,
femaleCount = state.femaleCount,
onFemaleCountChange = viewModel::onFemaleCountChange,
vehicleNumbers = state.vehicleNumbers
) )
if (state.isLoading) { if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@@ -1,10 +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 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

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