Compare commits

...

44 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
androidlover5842
c5e0648dd1 add payment ledger impl and payu impl 2026-01-30 10:17:17 +05:30
androidlover5842
2d75b88892 guest details: improve room stays ui 2026-01-29 23:19:54 +05:30
androidlover5842
be820391bc booking details: add ability to see details of guest 2026-01-29 22:03:06 +05:30
androidlover5842
9d3ade3d03 active stays: ability to extent checkout time 2026-01-29 20:39:15 +05:30
androidlover5842
f593306c50 improve checked in guest ui 2026-01-29 20:14:14 +05:30
androidlover5842
dffa8b14cd ability to see stays from booking id on click 2026-01-29 14:07:01 +05:30
androidlover5842
5f522ca3ab booking create: manage booking rates flow 2026-01-29 12:09:43 +05:30
androidlover5842
29065cee22 booking: ability to take signature 2026-01-29 10:36:49 +05:30
185 changed files with 14540 additions and 2397 deletions

247
AGENTS.md
View File

@@ -1,5 +1,9 @@
# TrisolarisPMS API Usage
## API Docs Path
- `/home/androidlover5842/IdeaProjects/TrisolarisServer/docs`
## 1) Booking
### Create booking
@@ -46,10 +50,44 @@ Response
---
### List bookings
GET /properties/{propertyId}/bookings
Optional query param:
- status (comma-separated), e.g. status=OPEN,CHECKED_IN
Behavior:
- If status is omitted, returns all bookings for the property (newest first).
Response: List of BookingListItem with id, status, guestId, guestName, guestPhone, roomNumbers, source, times, counts, expectedGuestCount, notes.
Notes:
- It returns active room stays (toAt = null) for each booking.
---
### Booking details
GET /properties/{propertyId}/bookings/{bookingId}
Includes:
- Guest info (name/phone/nationality/address/signatureUrl)
- Room numbers (active stays if present; otherwise all stays)
- Travel fields (fromCity/toCity/memberRelation)
- Transport mode, expected/actual times
- Counts (male/female/child/total/expected)
- Registered by (createdBy name/phone)
- totalNightlyRate (sum of nightlyRate across shown rooms)
- balance: expectedPay, amountCollected, pending
---
### Check-in (creates RoomStay)
POST /properties/{propertyId}/bookings/{bookingId}/check-in
Auth: ADMIN/MANAGER/STAFF
Auth: ADMIN/MANAGER
Body
Required:
@@ -61,7 +99,7 @@ Optional:
- checkInAt (String)
- transportMode (String enum)
- nightlyRate (Long)
- rateSource (PRESET|NEGOTIATED|OTA)
- rateSource (MANUAL|RATE_PLAN|OTA)
- ratePlanCode (String)
- currency (String)
- notes (String)
@@ -70,8 +108,8 @@ Optional:
"roomIds": ["uuid1","uuid2"],
"checkInAt": "2026-01-28T12:00:00+05:30",
"nightlyRate": 2500,
"rateSource": "NEGOTIATED",
"ratePlanCode": "WEEKEND",
"rateSource": "MANUAL",
"ratePlanCode": "EP",
"currency": "INR",
"notes": "Late arrival"
}
@@ -81,7 +119,7 @@ Optional:
### Pre-assign room stay
POST /properties/{propertyId}/bookings/{bookingId}/room-stays
Auth: ADMIN/MANAGER/STAFF
Auth: ADMIN/MANAGER
Body
Required:
@@ -93,7 +131,7 @@ Required:
Optional:
- nightlyRate (Long)
- rateSource (PRESET|NEGOTIATED|OTA)
- rateSource (MANUAL|RATE_PLAN|OTA)
- ratePlanCode (String)
- currency (String)
- notes (String)
@@ -103,13 +141,82 @@ Optional:
"fromAt": "2026-01-29T12:00:00+05:30",
"toAt": "2026-01-30T10:00:00+05:30",
"nightlyRate": 2800,
"rateSource": "PRESET",
"ratePlanCode": "WEEKEND",
"rateSource": "RATE_PLAN",
"ratePlanCode": "EP",
"currency": "INR"
}
---
### Active room stays
GET /properties/{propertyId}/room-stays/active
Auth: any member except AGENT-only
Response: list of ActiveRoomStayResponse
[
{
"roomStayId":"uuid",
"bookingId":"uuid",
"guestId":"uuid-or-null",
"guestName":"Name",
"guestPhone":"+9111...",
"roomId":"uuid",
"roomNumber":"101",
"roomTypeName":"DELUXE",
"fromAt":"2026-01-29T12:00:00+05:30",
"checkinAt":"2026-01-29T12:05:00+05:30",
"expectedCheckoutAt":"2026-01-30T10:00:00+05:30"
}
]
---
### Change room (move guest)
POST /properties/{propertyId}/room-stays/{roomStayId}/change-room
Auth: ADMIN/MANAGER/STAFF
Body
{
"newRoomId":"uuid",
"movedAt":"2026-01-30T15:00:00+05:30",
"idempotencyKey":"any-unique-string"
}
Response
{
"oldRoomStayId":"uuid",
"newRoomStayId":"uuid",
"oldRoomId":"uuid",
"newRoomId":"uuid",
"movedAt":"2026-01-30T15:00:00+05:30"
}
---
### Update expected dates
POST /properties/{propertyId}/bookings/{bookingId}/expected-dates
Rules:
- OPEN → can update expectedCheckInAt and/or expectedCheckOutAt
- CHECKED_IN → can update only expectedCheckOutAt
- CHECKED_OUT / CANCELLED / NO_SHOW → forbidden
Body
{
"expectedCheckInAt": "2026-01-29T12:00:00+05:30",
"expectedCheckOutAt": "2026-01-30T10:00:00+05:30"
}
---
## 2) Guests
### Create guest + link to booking
@@ -296,7 +403,7 @@ Required:
- effectiveAt (String, ISO-8601)
- nightlyRate (Long)
- rateSource (PRESET|NEGOTIATED|OTA)
- rateSource (MANUAL|RATE_PLAN|OTA)
Optional:
@@ -306,7 +413,7 @@ Optional:
{
"effectiveAt": "2026-01-30T12:00:00+05:30",
"nightlyRate": 2000,
"rateSource": "NEGOTIATED",
"rateSource": "MANUAL",
"currency": "INR"
}
@@ -316,6 +423,57 @@ Response
---
### Check-out (closes all active stays on booking)
POST /properties/{propertyId}/bookings/{bookingId}/check-out
Auth: ADMIN/MANAGER
Body
{ "checkOutAt":"2026-01-30T10:00:00+05:30", "notes":"optional" }
Response: 204 No Content
---
### Bulk check-in (creates multiple room stays)
POST /properties/{propertyId}/bookings/{bookingId}/check-in/bulk
Body:
{
"stays": [
{
"roomId": "uuid",
"checkInAt": "2026-01-29T12:00:00+05:30",
"checkOutAt": "2026-01-30T10:00:00+05:30",
"nightlyRate": 6000,
"rateSource": "MANUAL",
"ratePlanCode": "EP",
"currency": "INR"
},
{
"roomId": "uuid",
"checkInAt": "2026-01-29T12:00:00+05:30",
"checkOutAt": "2026-01-30T10:00:00+05:30",
"nightlyRate": 8000,
"rateSource": "MANUAL",
"ratePlanCode": "EP",
"currency": "INR"
}
]
}
Behavior
- Creates one RoomStay per stay with its own rate.
- Sets booking CHECKED_IN, checkinAt = earliest stay check-in.
- If any checkOutAt provided, booking expectedCheckoutAt = latest of those.
- Rejects duplicate room IDs.
- Rejects invalid stay date range (checkOutAt <= checkInAt).
- Blocks occupied rooms.
## 6) Payments + Balance
### Add payment
@@ -373,3 +531,72 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
## 7) Compose Notes
- 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 {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
id("com.google.gms.google-services")
}
android {
extensions.configure<ApplicationExtension>("android") {
namespace = "com.android.trisolarispms"
compileSdk {
version = release(36)
}
compileSdk = 36
defaultConfig {
applicationId = "com.android.trisolarispms"
@@ -29,12 +32,21 @@ android {
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
@@ -55,10 +67,16 @@ dependencies {
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.okhttp.sse)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.lottie.compose)
implementation(libs.calendar.compose)
implementation(libs.libphonenumber)
implementation(libs.zxing.core)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services)

View File

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

View File

@@ -3,38 +3,15 @@ package com.android.trisolarispms
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.compose.BackHandler
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.AuthViewModel
import com.android.trisolarispms.ui.auth.NameScreen
import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.booking.BookingCreateScreen
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.home.HomeScreen
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.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.navigation.MainRouteContent
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
class MainActivity : ComponentActivity() {
@@ -46,274 +23,17 @@ class MainActivity : ComponentActivity() {
val authViewModel: AuthViewModel = viewModel()
val state by authViewModel.state.collectAsState()
if (state.unauthorized) {
UnauthorizedScreen(
when {
state.unauthorized -> UnauthorizedScreen(
message = state.error ?: "Not authorized. Contact admin.",
onSignOut = authViewModel::signOut
)
} else if (state.apiVerified && state.needsName) {
NameScreen(viewModel = authViewModel)
} else if (state.apiVerified) {
val route = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
val refreshKey = remember { mutableStateOf(0) }
val selectedPropertyId = remember { mutableStateOf<String?>(null) }
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 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
}
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.CreateBooking -> route.value = AppRoute.Home
is AppRoute.GuestInfo -> route.value = AppRoute.Home
}
}
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()) {
route.value = AppRoute.GuestInfo(currentRoute.propertyId, bookingId, guestId)
} 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.Home }
)
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) }
)
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)
state.apiVerified && state.needsName -> NameScreen(viewModel = authViewModel)
state.apiVerified -> MainRouteContent(
state = state,
authViewModel = authViewModel
)
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,18 +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

View File

@@ -1,66 +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.RoomStayDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Path
interface BookingApi {
@POST("properties/{propertyId}/bookings")
suspend fun createBooking(
@Path("propertyId") propertyId: String,
@Body body: BookingCreateRequest
): Response<BookingCreateResponse>
@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>
}

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 okhttp3.Authenticator
@@ -10,11 +10,11 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
fun create(
fun createOkHttpClient(
auth: FirebaseAuth = FirebaseAuth.getInstance(),
baseUrl: String = ApiConstants.BASE_URL,
enableLogging: Boolean = true
): ApiService {
enableLogging: Boolean = true,
readTimeoutSeconds: Long = 30
): OkHttpClient {
val tokenProvider = FirebaseAuthTokenProvider(auth)
val authInterceptor = Interceptor { chain ->
val original = chain.request()
@@ -52,14 +52,25 @@ object ApiClient {
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder()
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.authenticator(authenticator)
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.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()
.baseUrl(baseUrl)

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api
package com.android.trisolarispms.data.api.core
object ApiConstants {
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 {
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 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
import java.util.Locale
enum class BookingBillingMode {
PROPERTY_POLICY,
CUSTOM_WINDOW,
FULL_24H;
companion object {
fun from(value: String?): BookingBillingMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class BookingCheckInRequest(
val roomIds: List<String>,
val checkInAt: String? = null,
@@ -11,8 +26,13 @@ data class BookingCheckInRequest(
data class BookingCreateRequest(
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val billingMode: BookingBillingMode? = null,
val billingCheckoutTime: String? = null,
val source: String? = null,
val guestPhoneE164: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val childCount: Int? = null,
val maleCount: Int? = null,
@@ -30,6 +50,141 @@ data class BookingCreateResponse(
val expectedCheckOutAt: String? = null
)
data class BookingListItem(
val id: String? = null,
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
)
data class BookingBulkCheckInRequest(
val stays: List<BookingBulkCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
)
data class BookingBulkCheckInStayRequest(
val roomId: String,
val checkInAt: String,
val checkOutAt: String? = null,
val nightlyRate: Long? = null,
val rateSource: String? = null,
val ratePlanCode: String? = null,
val currency: String? = null
)
data class BookingExpectedDatesRequest(
val expectedCheckInAt: 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(
val id: String? = null,
val status: String? = null,
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: 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 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
)
data class BookingBillingPolicyUpdateRequest(
val billingMode: BookingBillingMode,
val billingCheckoutTime: String? = null
)
data class BookingLinkGuestRequest(
val guestId: String
)
@@ -39,6 +194,11 @@ data class BookingCheckOutRequest(
val notes: String? = null
)
data class BookingRoomStayCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest(
val cancelledAt: String? = null,
val reason: String? = null
@@ -49,39 +209,12 @@ data class BookingNoShowRequest(
val reason: String? = null
)
data class BookingRoomStayCreateRequest(
val roomId: String,
val fromAt: String,
val toAt: String,
val notes: String? = null
data class BookingBalanceResponse(
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null
)
// Room Stays
data class RoomStayCreateRequest(
val roomId: String,
val guestId: String? = null,
val checkIn: String? = null,
val checkOut: String? = null
)
data class RoomStayDto(
val id: String? = null,
val bookingId: String? = null,
val roomId: String? = null,
val status: String? = null
)
data class RoomChangeRequest(
val newRoomId: String,
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: String? = null,
val newRoomStayId: String? = null,
val oldRoomId: String? = null,
val newRoomId: String? = null,
val movedAt: String? = null
data class RoomStayVoidRequest(
val reason: String
)

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class GuestDto(
val id: String? = null,
val name: String? = null,
val phoneE164: String? = null,
@SerializedName(value = "dob", alternate = ["age"])
val dob: String? = null,
val nationality: String? = null,
val addressText: String? = null,
val vehicleNumbers: List<String> = emptyList(),
@@ -15,6 +19,7 @@ data class GuestCreateRequest(
val phoneE164: String? = null,
val name: String? = null,
val nationality: String? = null,
val age: String? = null,
val addressText: String? = null
)
@@ -22,6 +27,7 @@ data class GuestUpdateRequest(
val phoneE164: String? = null,
val name: String? = null,
val nationality: String? = null,
val age: String? = null,
val addressText: String? = null
)

View File

@@ -0,0 +1,39 @@
package com.android.trisolarispms.data.api.model
data class PaymentDto(
val id: String? = null,
val bookingId: String? = null,
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
)
data class PaymentCreateRequest(
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,7 +1,7 @@
package com.android.trisolarispms.data.api.model
data class PropertyCreateRequest(
val code: String,
val code: String? = null,
val name: String,
val addressText: String? = null,
val timezone: String? = null,
@@ -36,3 +36,7 @@ data class PropertyDto(
val emailAddresses: 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
import com.google.gson.annotations.SerializedName
data class RoomCreateRequest(
val roomNumber: Int,
val floor: Int? = null,
@@ -47,9 +49,14 @@ data class RoomAvailabilityResponse(
)
data class RoomAvailabilityRangeResponse(
@SerializedName(value = "roomTypeCode", alternate = ["code"])
val roomTypeCode: String? = null,
val roomTypeName: String? = null,
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null
val freeCount: Int? = null,
val averageRate: Double? = null,
val currency: String? = null,
val ratePlanCode: String? = null
)
// Images

View File

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

View File

@@ -0,0 +1,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.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.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.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.GuestCreateRequest
@@ -8,11 +8,14 @@ import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import com.android.trisolarispms.data.api.model.GuestVisitCountResponse
import com.android.trisolarispms.data.api.model.GuestVehicleDto
import com.android.trisolarispms.data.api.model.GuestVehicleRequest
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
@@ -49,6 +52,14 @@ interface GuestApi {
@Path("guestId") guestId: String
): Response<GuestDto>
@Multipart
@POST("properties/{propertyId}/guests/{guestId}/signature")
suspend fun uploadSignature(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Part file: MultipartBody.Part
): Response<Unit>
@POST("properties/{propertyId}/guests/{guestId}/vehicles")
suspend fun addGuestVehicle(
@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.GuestDocumentDto
import okhttp3.MultipartBody
@@ -7,9 +7,11 @@ import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.DELETE
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface GuestDocumentApi {
@@ -22,6 +24,15 @@ interface GuestDocumentApi {
@Part("bookingId") bookingId: RequestBody
): 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")
suspend fun listGuestDocuments(
@Path("propertyId") propertyId: String,
@@ -35,4 +46,11 @@ interface GuestDocumentApi {
@Path("guestId") guestId: String,
@Path("documentId") documentId: String
): 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 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 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.PropertyCreateRequest
import com.android.trisolarispms.data.api.model.PropertyDto
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 retrofit2.Response
import retrofit2.http.Body
@@ -28,18 +30,30 @@ interface PropertyApi {
): Response<PropertyDto>
@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")
suspend fun updateUserRoles(
@Path("propertyId") propertyId: String,
@Path("userId") userId: String,
@Body body: UserRolesUpdateRequest
): Response<ActionResponse>
): Response<PropertyUserResponse>
@DELETE("properties/{propertyId}/users/{userId}")
suspend fun deletePropertyUser(
@Path("propertyId") propertyId: String,
@Path("userId") userId: String
): 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.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,4 +1,4 @@
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.RoomAvailabilityResponse
@@ -54,7 +54,8 @@ interface RoomApi {
suspend fun getRoomAvailabilityRange(
@Path("propertyId") propertyId: String,
@Query("from") from: String,
@Query("to") to: String
@Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available")

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 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.RoomStayVoidRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
@@ -10,13 +9,13 @@ import retrofit2.http.POST
import retrofit2.http.Path
interface RoomStayApi {
@POST("properties/{propertyId}/room-stays/{roomStayId}/change-room")
suspend fun changeRoom(
@Path("propertyId") propertyId: String,
@Path("roomStayId") roomStayId: String,
@Body body: RoomChangeRequest
): Response<RoomChangeResponse>
@GET("properties/{propertyId}/room-stays/active")
suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response<List<ActiveRoomStayDto>>
@POST("properties/{propertyId}/room-stays/{roomStayId}/void")
suspend fun voidRoomStay(
@Path("propertyId") propertyId: String,
@Path("roomStayId") roomStayId: String,
@Body body: RoomStayVoidRequest
): Response<Unit>
}

View File

@@ -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.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 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,30 +0,0 @@
package com.android.trisolarispms.ui
sealed interface AppRoute {
data object Home : AppRoute
data class CreateBooking(val propertyId: String) : AppRoute
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: String) : AppRoute
data class AddRoom(val propertyId: String) : AppRoute
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
data class IssueTemporaryCard(val propertyId: String, val roomId: String) : AppRoute
data class CardInfo(val propertyId: String) : AppRoute
data class RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
data class RatePlanCalendar(
val propertyId: String,
val roomTypeId: String,
val ratePlanId: String,
val ratePlanCode: String
) : AppRoute
data object Amenities : AppRoute
data object AddAmenity : AppRoute
data class EditAmenity(val amenityId: String) : AppRoute
data class RoomImages(val propertyId: String, val roomId: String) : AppRoute
data object ImageTags : AppRoute
data object AddImageTag : AppRoute
data class EditImageTag(val tagId: String) : AppRoute
}

View File

@@ -1,6 +1,9 @@
package com.android.trisolarispms.ui.auth
import androidx.activity.ComponentActivity
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -17,47 +20,90 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import kotlinx.coroutines.delay
@Composable
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
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(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center
) {
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = state.phone,
onValueChange = viewModel::onPhoneChange,
label = { Text("Phone number") },
placeholder = { Text("10-digit mobile") },
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
PhoneNumberCountryField(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange
)
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(
onClick = {
if (activity != null) {
@@ -66,9 +112,9 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
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))
@@ -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) {
Spacer(modifier = Modifier.height(12.dp))
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
import com.android.trisolarispms.core.auth.Role
data class AuthUiState(
val countryCode: String = "+91",
val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
val phone: String = "",
val code: String = "",
val isLoading: Boolean = false,
val isCodeSent: Boolean = false,
val resendAvailableAt: Long? = null,
val verificationId: String? = null,
val error: String? = null,
val userId: String? = null,
@@ -16,5 +21,5 @@ data class AuthUiState(
val nameInput: String = "",
val needsName: 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.lifecycle.ViewModel
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.auth.FirebaseAuth
import com.google.firebase.auth.PhoneAuthCredential
@@ -14,6 +16,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
class AuthViewModel(
@@ -23,6 +28,7 @@ class AuthViewModel(
val state: StateFlow<AuthUiState> = _state
private var resendToken: PhoneAuthProvider.ForceResendingToken? = null
private var resendCooldownJob: Job? = null
init {
val user = auth.currentUser
@@ -31,13 +37,28 @@ class AuthViewModel(
}
}
fun onPhoneChange(value: String) {
val digits = value.filter { it.isDigit() }.take(10)
_state.update { it.copy(phone = digits, error = null) }
fun onPhoneCountryChange(value: String) {
val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(value)
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) {
_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) {
@@ -48,6 +69,15 @@ class AuthViewModel(
_state.update { it.copy(error = null) }
}
fun retryAfterConnectivityIssue() {
val currentUser = auth.currentUser
if (currentUser != null) {
verifyExistingSession(currentUser.uid)
} else {
clearError()
}
}
fun reportError(message: String) {
setError(message)
}
@@ -57,9 +87,14 @@ class AuthViewModel(
}
fun sendCode(activity: ComponentActivity) {
val now = System.currentTimeMillis()
val resendAt = state.value.resendAvailableAt
if (resendAt != null && now < resendAt) {
return
}
val phone = buildE164Phone()
if (phone == null) {
setError("Enter a valid 10-digit phone number")
setError("Enter a valid phone number")
return
}
@@ -71,7 +106,7 @@ class AuthViewModel(
}
override fun onVerificationFailed(e: FirebaseException) {
setError(e.localizedMessage ?: "Verification failed")
setError(mapThrowableToMessage(e, fallback = "Verification failed"))
}
override fun onCodeSent(
@@ -83,10 +118,12 @@ class AuthViewModel(
it.copy(
isLoading = false,
isCodeSent = true,
resendAvailableAt = System.currentTimeMillis() + 60_000,
verificationId = verificationId,
error = null
)
}
startResendCooldown()
}
}
@@ -104,9 +141,10 @@ class AuthViewModel(
}
private fun buildE164Phone(): String? {
val digits = state.value.phone.trim()
if (digits.length != 10) return null
return "${state.value.countryCode}$digits"
val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode)
val digits = state.value.phoneNationalNumber.trim()
if (digits.length != option.maxLength) return null
return "+${option.dialCode}$digits"
}
fun verifyCode() {
@@ -121,6 +159,10 @@ class AuthViewModel(
setError("Enter the code")
return
}
if (code.length != 6) {
setError("Enter the 6-digit code")
return
}
val credential = PhoneAuthProvider.getCredential(verificationId, code)
signInWithCredential(credential)
@@ -136,11 +178,30 @@ class AuthViewModel(
val response = api.verifyAuth()
handleVerifyResponse(userId, response)
} catch (e: Exception) {
setError(e.localizedMessage ?: "Sign-in failed")
setError(mapThrowableToMessage(e, fallback = "Sign-in failed"))
}
}
}
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) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, userId = userId) }
@@ -149,7 +210,7 @@ class AuthViewModel(
val response = api.verifyAuth()
handleVerifyResponse(userId, response)
} catch (e: Exception) {
setError(e.localizedMessage ?: "Session verify failed")
setError(mapThrowableToMessage(e, fallback = "Session verify failed"))
}
}
}
@@ -159,50 +220,25 @@ class AuthViewModel(
response: retrofit2.Response<com.android.trisolarispms.data.api.model.AuthVerifyResponse>
) {
val body = response.body()
val status = body?.status
val userName = body?.user?.name
val isSuperAdmin = body?.user?.superAdmin == true
val propertyRoles = body?.properties
.orEmpty()
.mapNotNull { entry ->
val id = entry.propertyId
id?.let { it to entry.roles.orEmpty() }
id?.let { it to entry.roles.toRoles() }
}
.toMap()
when {
response.isSuccessful && (body?.status == "OK" || body?.status == "SUPER_ADMIN") -> {
_state.update {
it.copy(
isLoading = false,
userId = userId,
apiVerified = true,
needsName = userName.isNullOrBlank(),
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.isSuccessful && (status == "OK" || status == "SUPER_ADMIN" || status == "NO_PROPERTIES") ->
setVerifiedState(
userId = userId,
userName = userName,
isSuperAdmin = isSuperAdmin,
propertyRoles = propertyRoles,
noProperties = status == "NO_PROPERTIES"
)
response.code() == 401 -> {
_state.update {
it.copy(
@@ -225,13 +261,37 @@ class AuthViewModel(
noProperties = false,
unauthorized = false,
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() {
auth.signOut()
_state.update { AuthUiState() }
@@ -259,10 +319,10 @@ class AuthViewModel(
)
}
} else {
setError("Update failed: ${response.code()}")
setError(mapHttpError(response.code(), "Update failed"))
}
} catch (e: Exception) {
setError(e.localizedMessage ?: "Update failed")
setError(mapThrowableToMessage(e, fallback = "Update failed"))
}
}
}
@@ -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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -10,28 +8,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.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.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -44,17 +31,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.CalendarMonth
import com.kizitonwose.calendar.core.DayPosition
import com.kizitonwose.calendar.core.daysOfWeek
import com.android.trisolarispms.core.booking.BookingProfileOptions
import com.android.trisolarispms.ui.common.CityAutocompleteField
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
import com.android.trisolarispms.ui.common.SaveTopBarScaffold
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
@@ -66,114 +49,136 @@ fun BookingCreateScreen(
viewModel: BookingCreateViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val showCheckInPicker = remember { mutableStateOf(false) }
val showCheckOutPicker = remember { mutableStateOf(false) }
val showCheckInDatePicker = remember { mutableStateOf(false) }
val showCheckInTimePicker = remember { mutableStateOf(false) }
val showCheckOutDatePicker = remember { mutableStateOf(false) }
val showCheckOutTimePicker = remember { mutableStateOf(false) }
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val checkInNow = remember { mutableStateOf(true) }
val sourceMenuExpanded = remember { mutableStateOf(false) }
val sourceOptions = listOf("WALKIN", "OTA", "AGENT")
val relationMenuExpanded = remember { mutableStateOf(false) }
val transportMenuExpanded = remember { mutableStateOf(false) }
val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
val billingModeMenuExpanded = remember { mutableStateOf(false) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
val applyCheckInSelection: (LocalDate, String) -> Unit = { date, time ->
checkInDate.value = date
checkInTime.value = time
val checkInAt = formatBookingIso(date, time)
viewModel.onExpectedCheckInAtChange(checkInAt)
val currentCheckOutDate = checkOutDate.value
if (currentCheckOutDate != null && currentCheckOutDate.isBefore(date)) {
checkOutDate.value = date
val adjustedCheckOutAt = formatBookingIso(date, checkOutTime.value)
viewModel.onExpectedCheckOutAtChange(adjustedCheckOutAt)
}
viewModel.autoSetBillingFromCheckIn(checkInAt)
viewModel.refreshExpectedCheckoutPreview(propertyId)
}
LaunchedEffect(propertyId) {
viewModel.reset()
viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
checkInNow.value = true
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate
checkOutTime.value = "11:00"
viewModel.onExpectedCheckOutAtChange(formatIso(defaultCheckoutDate, checkOutTime.value))
viewModel.onExpectedCheckOutAtChange(formatBookingIso(defaultCheckoutDate, checkOutTime.value))
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Booking") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submit(propertyId, onCreated) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
LaunchedEffect(state.expectedCheckOutAt) {
val parsed = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull() ?: return@LaunchedEffect
checkOutDate.value = parsed.toLocalDate()
checkOutTime.value = parsed.format(timeFormatter)
}
SaveTopBarScaffold(
title = "Create Booking",
onBack = onBack,
onSave = { viewModel.submit(propertyId, onCreated) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Check in now")
Switch(
checked = checkInNow.value,
onCheckedChange = { enabled ->
checkInNow.value = enabled
if (enabled) {
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
} else {
viewModel.onExpectedCheckInAtChange("")
}
}
)
val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
if (!checkInNow.value) {
Spacer(modifier = Modifier.height(8.dp))
val checkOutDisplay = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val cardDateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val cardTimeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
val totalTimeText = remember(state.expectedCheckInAt, state.expectedCheckOutAt) {
val start = runCatching { OffsetDateTime.parse(state.expectedCheckInAt) }.getOrNull()
val end = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull()
formatBookingDurationText(start, end)
}
BookingDateTimeQuickEditorCard(
checkInDateText = checkInDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkInTimeText = checkInDisplay?.format(cardTimeFormatter) ?: "--:--",
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
totalTimeText = totalTimeText,
checkInEditable = true,
onCheckInDateClick = { showCheckInDatePicker.value = true },
onCheckInTimeClick = { showCheckInTimePicker.value = true },
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
onCheckOutTimeClick = { showCheckOutTimePicker.value = true }
)
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = billingModeMenuExpanded.value,
onExpandedChange = { billingModeMenuExpanded.value = !billingModeMenuExpanded.value }
) {
OutlinedTextField(
value = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
value = state.billingMode.name,
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
label = { Text("Billing Mode") },
trailingIcon = {
IconButton(onClick = { showCheckInPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
ExposedDropdownMenuDefaults.TrailingIcon(expanded = billingModeMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = billingModeMenuExpanded.value,
onDismissRequest = { billingModeMenuExpanded.value = false }
) {
BookingBillingMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.name) },
onClick = {
billingModeMenuExpanded.value = false
viewModel.onBillingModeChange(mode)
viewModel.refreshExpectedCheckoutPreview(propertyId)
}
)
}
}
}
if (state.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
Spacer(modifier = Modifier.height(12.dp))
BookingTimePickerTextField(
value = state.billingCheckoutTime,
label = { Text("Billing check-out (HH:mm)") },
onTimeSelected = { selectedTime ->
viewModel.onBillingCheckoutTimeChange(selectedTime)
viewModel.refreshExpectedCheckoutPreview(propertyId)
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
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 phoneDigitsLength = state.phoneNationalNumber.length
val phoneIsComplete = phoneDigitsLength == selectedCountry.maxLength
@@ -219,97 +224,59 @@ fun BookingCreateScreen(
}
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
ExposedDropdownMenuBox(
expanded = phoneCountryMenuExpanded.value,
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
modifier = Modifier.weight(0.3f)
) {
OutlinedTextField(
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
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)
)
}
PhoneNumberCountryField(
phoneCountryCode = state.phoneCountryCode,
onPhoneCountryCodeChange = viewModel::onPhoneCountryChange,
phoneNationalNumber = state.phoneNationalNumber,
onPhoneNationalNumberChange = viewModel::onPhoneNationalNumberChange,
countryWeight = 0.3f,
numberWeight = 0.7f
)
Spacer(modifier = Modifier.height(12.dp))
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,
onValueChange = viewModel::onToCityChange,
label = "To City (optional)",
suggestions = state.toCitySuggestions,
isLoading = state.isToCitySearchLoading,
onSuggestionSelected = viewModel::onToCitySuggestionSelected
)
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = sourceMenuExpanded.value,
onExpandedChange = { sourceMenuExpanded.value = !sourceMenuExpanded.value }
expanded = relationMenuExpanded.value,
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
) {
OutlinedTextField(
value = state.source,
value = state.memberRelation,
onValueChange = {},
readOnly = true,
label = { Text("Source") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceMenuExpanded.value) },
label = { Text("Member Relation (optional)") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = sourceMenuExpanded.value,
onDismissRequest = { sourceMenuExpanded.value = false }
expanded = relationMenuExpanded.value,
onDismissRequest = { relationMenuExpanded.value = false }
) {
sourceOptions.forEach { option ->
BookingProfileOptions.memberRelations.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
sourceMenuExpanded.value = false
viewModel.onSourceChange(option)
relationMenuExpanded.value = false
viewModel.onMemberRelationChange(option)
}
)
}
@@ -321,7 +288,7 @@ fun BookingCreateScreen(
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) {
OutlinedTextField(
value = state.transportMode,
value = state.transportMode.ifBlank { "Not set" },
onValueChange = {},
readOnly = true,
label = { Text("Transport Mode") },
@@ -336,9 +303,10 @@ fun BookingCreateScreen(
expanded = transportMenuExpanded.value,
onDismissRequest = { transportMenuExpanded.value = false }
) {
transportOptions.forEach { option ->
BookingProfileOptions.transportModes.forEach { option ->
val optionLabel = option.ifBlank { "Not set" }
DropdownMenuItem(
text = { Text(option) },
text = { Text(optionLabel) },
onClick = {
transportMenuExpanded.value = false
viewModel.onTransportModeChange(option)
@@ -405,187 +373,51 @@ fun BookingCreateScreen(
}
}
if (showCheckInPicker.value) {
DateTimePickerDialog(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
if (showCheckInDatePicker.value) {
BookingDatePickerDialog(
initialDate = checkInDate.value ?: LocalDate.now(),
minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false },
onConfirm = { date, time ->
checkInDate.value = date
checkInTime.value = time
val formatted = formatIso(date, time)
viewModel.onExpectedCheckInAtChange(formatted)
showCheckInPicker.value = false
onDismiss = { showCheckInDatePicker.value = false },
onDateSelected = { selectedDate ->
applyCheckInSelection(selectedDate, checkInTime.value)
}
)
}
if (showCheckOutPicker.value) {
DateTimePickerDialog(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
if (showCheckInTimePicker.value) {
BookingTimePickerDialog(
initialTime = checkInTime.value,
onDismiss = { showCheckInTimePicker.value = false },
onTimeSelected = { selectedTime ->
val selectedDate = checkInDate.value ?: LocalDate.now()
applyCheckInSelection(selectedDate, selectedTime)
}
)
}
if (showCheckOutDatePicker.value) {
BookingDatePickerDialog(
initialDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now()),
minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false },
onConfirm = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
val formatted = formatIso(date, time)
onDismiss = { showCheckOutDatePicker.value = false },
onDateSelected = { selectedDate ->
checkOutDate.value = selectedDate
val formatted = formatBookingIso(selectedDate, checkOutTime.value)
viewModel.onExpectedCheckOutAtChange(formatted)
}
)
}
if (showCheckOutTimePicker.value) {
BookingTimePickerDialog(
initialTime = checkOutTime.value,
onDismiss = { showCheckOutTimePicker.value = false },
onTimeSelected = { selectedTime ->
checkOutTime.value = selectedTime
val selectedDate = checkOutDate.value ?: (checkInDate.value ?: LocalDate.now())
val formatted = formatBookingIso(selectedDate, selectedTime)
viewModel.onExpectedCheckOutAtChange(formatted)
showCheckOutPicker.value = false
}
)
}
}
@Composable
private fun DateTimePickerDialog(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, String) -> Unit
) {
val today = remember { LocalDate.now() }
val currentMonth = remember { YearMonth.from(today) }
val startMonth = remember { currentMonth }
val endMonth = remember { currentMonth.plusMonths(24) }
val daysOfWeek = remember { daysOfWeek() }
val calendarState = rememberCalendarState(
startMonth = startMonth,
endMonth = endMonth,
firstVisibleMonth = currentMonth,
firstDayOfWeek = daysOfWeek.first()
)
val selectedDate = remember { mutableStateOf(initialDate ?: today) }
val timeValue = remember { mutableStateOf(initialTime) }
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
DaysOfWeekHeader(daysOfWeek)
HorizontalCalendar(
state = calendarState,
dayContent = { day ->
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
DayCell(
day = day,
isSelectedStart = selectedDate.value == day.date,
isSelectedEnd = false,
isInRange = false,
hasRate = false,
isSelectable = selectable,
onClick = { selectedDate.value = day.date }
)
},
monthHeader = { month ->
MonthHeader(month)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = timeValue.value,
onValueChange = { timeValue.value = it },
label = { Text("Time (HH:MM)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
val time = timeValue.value.ifBlank { initialTime }
onConfirm(selectedDate.value, time)
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun DaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
daysOfWeek.forEach { day ->
Text(
text = day.name.take(3),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
@Composable
private fun MonthHeader(month: CalendarMonth) {
Text(
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun DayCell(
day: CalendarDay,
isSelectedStart: Boolean,
isSelectedEnd: Boolean,
isInRange: Boolean,
hasRate: Boolean,
isSelectable: Boolean,
onClick: () -> Unit
) {
val isInMonth = day.position == DayPosition.MonthDate
val background = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
isInRange -> MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> Color.Transparent
}
val textColor = when {
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
!isSelectable -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
Column(
modifier = Modifier
.size(40.dp)
.padding(2.dp)
.background(background, shape = MaterialTheme.shapes.small)
.clickable(enabled = isSelectable) { onClick() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
}
}
private fun formatIso(date: LocalDate, time: String): String {
val parts = time.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
val zone = ZoneId.of("Asia/Kolkata")
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
val offset = zone.rules.getOffset(localDateTime)
return OffsetDateTime.of(localDateTime, offset)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
package com.android.trisolarispms.ui.guest
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.Locale
import com.android.trisolarispms.ui.common.BackTopBarScaffold
import com.android.trisolarispms.ui.common.PaddedScreenColumn
@Composable
fun GuestSignatureScreen(
propertyId: String,
bookingId: String,
guestId: String,
onBack: () -> Unit,
onDone: () -> Unit,
viewModel: GuestSignatureViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val strokes = remember { mutableStateListOf<MutableList<Offset>>() }
val canvasSize = remember { mutableStateOf(IntSize.Zero) }
LaunchedEffect(guestId) {
viewModel.reset()
}
BackTopBarScaffold(
title = "Guest Signature",
onBack = onBack,
actions = {
IconButton(
onClick = {
val svg = buildSignatureSvg(strokes, canvasSize.value)
if (!svg.isNullOrBlank()) {
viewModel.uploadSignature(propertyId, bookingId, guestId, svg, onDone)
}
},
enabled = strokes.isNotEmpty() && !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Done, contentDescription = "Upload")
}
}
}
) { padding ->
PaddedScreenColumn(padding = padding) {
Text(
text = "Please draw the guest signature below.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
Canvas(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.border(1.dp, MaterialTheme.colorScheme.outline)
.clipToBounds()
.onSizeChanged { canvasSize.value = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val stroke = mutableStateListOf(down.position)
strokes.add(stroke)
drag(down.id) { change ->
stroke.add(change.position)
change.consume()
}
}
}
) {
val strokeColor = Color.Black
val strokeWidth = 3.dp.toPx()
strokes.forEach { stroke ->
if (stroke.size == 1) {
drawCircle(
color = strokeColor,
radius = strokeWidth / 2f,
center = stroke.first()
)
} else {
val path = Path()
path.moveTo(stroke.first().x, stroke.first().y)
for (i in 1 until stroke.size) {
val point = stroke[i]
path.lineTo(point.x, point.y)
}
drawPath(
path = path,
color = strokeColor,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round)
)
}
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { strokes.clear() },
enabled = strokes.isNotEmpty() && !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Clear")
}
}
}
}
private fun buildSignatureSvg(strokes: List<List<Offset>>, canvasSize: IntSize): String? {
if (strokes.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null
val width = canvasSize.width
val height = canvasSize.height
val sb = StringBuilder()
sb.append("""<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" viewBox="0 0 $width $height">""")
strokes.forEach { stroke ->
if (stroke.isNotEmpty()) {
sb.append("<path d=\"")
stroke.forEachIndexed { index, point ->
val x = String.format(Locale.US, "%.2f", point.x)
val y = String.format(Locale.US, "%.2f", point.y)
if (index == 0) {
sb.append("M $x $y ")
} else {
sb.append("L $x $y ")
}
}
sb.append("\" fill=\"none\" stroke=\"#000000\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>")
}
}
sb.append("</svg>")
return sb.toString()
}

View File

@@ -0,0 +1,6 @@
package com.android.trisolarispms.ui.guest
data class GuestSignatureState(
val isLoading: Boolean = false,
val error: String? = null
)

View File

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

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.guestdocs
import com.android.trisolarispms.data.api.model.GuestDocumentDto
data class GuestDocumentsState(
val isLoading: Boolean = false,
val isUploading: Boolean = false,
val error: String? = null,
val documents: List<GuestDocumentDto> = emptyList()
)

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