Compare commits
52 Commits
be52b58165
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69a01a460 | ||
|
|
1000f2411c | ||
|
|
e9c3b4f669 | ||
|
|
90c2b6fb9f | ||
|
|
1e5f412f82 | ||
|
|
a67eacd77f | ||
|
|
f9b09e2376 | ||
|
|
e1250a0f32 | ||
|
|
d69ed60a6e | ||
|
|
56f13f5e79 | ||
|
|
9555ae2e40 | ||
|
|
9d942d6411 | ||
|
|
3a90aa848d | ||
|
|
eab5517f9b | ||
|
|
b0c28d0aa4 | ||
|
|
dcaaba92dd | ||
|
|
52a6d379b0 | ||
|
|
4e5f368256 | ||
|
|
d6c8e522de | ||
|
|
18c5cb814d | ||
|
|
a691e84fd8 | ||
|
|
d54a9af5ee | ||
|
|
8c790fbce0 | ||
|
|
f97834291d | ||
|
|
0f0db0dcf5 | ||
|
|
99ce18a435 | ||
|
|
342ff6a237 | ||
|
|
86307a66c8 | ||
|
|
3219e40a02 | ||
|
|
4c31a20af4 | ||
|
|
8f62459d5e | ||
|
|
43ee7311e8 | ||
|
|
2b0f352ced | ||
|
|
53300a6a84 | ||
|
|
9ac0b55b89 | ||
|
|
4fc080f146 | ||
|
|
c5e0648dd1 | ||
|
|
2d75b88892 | ||
|
|
be820391bc | ||
|
|
9d3ade3d03 | ||
|
|
f593306c50 | ||
|
|
dffa8b14cd | ||
|
|
5f522ca3ab | ||
|
|
29065cee22 | ||
|
|
799c0b44b9 | ||
|
|
869e59aaac | ||
|
|
3a7667c609 | ||
|
|
8bd2c2eeae | ||
|
|
726f07bff4 | ||
|
|
3fe0730f4c | ||
|
|
1068e05c4a | ||
|
|
65a41863e2 |
602
AGENTS.md
Normal file
602
AGENTS.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# TrisolarisPMS API Usage
|
||||
|
||||
## API Docs Path
|
||||
|
||||
- `/home/androidlover5842/IdeaProjects/TrisolarisServer/docs`
|
||||
|
||||
## 1) Booking
|
||||
|
||||
### Create booking
|
||||
|
||||
POST /properties/{propertyId}/bookings
|
||||
Auth: ADMIN/MANAGER/STAFF
|
||||
|
||||
Body (JSON)
|
||||
Required:
|
||||
|
||||
- expectedCheckInAt (String, ISO-8601, required)
|
||||
- expectedCheckOutAt (String, ISO-8601, required)
|
||||
|
||||
Optional:
|
||||
|
||||
- source (String, default "WALKIN")
|
||||
- transportMode (String enum)
|
||||
- adultCount (Int)
|
||||
- totalGuestCount (Int)
|
||||
- notes (String)
|
||||
|
||||
{
|
||||
"source": "WALKIN",
|
||||
"expectedCheckInAt": "2026-01-28T12:00:00+05:30",
|
||||
"expectedCheckOutAt": "2026-01-29T10:00:00+05:30",
|
||||
"transportMode": "CAR",
|
||||
"adultCount": 2,
|
||||
"totalGuestCount": 3,
|
||||
"notes": "Late arrival"
|
||||
}
|
||||
|
||||
Behavior
|
||||
If expectedCheckInAt >= now(property timezone) -> booking becomes CHECKED_IN, and checkinAt is set, expected fields are null.
|
||||
|
||||
Response
|
||||
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "OPEN|CHECKED_IN",
|
||||
"checkInAt": "2026-01-28T12:00:00+05:30" | null,
|
||||
"expectedCheckInAt": "..." | null,
|
||||
"expectedCheckOutAt": "..." | null
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- roomIds (List<UUID>)
|
||||
|
||||
Optional:
|
||||
|
||||
- checkInAt (String)
|
||||
- transportMode (String enum)
|
||||
- nightlyRate (Long)
|
||||
- rateSource (MANUAL|RATE_PLAN|OTA)
|
||||
- ratePlanCode (String)
|
||||
- currency (String)
|
||||
- notes (String)
|
||||
|
||||
{
|
||||
"roomIds": ["uuid1","uuid2"],
|
||||
"checkInAt": "2026-01-28T12:00:00+05:30",
|
||||
"nightlyRate": 2500,
|
||||
"rateSource": "MANUAL",
|
||||
"ratePlanCode": "EP",
|
||||
"currency": "INR",
|
||||
"notes": "Late arrival"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### Pre-assign room stay
|
||||
|
||||
POST /properties/{propertyId}/bookings/{bookingId}/room-stays
|
||||
Auth: ADMIN/MANAGER
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- roomId (UUID)
|
||||
- fromAt (String)
|
||||
- toAt (String)
|
||||
|
||||
Optional:
|
||||
|
||||
- nightlyRate (Long)
|
||||
- rateSource (MANUAL|RATE_PLAN|OTA)
|
||||
- ratePlanCode (String)
|
||||
- currency (String)
|
||||
- notes (String)
|
||||
|
||||
{
|
||||
"roomId": "uuid",
|
||||
"fromAt": "2026-01-29T12:00:00+05:30",
|
||||
"toAt": "2026-01-30T10:00:00+05:30",
|
||||
"nightlyRate": 2800,
|
||||
"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
|
||||
|
||||
POST /properties/{propertyId}/guests
|
||||
Auth: property member
|
||||
Body (required):
|
||||
|
||||
- bookingId (UUID)
|
||||
|
||||
Optional:
|
||||
|
||||
- phoneE164 (String)
|
||||
- name (String)
|
||||
- nationality (String)
|
||||
- addressText (String)
|
||||
|
||||
{
|
||||
"bookingId": "uuid",
|
||||
"phoneE164": "+911111111111",
|
||||
"name": "John",
|
||||
"nationality": "IN",
|
||||
"addressText": "Varanasi"
|
||||
}
|
||||
|
||||
Behavior:
|
||||
|
||||
- If phone already exists -> links existing guest to booking and returns it.
|
||||
- If booking already has a guest -> 409.
|
||||
|
||||
Response (GuestResponse)
|
||||
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "John",
|
||||
"phoneE164": "+911111111111",
|
||||
"nationality": "IN",
|
||||
"addressText": "Varanasi",
|
||||
"signatureUrl": "/properties/{propertyId}/guests/{guestId}/signature/file",
|
||||
"vehicleNumbers": [],
|
||||
"averageScore": null
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### Add guest vehicle + link to booking
|
||||
|
||||
POST /properties/{propertyId}/guests/{guestId}/vehicles
|
||||
Auth: property member
|
||||
Body:
|
||||
|
||||
{ "vehicleNumber": "UP32AB1234", "bookingId": "uuid" }
|
||||
|
||||
---
|
||||
|
||||
### Upload signature (SVG only)
|
||||
|
||||
POST /properties/{propertyId}/guests/{guestId}/signature
|
||||
Auth: ADMIN/MANAGER
|
||||
Multipart:
|
||||
|
||||
- file (SVG)
|
||||
|
||||
---
|
||||
|
||||
### Download signature
|
||||
|
||||
GET /properties/{propertyId}/guests/{guestId}/signature/file
|
||||
Auth: property member
|
||||
Returns image/svg+xml.
|
||||
|
||||
---
|
||||
|
||||
### Search guest by phone
|
||||
|
||||
GET /properties/{propertyId}/guests/search?phone=+911111111111
|
||||
|
||||
---
|
||||
|
||||
## 3) Room Types (default rate + rate resolve)
|
||||
|
||||
### Room type create/update
|
||||
|
||||
Fields now include defaultRate:
|
||||
|
||||
RoomTypeUpsertRequest
|
||||
|
||||
{
|
||||
"code": "DELUX",
|
||||
"name": "Deluxe",
|
||||
"baseOccupancy": 2,
|
||||
"maxOccupancy": 3,
|
||||
"sqFeet": 150,
|
||||
"bathroomSqFeet": 30,
|
||||
"defaultRate": 2500,
|
||||
"active": true,
|
||||
"otaAliases": [],
|
||||
"amenityIds": []
|
||||
}
|
||||
|
||||
### Resolve preset rate for date
|
||||
|
||||
GET /properties/{propertyId}/room-types/{roomTypeCode}/rate?date=YYYY-MM-DD&ratePlanCode=optional
|
||||
Auth: public if no auth, or member
|
||||
|
||||
Response
|
||||
|
||||
{
|
||||
"roomTypeCode": "DELUX",
|
||||
"rateDate": "2026-02-01",
|
||||
"rate": 2800,
|
||||
"currency": "INR",
|
||||
"ratePlanCode": "WEEKEND"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## 4) Rate Plans + Calendar
|
||||
|
||||
### Create rate plan
|
||||
|
||||
POST /properties/{propertyId}/rate-plans
|
||||
Auth: ADMIN/MANAGER
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- code (String)
|
||||
- name (String)
|
||||
- roomTypeCode (String)
|
||||
- baseRate (Long)
|
||||
|
||||
Optional:
|
||||
|
||||
- currency (String, default property currency)
|
||||
|
||||
{ "code":"WEEKEND", "name":"Weekend", "roomTypeCode":"DELUX", "baseRate":2800, "currency":"INR" }
|
||||
|
||||
Response RatePlanResponse
|
||||
|
||||
### List plans
|
||||
|
||||
GET /properties/{propertyId}/rate-plans?roomTypeCode=optional
|
||||
Auth: member
|
||||
|
||||
### Update
|
||||
|
||||
PUT /properties/{propertyId}/rate-plans/{ratePlanId}
|
||||
Body:
|
||||
|
||||
{ "name":"Weekend", "baseRate":3000, "currency":"INR" }
|
||||
|
||||
### Delete
|
||||
|
||||
DELETE /properties/{propertyId}/rate-plans/{ratePlanId}
|
||||
|
||||
### Calendar upsert (batch)
|
||||
|
||||
POST /properties/{propertyId}/rate-plans/{ratePlanId}/calendar
|
||||
Body: Array
|
||||
|
||||
[
|
||||
{ "rateDate":"2026-02-01", "rate":3200 },
|
||||
{ "rateDate":"2026-02-02", "rate":3500 }
|
||||
]
|
||||
|
||||
### Calendar list
|
||||
|
||||
GET /properties/{propertyId}/rate-plans/{ratePlanId}/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
|
||||
### Calendar delete
|
||||
|
||||
DELETE /properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate}
|
||||
|
||||
---
|
||||
|
||||
## 5) RoomStay rate change (mid-stay renegotiation)
|
||||
|
||||
POST /properties/{propertyId}/room-stays/{roomStayId}/change-rate
|
||||
Auth: ADMIN/MANAGER
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- effectiveAt (String, ISO-8601)
|
||||
- nightlyRate (Long)
|
||||
- rateSource (MANUAL|RATE_PLAN|OTA)
|
||||
|
||||
Optional:
|
||||
|
||||
- ratePlanCode (String)
|
||||
- currency (String)
|
||||
|
||||
{
|
||||
"effectiveAt": "2026-01-30T12:00:00+05:30",
|
||||
"nightlyRate": 2000,
|
||||
"rateSource": "MANUAL",
|
||||
"currency": "INR"
|
||||
}
|
||||
|
||||
Response
|
||||
|
||||
{ "oldRoomStayId":"uuid", "newRoomStayId":"uuid", "effectiveAt":"..." }
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
POST /properties/{propertyId}/bookings/{bookingId}/payments
|
||||
Auth: ADMIN/MANAGER/STAFF
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- amount (Long)
|
||||
- method (CASH|CARD|UPI|BANK|ONLINE)
|
||||
|
||||
Optional:
|
||||
|
||||
- currency (String, default property currency)
|
||||
- reference (String)
|
||||
- notes (String)
|
||||
- receivedAt (String)
|
||||
|
||||
{
|
||||
"amount": 1200,
|
||||
"method": "CASH",
|
||||
"currency": "INR",
|
||||
"reference": "RCP-123",
|
||||
"notes": "Advance"
|
||||
}
|
||||
|
||||
Response
|
||||
|
||||
{
|
||||
"id":"uuid",
|
||||
"bookingId":"uuid",
|
||||
"amount":1200,
|
||||
"currency":"INR",
|
||||
"method":"CASH",
|
||||
"reference":"RCP-123",
|
||||
"notes":"Advance",
|
||||
"receivedAt":"2026-01-28T12:00:00+05:30",
|
||||
"receivedByUserId":"uuid"
|
||||
}
|
||||
|
||||
### List payments
|
||||
|
||||
GET /properties/{propertyId}/bookings/{bookingId}/payments
|
||||
|
||||
### Booking balance
|
||||
|
||||
GET /properties/{propertyId}/bookings/{bookingId}/balance
|
||||
|
||||
{ "expectedPay": 2745, "amountCollected": 1200, "pending": 1545 }
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
@@ -1,18 +1,21 @@
|
||||
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"
|
||||
minSdk = 23
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
@@ -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,8 +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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,30 +6,12 @@ import androidx.activity.compose.setContent
|
||||
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.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.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.RoomTypesScreen
|
||||
import com.android.trisolarispms.ui.navigation.MainRouteContent
|
||||
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -41,183 +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 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)
|
||||
}
|
||||
|
||||
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.ActiveRoomStays -> ActiveRoomStaysScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
propertyName = currentRoute.propertyName,
|
||||
onBack = { route.value = AppRoute.Home },
|
||||
onViewRooms = { route.value = AppRoute.Rooms(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) },
|
||||
canManageRooms = canManageProperty(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) }
|
||||
)
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.android.trisolarispms.core.booking
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
private val defaultPropertyZone: ZoneId = ZoneId.of("Asia/Kolkata")
|
||||
|
||||
fun isFutureBookingCheckIn(expectedCheckInAt: String?, zoneId: ZoneId = defaultPropertyZone): Boolean {
|
||||
if (expectedCheckInAt.isNullOrBlank()) return false
|
||||
val checkInDate = runCatching { OffsetDateTime.parse(expectedCheckInAt).toLocalDate() }.getOrNull() ?: return false
|
||||
val today = LocalDate.now(zoneId)
|
||||
return checkInDate.isAfter(today)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.android.trisolarispms.core.booking
|
||||
|
||||
object BookingProfileOptions {
|
||||
val memberRelations: List<String> = listOf(
|
||||
"FRIENDS",
|
||||
"FAMILY",
|
||||
"GROUP",
|
||||
"ALONE"
|
||||
)
|
||||
|
||||
val transportModes: List<String> = listOf(
|
||||
"",
|
||||
"CAR",
|
||||
"BIKE",
|
||||
"TRAIN",
|
||||
"PLANE",
|
||||
"BUS",
|
||||
"FOOT",
|
||||
"CYCLE",
|
||||
"OTHER"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.android.trisolarispms.core.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CitySearchController(
|
||||
private val scope: CoroutineScope,
|
||||
private val onUpdate: (isLoading: Boolean, suggestions: List<String>) -> Unit,
|
||||
private val search: suspend (query: String, limit: Int) -> List<String>,
|
||||
private val minQueryLength: Int = 2,
|
||||
private val defaultLimit: Int = 20,
|
||||
private val debounceMs: Long = 300L
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
fun onQueryChanged(rawQuery: String) {
|
||||
val query = rawQuery.trim()
|
||||
job?.cancel()
|
||||
if (query.length < minQueryLength) {
|
||||
onUpdate(false, emptyList())
|
||||
return
|
||||
}
|
||||
|
||||
job = scope.launch {
|
||||
delay(debounceMs)
|
||||
if (!isActive) return@launch
|
||||
onUpdate(true, emptyList())
|
||||
try {
|
||||
val suggestions = search(query, defaultLimit)
|
||||
if (isActive) {
|
||||
onUpdate(false, suggestions)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
if (isActive) {
|
||||
onUpdate(false, emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.android.trisolarispms.core.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Response
|
||||
|
||||
internal fun <S> ViewModel.launchRequest(
|
||||
state: MutableStateFlow<S>,
|
||||
setLoading: (S) -> S,
|
||||
setError: (S, String) -> S,
|
||||
defaultError: String,
|
||||
block: suspend () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
state.update(setLoading)
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
val message = e.localizedMessage ?: defaultError
|
||||
state.update { current -> setError(current, message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <S> ViewModel.launchApiMutation(
|
||||
state: MutableStateFlow<S>,
|
||||
action: String,
|
||||
setLoading: (S) -> S,
|
||||
setSuccess: (S) -> S,
|
||||
setError: (S, String) -> S,
|
||||
onDone: () -> Unit = {},
|
||||
createApi: () -> ApiService = { ApiClient.create() },
|
||||
call: suspend (ApiService) -> Response<*>
|
||||
) {
|
||||
launchRequest(
|
||||
state = state,
|
||||
setLoading = setLoading,
|
||||
setError = setError,
|
||||
defaultError = "$action failed"
|
||||
) {
|
||||
val response = call(createApi())
|
||||
if (response.isSuccessful) {
|
||||
state.update(setSuccess)
|
||||
onDone()
|
||||
} else {
|
||||
state.update { current ->
|
||||
setError(current, "$action failed: ${response.code()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.android.trisolarispms.data.api
|
||||
|
||||
interface ApiService :
|
||||
AuthApi,
|
||||
PropertyApi,
|
||||
RoomTypeApi,
|
||||
RoomApi,
|
||||
RoomImageApi,
|
||||
ImageTagApi,
|
||||
BookingApi,
|
||||
RoomStayApi,
|
||||
CardApi,
|
||||
GuestApi,
|
||||
GuestDocumentApi,
|
||||
TransportApi,
|
||||
InboundEmailApi,
|
||||
AmenityApi
|
||||
@@ -1,50 +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.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/{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>
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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/"
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.api.core
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
|
||||
object GeoSearchRepository {
|
||||
suspend fun searchCityDisplayValues(
|
||||
query: String,
|
||||
limit: Int = 20
|
||||
): List<String> {
|
||||
val response = ApiClient.create().searchCities(query = query, limit = limit)
|
||||
if (!response.isSuccessful) return emptyList()
|
||||
return response.body()
|
||||
.orEmpty()
|
||||
.mapNotNull(::extractCityDisplayValue)
|
||||
.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractCityDisplayValue(element: JsonElement): String? {
|
||||
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
|
||||
return element.asString.trim().ifBlank { null }
|
||||
}
|
||||
if (!element.isJsonObject) return null
|
||||
|
||||
val obj = element.asJsonObject
|
||||
val city = obj.get("city")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
|
||||
?: return null
|
||||
val state = obj.get("state")?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.ifBlank { null }
|
||||
return if (state == null) city else "$city, $state"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class BillingPolicyRequest(
|
||||
val billingCheckinTime: String,
|
||||
val billingCheckoutTime: String
|
||||
)
|
||||
|
||||
data class BillingPolicyResponse(
|
||||
val propertyId: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null
|
||||
)
|
||||
@@ -1,5 +1,20 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
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,
|
||||
@@ -8,11 +23,182 @@ data class BookingCheckInRequest(
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
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,
|
||||
val femaleCount: Int? = null,
|
||||
val expectedGuestCount: Int? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class BookingCreateResponse(
|
||||
val id: String? = null,
|
||||
val status: String? = null,
|
||||
val checkInAt: String? = null,
|
||||
val guestId: String? = null,
|
||||
val expectedCheckInAt: String? = null,
|
||||
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
|
||||
)
|
||||
|
||||
data class BookingCheckOutRequest(
|
||||
val checkOutAt: String? = null,
|
||||
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
|
||||
@@ -23,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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -34,3 +34,7 @@ data class IssuedCardResponse(
|
||||
val issuedByUserId: String? = null,
|
||||
val revokedAt: String? = null
|
||||
)
|
||||
|
||||
data class RevokeCardResponse(
|
||||
val timeData: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class CitySearchItemDto(
|
||||
val city: String? = null,
|
||||
val state: String? = null
|
||||
)
|
||||
@@ -1,15 +1,36 @@
|
||||
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(),
|
||||
val averageScore: Double? = null
|
||||
)
|
||||
|
||||
data class GuestCreateRequest(
|
||||
val bookingId: String,
|
||||
val phoneE164: String? = null,
|
||||
val name: String? = null,
|
||||
val nationality: String? = null,
|
||||
val age: String? = null,
|
||||
val addressText: String? = null
|
||||
)
|
||||
|
||||
data class GuestUpdateRequest(
|
||||
val phoneE164: String? = null,
|
||||
val name: String? = null,
|
||||
val nationality: String? = null,
|
||||
val age: String? = null,
|
||||
val addressText: String? = null
|
||||
)
|
||||
|
||||
data class GuestVehicleRequest(
|
||||
val vehicleNumber: String
|
||||
)
|
||||
@@ -49,3 +70,8 @@ data class GuestDocumentDto(
|
||||
val extractedData: Map<String, String>? = null,
|
||||
val extractedAt: String? = null
|
||||
)
|
||||
|
||||
data class GuestVisitCountResponse(
|
||||
val guestId: String? = null,
|
||||
val bookingCount: Int? = null
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class RatePlanRequest(
|
||||
val code: String? = null,
|
||||
val name: String? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val baseRate: Long? = null,
|
||||
val currency: String? = null
|
||||
)
|
||||
|
||||
data class RatePlanResponse(
|
||||
val id: String? = null,
|
||||
val propertyId: String? = null,
|
||||
val code: String? = null,
|
||||
val name: String? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val baseRate: Long? = null,
|
||||
val currency: String? = null
|
||||
)
|
||||
|
||||
data class RatePlanCalendarEntry(
|
||||
val rateDate: String,
|
||||
val rate: Long
|
||||
)
|
||||
|
||||
data class RatePlanCalendarUpsertRequest(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val rate: Long
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
@@ -30,7 +32,9 @@ data class RoomDto(
|
||||
val hasNfc: Boolean? = null,
|
||||
val active: Boolean? = null,
|
||||
val maintenance: Boolean? = null,
|
||||
val notes: String? = null
|
||||
val notes: String? = null,
|
||||
val tempCardActive: Boolean? = null,
|
||||
val tempCardExpiresAt: String? = null
|
||||
)
|
||||
|
||||
data class RoomBoardDto(
|
||||
@@ -45,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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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.CardPrepareRequest
|
||||
import com.android.trisolarispms.data.api.model.CardPrepareResponse
|
||||
import com.android.trisolarispms.data.api.model.IssueCardRequest
|
||||
import com.android.trisolarispms.data.api.model.IssuedCardResponse
|
||||
import com.android.trisolarispms.data.api.model.RevokeCardResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
@@ -40,6 +40,12 @@ interface CardApi {
|
||||
@Body body: IssueCardRequest
|
||||
): Response<IssuedCardResponse>
|
||||
|
||||
@GET("properties/{propertyId}/room-stays/cards/{cardIndex}")
|
||||
suspend fun getCardByIndex(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("cardIndex") cardIndex: String
|
||||
): Response<IssuedCardResponse>
|
||||
|
||||
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
||||
suspend fun listCards(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@@ -50,5 +56,5 @@ interface CardApi {
|
||||
suspend fun revokeCard(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("cardId") cardId: String
|
||||
): Response<ActionResponse>
|
||||
): Response<RevokeCardResponse>
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
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
|
||||
import com.android.trisolarispms.data.api.model.GuestRatingDto
|
||||
import com.android.trisolarispms.data.api.model.GuestRatingRequest
|
||||
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
|
||||
|
||||
interface GuestApi {
|
||||
@POST("properties/{propertyId}/guests")
|
||||
suspend fun createGuest(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Body body: GuestCreateRequest
|
||||
): Response<GuestDto>
|
||||
|
||||
@PUT("properties/{propertyId}/guests/{guestId}")
|
||||
suspend fun updateGuest(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("guestId") guestId: String,
|
||||
@Body body: GuestUpdateRequest
|
||||
): Response<GuestDto>
|
||||
|
||||
@GET("properties/{propertyId}/guests/search")
|
||||
suspend fun searchGuests(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@@ -20,6 +40,26 @@ interface GuestApi {
|
||||
@Query("vehicleNumber") vehicleNumber: String? = null
|
||||
): Response<List<GuestDto>>
|
||||
|
||||
@GET("properties/{propertyId}/guests/visit-count")
|
||||
suspend fun getGuestVisitCount(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("phone") phone: String
|
||||
): Response<GuestVisitCountResponse>
|
||||
|
||||
@GET("properties/{propertyId}/guests/{guestId}")
|
||||
suspend fun getGuest(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@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,
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.android.trisolarispms.data.api.service
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarEntry
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarUpsertRequest
|
||||
import com.android.trisolarispms.data.api.model.RatePlanRequest
|
||||
import com.android.trisolarispms.data.api.model.RatePlanResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface RatePlanApi {
|
||||
@POST("properties/{propertyId}/rate-plans")
|
||||
suspend fun createRatePlan(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Body body: RatePlanRequest
|
||||
): Response<RatePlanResponse>
|
||||
|
||||
@GET("properties/{propertyId}/rate-plans")
|
||||
suspend fun listRatePlans(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("roomTypeCode") roomTypeCode: String? = null
|
||||
): Response<List<RatePlanResponse>>
|
||||
|
||||
@PUT("properties/{propertyId}/rate-plans/{ratePlanId}")
|
||||
suspend fun updateRatePlan(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Body body: RatePlanRequest
|
||||
): Response<RatePlanResponse>
|
||||
|
||||
@DELETE("properties/{propertyId}/rate-plans/{ratePlanId}")
|
||||
suspend fun deleteRatePlan(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/rate-plans/{ratePlanId}/calendar")
|
||||
suspend fun upsertRatePlanCalendar(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Body body: RatePlanCalendarUpsertRequest
|
||||
): Response<Unit>
|
||||
|
||||
@GET("properties/{propertyId}/rate-plans/{ratePlanId}/calendar")
|
||||
suspend fun listRatePlanCalendar(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String
|
||||
): Response<List<RatePlanCalendarEntry>>
|
||||
|
||||
@DELETE("properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate}")
|
||||
suspend fun deleteRatePlanCalendarEntry(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Path("rateDate") rateDate: String
|
||||
): Response<Unit>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.local.booking
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BookingDetailsCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM booking_details_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
fun observe(propertyId: String, bookingId: String): Flow<BookingDetailsCacheEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: BookingDetailsCacheEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM booking_details_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun delete(propertyId: String, bookingId: String)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.android.trisolarispms.data.local.booking
|
||||
|
||||
import androidx.room.Entity
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
|
||||
@Entity(
|
||||
tableName = "booking_details_cache",
|
||||
primaryKeys = ["propertyId", "bookingId"]
|
||||
)
|
||||
data class BookingDetailsCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val detailsId: String? = null,
|
||||
val status: String? = null,
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val guestNationality: String? = null,
|
||||
val guestAge: String? = null,
|
||||
val guestAddressText: String? = null,
|
||||
val guestSignatureUrl: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val roomNumbers: List<Int> = emptyList(),
|
||||
val source: String? = null,
|
||||
val fromCity: String? = null,
|
||||
val toCity: String? = null,
|
||||
val memberRelation: String? = null,
|
||||
val transportMode: String? = null,
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null,
|
||||
val checkInAt: String? = null,
|
||||
val checkOutAt: String? = null,
|
||||
val adultCount: Int? = null,
|
||||
val maleCount: Int? = null,
|
||||
val femaleCount: Int? = null,
|
||||
val childCount: Int? = null,
|
||||
val totalGuestCount: Int? = null,
|
||||
val expectedGuestCount: Int? = null,
|
||||
val totalNightlyRate: Long? = null,
|
||||
val notes: String? = null,
|
||||
val registeredByName: String? = null,
|
||||
val registeredByPhone: String? = null,
|
||||
val expectedPay: Long? = null,
|
||||
val amountCollected: Long? = null,
|
||||
val pending: Long? = null,
|
||||
val billableNights: Long? = null,
|
||||
val billingMode: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun BookingDetailsResponse.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): BookingDetailsCacheEntity = BookingDetailsCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
detailsId = id,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
guestNationality = guestNationality,
|
||||
guestAge = guestAge,
|
||||
guestAddressText = guestAddressText,
|
||||
guestSignatureUrl = guestSignatureUrl,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
adultCount = adultCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
childCount = childCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
totalNightlyRate = totalNightlyRate,
|
||||
notes = notes,
|
||||
registeredByName = registeredByName,
|
||||
registeredByPhone = registeredByPhone,
|
||||
expectedPay = expectedPay,
|
||||
amountCollected = amountCollected,
|
||||
pending = pending,
|
||||
billableNights = billableNights,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime
|
||||
)
|
||||
|
||||
internal fun BookingDetailsCacheEntity.toApiModel(): BookingDetailsResponse = BookingDetailsResponse(
|
||||
id = detailsId ?: bookingId,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
guestNationality = guestNationality,
|
||||
guestAge = guestAge,
|
||||
guestAddressText = guestAddressText,
|
||||
guestSignatureUrl = guestSignatureUrl,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
adultCount = adultCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
childCount = childCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
totalNightlyRate = totalNightlyRate,
|
||||
notes = notes,
|
||||
registeredByName = registeredByName,
|
||||
registeredByPhone = registeredByPhone,
|
||||
expectedPay = expectedPay,
|
||||
amountCollected = amountCollected,
|
||||
pending = pending,
|
||||
billableNights = billableNights,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.android.trisolarispms.data.local.booking
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class BookingDetailsRepository(
|
||||
private val dao: BookingDetailsCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeBookingDetails(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Flow<BookingDetailsResponse?> =
|
||||
dao.observe(propertyId = propertyId, bookingId = bookingId).map { cached ->
|
||||
cached?.toApiModel()
|
||||
}
|
||||
|
||||
suspend fun refreshBookingDetails(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Result<Unit> = runCatching {
|
||||
val response = createApi().getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body() ?: throw IllegalStateException("Load failed: empty response")
|
||||
dao.upsert(body.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
|
||||
}
|
||||
|
||||
suspend fun updateExpectedDates(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
body: BookingExpectedDatesRequest
|
||||
): Result<Unit> = runCatching {
|
||||
val api = createApi()
|
||||
val response = api.updateExpectedDates(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = body
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Update failed: ${response.code()}")
|
||||
}
|
||||
refreshBookingDetails(propertyId = propertyId, bookingId = bookingId).getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun storeSnapshot(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
details: BookingDetailsResponse
|
||||
) {
|
||||
dao.upsert(details.toCacheEntity(propertyId = propertyId, bookingId = bookingId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.android.trisolarispms.data.local.bookinglist
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BookingListCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM booking_list_cache
|
||||
WHERE propertyId = :propertyId AND status = :status
|
||||
ORDER BY sortOrder ASC, bookingId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByStatus(propertyId: String, status: String): Flow<List<BookingListCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<BookingListCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM booking_list_cache
|
||||
WHERE propertyId = :propertyId AND status = :status
|
||||
"""
|
||||
)
|
||||
suspend fun deleteByPropertyAndStatus(propertyId: String, status: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForStatus(
|
||||
propertyId: String,
|
||||
status: String,
|
||||
items: List<BookingListCacheEntity>
|
||||
) {
|
||||
deleteByPropertyAndStatus(propertyId = propertyId, status = status)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.android.trisolarispms.data.local.bookinglist
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.BookingListItem
|
||||
|
||||
@Entity(
|
||||
tableName = "booking_list_cache",
|
||||
primaryKeys = ["propertyId", "bookingId"],
|
||||
indices = [
|
||||
Index(value = ["propertyId"]),
|
||||
Index(value = ["propertyId", "status"])
|
||||
]
|
||||
)
|
||||
data class BookingListCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val status: String? = null,
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val roomNumbers: List<Int> = emptyList(),
|
||||
val source: String? = null,
|
||||
val fromCity: String? = null,
|
||||
val toCity: String? = null,
|
||||
val memberRelation: String? = null,
|
||||
val transportMode: String? = null,
|
||||
val checkInAt: String? = null,
|
||||
val checkOutAt: String? = null,
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null,
|
||||
val adultCount: Int? = null,
|
||||
val childCount: Int? = null,
|
||||
val maleCount: Int? = null,
|
||||
val femaleCount: Int? = null,
|
||||
val totalGuestCount: Int? = null,
|
||||
val expectedGuestCount: Int? = null,
|
||||
val notes: String? = null,
|
||||
val pending: Long? = null,
|
||||
val billingMode: String? = null,
|
||||
val billingCheckinTime: String? = null,
|
||||
val billingCheckoutTime: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun BookingListItem.toCacheEntity(
|
||||
propertyId: String,
|
||||
sortOrder: Int
|
||||
): BookingListCacheEntity? {
|
||||
val safeBookingId = id?.trim()?.ifBlank { null } ?: return null
|
||||
return BookingListCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = safeBookingId,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
adultCount = adultCount,
|
||||
childCount = childCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
notes = notes,
|
||||
pending = pending,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun BookingListCacheEntity.toApiModel(): BookingListItem = BookingListItem(
|
||||
id = bookingId,
|
||||
status = status,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = source,
|
||||
fromCity = fromCity,
|
||||
toCity = toCity,
|
||||
memberRelation = memberRelation,
|
||||
transportMode = transportMode,
|
||||
checkInAt = checkInAt,
|
||||
checkOutAt = checkOutAt,
|
||||
expectedCheckInAt = expectedCheckInAt,
|
||||
expectedCheckOutAt = expectedCheckOutAt,
|
||||
adultCount = adultCount,
|
||||
childCount = childCount,
|
||||
maleCount = maleCount,
|
||||
femaleCount = femaleCount,
|
||||
totalGuestCount = totalGuestCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
notes = notes,
|
||||
pending = pending,
|
||||
billingMode = billingMode,
|
||||
billingCheckinTime = billingCheckinTime,
|
||||
billingCheckoutTime = billingCheckoutTime
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.android.trisolarispms.data.local.bookinglist
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.BookingListItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class BookingListRepository(
|
||||
private val dao: BookingListCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByStatus(propertyId: String, status: String): Flow<List<BookingListItem>> =
|
||||
dao.observeByStatus(propertyId = propertyId, status = status).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refreshByStatus(propertyId: String, status: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listBookings(propertyId = propertyId, status = status)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexedNotNull { index, booking ->
|
||||
booking.toCacheEntity(propertyId = propertyId, sortOrder = index)
|
||||
}
|
||||
dao.replaceForStatus(propertyId = propertyId, status = status, items = rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.android.trisolarispms.data.local.core
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheDao
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsCacheEntity
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListCacheDao
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListCacheEntity
|
||||
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentCacheDao
|
||||
import com.android.trisolarispms.data.local.guestdoc.GuestDocumentCacheEntity
|
||||
import com.android.trisolarispms.data.local.payment.PaymentCacheDao
|
||||
import com.android.trisolarispms.data.local.payment.PaymentCacheEntity
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayCacheDao
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayQrEventCacheEntity
|
||||
import com.android.trisolarispms.data.local.razorpay.RazorpayRequestCacheEntity
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheDao
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayCacheEntity
|
||||
import androidx.room.migration.Migration
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
BookingDetailsCacheEntity::class,
|
||||
ActiveRoomStayCacheEntity::class,
|
||||
BookingListCacheEntity::class,
|
||||
PaymentCacheEntity::class,
|
||||
RazorpayRequestCacheEntity::class,
|
||||
RazorpayQrEventCacheEntity::class,
|
||||
GuestDocumentCacheEntity::class
|
||||
],
|
||||
version = 5,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(RoomConverters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun bookingDetailsCacheDao(): BookingDetailsCacheDao
|
||||
abstract fun activeRoomStayCacheDao(): ActiveRoomStayCacheDao
|
||||
abstract fun bookingListCacheDao(): BookingListCacheDao
|
||||
abstract fun paymentCacheDao(): PaymentCacheDao
|
||||
abstract fun razorpayCacheDao(): RazorpayCacheDao
|
||||
abstract fun guestDocumentCacheDao(): GuestDocumentCacheDao
|
||||
|
||||
companion object {
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `active_room_stay_cache` (
|
||||
`roomStayId` TEXT NOT NULL,
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT,
|
||||
`guestId` TEXT,
|
||||
`guestName` TEXT,
|
||||
`guestPhone` TEXT,
|
||||
`roomId` TEXT,
|
||||
`roomNumber` INTEGER,
|
||||
`roomTypeCode` TEXT,
|
||||
`roomTypeName` TEXT,
|
||||
`fromAt` TEXT,
|
||||
`checkinAt` TEXT,
|
||||
`expectedCheckoutAt` TEXT,
|
||||
`nightlyRate` INTEGER,
|
||||
`currency` TEXT,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`roomStayId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_active_room_stay_cache_propertyId`
|
||||
ON `active_room_stay_cache` (`propertyId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_active_room_stay_cache_propertyId_bookingId`
|
||||
ON `active_room_stay_cache` (`propertyId`, `bookingId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `booking_list_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`status` TEXT,
|
||||
`guestId` TEXT,
|
||||
`guestName` TEXT,
|
||||
`guestPhone` TEXT,
|
||||
`vehicleNumbers` TEXT NOT NULL,
|
||||
`roomNumbers` TEXT NOT NULL,
|
||||
`source` TEXT,
|
||||
`fromCity` TEXT,
|
||||
`toCity` TEXT,
|
||||
`memberRelation` TEXT,
|
||||
`transportMode` TEXT,
|
||||
`checkInAt` TEXT,
|
||||
`checkOutAt` TEXT,
|
||||
`expectedCheckInAt` TEXT,
|
||||
`expectedCheckOutAt` TEXT,
|
||||
`adultCount` INTEGER,
|
||||
`childCount` INTEGER,
|
||||
`maleCount` INTEGER,
|
||||
`femaleCount` INTEGER,
|
||||
`totalGuestCount` INTEGER,
|
||||
`expectedGuestCount` INTEGER,
|
||||
`notes` TEXT,
|
||||
`pending` INTEGER,
|
||||
`billingMode` TEXT,
|
||||
`billingCheckinTime` TEXT,
|
||||
`billingCheckoutTime` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_booking_list_cache_propertyId`
|
||||
ON `booking_list_cache` (`propertyId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_booking_list_cache_propertyId_status`
|
||||
ON `booking_list_cache` (`propertyId`, `status`)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `payment_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`paymentId` TEXT NOT NULL,
|
||||
`amount` INTEGER,
|
||||
`currency` TEXT,
|
||||
`method` TEXT,
|
||||
`gatewayPaymentId` TEXT,
|
||||
`gatewayTxnId` TEXT,
|
||||
`bankRefNum` TEXT,
|
||||
`mode` TEXT,
|
||||
`pgType` TEXT,
|
||||
`payerVpa` TEXT,
|
||||
`payerName` TEXT,
|
||||
`paymentSource` TEXT,
|
||||
`reference` TEXT,
|
||||
`notes` TEXT,
|
||||
`receivedAt` TEXT,
|
||||
`receivedByUserId` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`, `paymentId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_payment_cache_propertyId_bookingId`
|
||||
ON `payment_cache` (`propertyId`, `bookingId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `razorpay_request_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`requestKey` TEXT NOT NULL,
|
||||
`type` TEXT,
|
||||
`requestId` TEXT,
|
||||
`amount` INTEGER,
|
||||
`currency` TEXT,
|
||||
`status` TEXT,
|
||||
`createdAt` TEXT,
|
||||
`qrId` TEXT,
|
||||
`imageUrl` TEXT,
|
||||
`expiryAt` TEXT,
|
||||
`paymentLinkId` TEXT,
|
||||
`paymentLink` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`, `requestKey`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_razorpay_request_cache_propertyId_bookingId`
|
||||
ON `razorpay_request_cache` (`propertyId`, `bookingId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `razorpay_qr_event_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`bookingId` TEXT NOT NULL,
|
||||
`qrId` TEXT NOT NULL,
|
||||
`eventKey` TEXT NOT NULL,
|
||||
`event` TEXT,
|
||||
`status` TEXT,
|
||||
`receivedAt` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `bookingId`, `qrId`, `eventKey`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_razorpay_qr_event_cache_propertyId_bookingId_qrId`
|
||||
ON `razorpay_qr_event_cache` (`propertyId`, `bookingId`, `qrId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `guest_document_cache` (
|
||||
`propertyId` TEXT NOT NULL,
|
||||
`guestId` TEXT NOT NULL,
|
||||
`documentId` TEXT NOT NULL,
|
||||
`bookingId` TEXT,
|
||||
`uploadedByUserId` TEXT,
|
||||
`uploadedAt` TEXT,
|
||||
`originalFilename` TEXT,
|
||||
`contentType` TEXT,
|
||||
`sizeBytes` INTEGER,
|
||||
`extractedDataJson` TEXT,
|
||||
`extractedAt` TEXT,
|
||||
`sortOrder` INTEGER NOT NULL,
|
||||
`updatedAtEpochMs` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`propertyId`, `guestId`, `documentId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS `index_guest_document_cache_propertyId_guestId`
|
||||
ON `guest_document_cache` (`propertyId`, `guestId`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.android.trisolarispms.data.local.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
|
||||
object LocalDatabaseProvider {
|
||||
@Volatile
|
||||
private var instance: AppDatabase? = null
|
||||
|
||||
fun get(context: Context): AppDatabase {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"trisolaris_pms_local.db"
|
||||
)
|
||||
.addMigrations(AppDatabase.MIGRATION_1_2)
|
||||
.addMigrations(AppDatabase.MIGRATION_2_3)
|
||||
.addMigrations(AppDatabase.MIGRATION_3_4)
|
||||
.addMigrations(AppDatabase.MIGRATION_4_5)
|
||||
.build()
|
||||
.also { built ->
|
||||
instance = built
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.android.trisolarispms.data.local.core
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
class RoomConverters {
|
||||
private val gson = Gson()
|
||||
private val stringListType = object : TypeToken<List<String>>() {}.type
|
||||
private val intListType = object : TypeToken<List<Int>>() {}.type
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringList(value: List<String>?): String = gson.toJson(value.orEmpty())
|
||||
|
||||
@TypeConverter
|
||||
fun toStringList(value: String?): List<String> {
|
||||
if (value.isNullOrBlank()) return emptyList()
|
||||
return runCatching { gson.fromJson<List<String>>(value, stringListType) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromIntList(value: List<Int>?): String = gson.toJson(value.orEmpty())
|
||||
|
||||
@TypeConverter
|
||||
fun toIntList(value: String?): List<Int> {
|
||||
if (value.isNullOrBlank()) return emptyList()
|
||||
return runCatching { gson.fromJson<List<Int>>(value, intListType) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.android.trisolarispms.data.local.guestdoc
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface GuestDocumentCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM guest_document_cache
|
||||
WHERE propertyId = :propertyId AND guestId = :guestId
|
||||
ORDER BY sortOrder ASC, documentId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByGuest(propertyId: String, guestId: String): Flow<List<GuestDocumentCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<GuestDocumentCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM guest_document_cache
|
||||
WHERE propertyId = :propertyId AND guestId = :guestId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteByGuest(propertyId: String, guestId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForGuest(
|
||||
propertyId: String,
|
||||
guestId: String,
|
||||
items: List<GuestDocumentCacheEntity>
|
||||
) {
|
||||
deleteByGuest(propertyId = propertyId, guestId = guestId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.android.trisolarispms.data.local.guestdoc
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.GuestDocumentDto
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
@Entity(
|
||||
tableName = "guest_document_cache",
|
||||
primaryKeys = ["propertyId", "guestId", "documentId"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "guestId"])
|
||||
]
|
||||
)
|
||||
data class GuestDocumentCacheEntity(
|
||||
val propertyId: String,
|
||||
val guestId: String,
|
||||
val documentId: String,
|
||||
val bookingId: String? = null,
|
||||
val uploadedByUserId: String? = null,
|
||||
val uploadedAt: String? = null,
|
||||
val originalFilename: String? = null,
|
||||
val contentType: String? = null,
|
||||
val sizeBytes: Long? = null,
|
||||
val extractedDataJson: String? = null,
|
||||
val extractedAt: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
private val extractedDataType = object : TypeToken<Map<String, String>>() {}.type
|
||||
|
||||
internal fun GuestDocumentDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
guestId: String,
|
||||
sortOrder: Int
|
||||
): GuestDocumentCacheEntity? {
|
||||
val safeDocumentId = id?.trim()?.ifBlank { null } ?: return null
|
||||
val extractedJson = runCatching { Gson().toJson(extractedData ?: emptyMap<String, String>()) }
|
||||
.getOrNull()
|
||||
return GuestDocumentCacheEntity(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
documentId = safeDocumentId,
|
||||
bookingId = bookingId,
|
||||
uploadedByUserId = uploadedByUserId,
|
||||
uploadedAt = uploadedAt,
|
||||
originalFilename = originalFilename,
|
||||
contentType = contentType,
|
||||
sizeBytes = sizeBytes,
|
||||
extractedDataJson = extractedJson,
|
||||
extractedAt = extractedAt,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun GuestDocumentCacheEntity.toApiModel(): GuestDocumentDto {
|
||||
val extractedMap = runCatching {
|
||||
if (extractedDataJson.isNullOrBlank()) emptyMap<String, String>()
|
||||
else Gson().fromJson<Map<String, String>>(extractedDataJson, extractedDataType)
|
||||
}.getOrElse { emptyMap() }
|
||||
return GuestDocumentDto(
|
||||
id = documentId,
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
bookingId = bookingId,
|
||||
uploadedByUserId = uploadedByUserId,
|
||||
uploadedAt = uploadedAt,
|
||||
originalFilename = originalFilename,
|
||||
contentType = contentType,
|
||||
sizeBytes = sizeBytes,
|
||||
extractedData = extractedMap,
|
||||
extractedAt = extractedAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.android.trisolarispms.data.local.guestdoc
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.GuestDocumentDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GuestDocumentRepository(
|
||||
private val dao: GuestDocumentCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByGuest(propertyId: String, guestId: String): Flow<List<GuestDocumentDto>> =
|
||||
dao.observeByGuest(propertyId = propertyId, guestId = guestId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refresh(propertyId: String, guestId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listGuestDocuments(propertyId = propertyId, guestId = guestId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexedNotNull { index, doc ->
|
||||
doc.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows)
|
||||
}
|
||||
|
||||
suspend fun storeSnapshot(
|
||||
propertyId: String,
|
||||
guestId: String,
|
||||
documents: List<GuestDocumentDto>
|
||||
) {
|
||||
val rows = documents.mapIndexedNotNull { index, doc ->
|
||||
doc.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceForGuest(propertyId = propertyId, guestId = guestId, items = rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.android.trisolarispms.data.local.payment
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PaymentCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM payment_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
ORDER BY sortOrder ASC, paymentId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<PaymentCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<PaymentCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM payment_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteByBooking(propertyId: String, bookingId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
items: List<PaymentCacheEntity>
|
||||
) {
|
||||
deleteByBooking(propertyId = propertyId, bookingId = bookingId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.android.trisolarispms.data.local.payment
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.PaymentDto
|
||||
|
||||
@Entity(
|
||||
tableName = "payment_cache",
|
||||
primaryKeys = ["propertyId", "bookingId", "paymentId"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "bookingId"])
|
||||
]
|
||||
)
|
||||
data class PaymentCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val paymentId: String,
|
||||
val amount: Long? = null,
|
||||
val currency: String? = null,
|
||||
val method: String? = null,
|
||||
val gatewayPaymentId: String? = null,
|
||||
val gatewayTxnId: String? = null,
|
||||
val bankRefNum: String? = null,
|
||||
val mode: String? = null,
|
||||
val pgType: String? = null,
|
||||
val payerVpa: String? = null,
|
||||
val payerName: String? = null,
|
||||
val paymentSource: String? = null,
|
||||
val reference: String? = null,
|
||||
val notes: String? = null,
|
||||
val receivedAt: String? = null,
|
||||
val receivedByUserId: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun PaymentDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
sortOrder: Int
|
||||
): PaymentCacheEntity? {
|
||||
val safePaymentId = id?.trim()?.ifBlank { null } ?: return null
|
||||
return PaymentCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
paymentId = safePaymentId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
method = method,
|
||||
gatewayPaymentId = gatewayPaymentId,
|
||||
gatewayTxnId = gatewayTxnId,
|
||||
bankRefNum = bankRefNum,
|
||||
mode = mode,
|
||||
pgType = pgType,
|
||||
payerVpa = payerVpa,
|
||||
payerName = payerName,
|
||||
paymentSource = paymentSource,
|
||||
reference = reference,
|
||||
notes = notes,
|
||||
receivedAt = receivedAt,
|
||||
receivedByUserId = receivedByUserId,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun PaymentCacheEntity.toApiModel(): PaymentDto = PaymentDto(
|
||||
id = paymentId,
|
||||
bookingId = bookingId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
method = method,
|
||||
gatewayPaymentId = gatewayPaymentId,
|
||||
gatewayTxnId = gatewayTxnId,
|
||||
bankRefNum = bankRefNum,
|
||||
mode = mode,
|
||||
pgType = pgType,
|
||||
payerVpa = payerVpa,
|
||||
payerName = payerName,
|
||||
paymentSource = paymentSource,
|
||||
reference = reference,
|
||||
notes = notes,
|
||||
receivedAt = receivedAt,
|
||||
receivedByUserId = receivedByUserId
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.android.trisolarispms.data.local.payment
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.PaymentDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class PaymentRepository(
|
||||
private val dao: PaymentCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<PaymentDto>> =
|
||||
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refresh(propertyId: String, bookingId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listPayments(propertyId = propertyId, bookingId = bookingId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexedNotNull { index, payment ->
|
||||
payment.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceForBooking(propertyId = propertyId, bookingId = bookingId, items = rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface RazorpayCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM razorpay_request_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
ORDER BY sortOrder ASC, requestKey ASC
|
||||
"""
|
||||
)
|
||||
fun observeRequests(propertyId: String, bookingId: String): Flow<List<RazorpayRequestCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertRequests(items: List<RazorpayRequestCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM razorpay_request_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteRequests(propertyId: String, bookingId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceRequests(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
items: List<RazorpayRequestCacheEntity>
|
||||
) {
|
||||
deleteRequests(propertyId = propertyId, bookingId = bookingId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertRequests(items)
|
||||
}
|
||||
}
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM razorpay_qr_event_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId AND qrId = :qrId
|
||||
ORDER BY sortOrder ASC, eventKey ASC
|
||||
"""
|
||||
)
|
||||
fun observeQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Flow<List<RazorpayQrEventCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertQrEvents(items: List<RazorpayQrEventCacheEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM razorpay_qr_event_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId AND qrId = :qrId
|
||||
"""
|
||||
)
|
||||
suspend fun deleteQrEvents(propertyId: String, bookingId: String, qrId: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String,
|
||||
items: List<RazorpayQrEventCacheEntity>
|
||||
) {
|
||||
deleteQrEvents(propertyId = propertyId, bookingId = bookingId, qrId = qrId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertQrEvents(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class RazorpayCacheRepository(
|
||||
private val dao: RazorpayCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeRequests(
|
||||
propertyId: String,
|
||||
bookingId: String
|
||||
): Flow<List<RazorpayRequestListItemDto>> =
|
||||
dao.observeRequests(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
fun observeQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Flow<List<RazorpayQrEventDto>> =
|
||||
dao.observeQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refreshRequests(propertyId: String, bookingId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listRazorpayRequests(propertyId = propertyId, bookingId = bookingId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapIndexed { index, item ->
|
||||
item.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceRequests(propertyId = propertyId, bookingId = bookingId, items = rows)
|
||||
}
|
||||
|
||||
suspend fun refreshQrEvents(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String
|
||||
): Result<List<RazorpayQrEventDto>> = runCatching {
|
||||
val response = createApi().listRazorpayQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body().orEmpty()
|
||||
val rows = body.mapIndexed { index, item ->
|
||||
item.toCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId,
|
||||
sortOrder = index
|
||||
)
|
||||
}
|
||||
dao.replaceQrEvents(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId,
|
||||
items = rows
|
||||
)
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
|
||||
|
||||
@Entity(
|
||||
tableName = "razorpay_qr_event_cache",
|
||||
primaryKeys = ["propertyId", "bookingId", "qrId", "eventKey"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "bookingId", "qrId"])
|
||||
]
|
||||
)
|
||||
data class RazorpayQrEventCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val qrId: String,
|
||||
val eventKey: String,
|
||||
val event: String? = null,
|
||||
val status: String? = null,
|
||||
val receivedAt: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun RazorpayQrEventDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
qrId: String,
|
||||
sortOrder: Int
|
||||
): RazorpayQrEventCacheEntity {
|
||||
val key = "${receivedAt.orEmpty()}:${event.orEmpty()}:${status.orEmpty()}:$sortOrder"
|
||||
return RazorpayQrEventCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
qrId = qrId,
|
||||
eventKey = key,
|
||||
event = event,
|
||||
status = status,
|
||||
receivedAt = receivedAt,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun RazorpayQrEventCacheEntity.toApiModel(): RazorpayQrEventDto = RazorpayQrEventDto(
|
||||
event = event,
|
||||
qrId = qrId,
|
||||
status = status,
|
||||
receivedAt = receivedAt
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.android.trisolarispms.data.local.razorpay
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
|
||||
|
||||
@Entity(
|
||||
tableName = "razorpay_request_cache",
|
||||
primaryKeys = ["propertyId", "bookingId", "requestKey"],
|
||||
indices = [
|
||||
Index(value = ["propertyId", "bookingId"])
|
||||
]
|
||||
)
|
||||
data class RazorpayRequestCacheEntity(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val requestKey: String,
|
||||
val type: String? = null,
|
||||
val requestId: String? = null,
|
||||
val amount: Long? = null,
|
||||
val currency: String? = null,
|
||||
val status: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val qrId: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
val expiryAt: String? = null,
|
||||
val paymentLinkId: String? = null,
|
||||
val paymentLink: String? = null,
|
||||
val sortOrder: Int = 0,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun RazorpayRequestListItemDto.toCacheEntity(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
sortOrder: Int
|
||||
): RazorpayRequestCacheEntity {
|
||||
val key = requestId?.trim()?.ifBlank { null }
|
||||
?: qrId?.trim()?.ifBlank { null }?.let { "qr:$it" }
|
||||
?: paymentLinkId?.trim()?.ifBlank { null }?.let { "plink:$it" }
|
||||
?: "idx:$sortOrder:${createdAt.orEmpty()}:${type.orEmpty()}"
|
||||
return RazorpayRequestCacheEntity(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
requestKey = key,
|
||||
type = type,
|
||||
requestId = requestId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
qrId = qrId,
|
||||
imageUrl = imageUrl,
|
||||
expiryAt = expiryAt,
|
||||
paymentLinkId = paymentLinkId,
|
||||
paymentLink = paymentLink,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
internal fun RazorpayRequestCacheEntity.toApiModel(): RazorpayRequestListItemDto = RazorpayRequestListItemDto(
|
||||
type = type,
|
||||
requestId = requestId,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
status = status,
|
||||
createdAt = createdAt,
|
||||
qrId = qrId,
|
||||
imageUrl = imageUrl,
|
||||
expiryAt = expiryAt,
|
||||
paymentLinkId = paymentLinkId,
|
||||
paymentLink = paymentLink
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.android.trisolarispms.data.local.roomstay
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ActiveRoomStayCacheDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM active_room_stay_cache
|
||||
WHERE propertyId = :propertyId
|
||||
ORDER BY roomNumber ASC, roomStayId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayCacheEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM active_room_stay_cache
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
ORDER BY roomNumber ASC, roomStayId ASC
|
||||
"""
|
||||
)
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayCacheEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<ActiveRoomStayCacheEntity>)
|
||||
|
||||
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId")
|
||||
suspend fun deleteByProperty(propertyId: String)
|
||||
|
||||
@Query("DELETE FROM active_room_stay_cache WHERE propertyId = :propertyId AND roomStayId = :roomStayId")
|
||||
suspend fun deleteByRoomStay(propertyId: String, roomStayId: String)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE active_room_stay_cache
|
||||
SET expectedCheckoutAt = :expectedCheckoutAt,
|
||||
updatedAtEpochMs = :updatedAtEpochMs
|
||||
WHERE propertyId = :propertyId AND bookingId = :bookingId
|
||||
"""
|
||||
)
|
||||
suspend fun updateExpectedCheckoutAtForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
expectedCheckoutAt: String?,
|
||||
updatedAtEpochMs: Long
|
||||
)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceForProperty(propertyId: String, items: List<ActiveRoomStayCacheEntity>) {
|
||||
deleteByProperty(propertyId)
|
||||
if (items.isNotEmpty()) {
|
||||
upsertAll(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.android.trisolarispms.data.local.roomstay
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
|
||||
@Entity(
|
||||
tableName = "active_room_stay_cache",
|
||||
indices = [
|
||||
Index(value = ["propertyId"]),
|
||||
Index(value = ["propertyId", "bookingId"])
|
||||
]
|
||||
)
|
||||
data class ActiveRoomStayCacheEntity(
|
||||
@PrimaryKey
|
||||
val roomStayId: String,
|
||||
val propertyId: String,
|
||||
val bookingId: String? = null,
|
||||
val guestId: String? = null,
|
||||
val guestName: String? = null,
|
||||
val guestPhone: String? = null,
|
||||
val roomId: String? = null,
|
||||
val roomNumber: Int? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val roomTypeName: String? = null,
|
||||
val fromAt: String? = null,
|
||||
val checkinAt: String? = null,
|
||||
val expectedCheckoutAt: String? = null,
|
||||
val nightlyRate: Long? = null,
|
||||
val currency: String? = null,
|
||||
val updatedAtEpochMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
internal fun ActiveRoomStayDto.toCacheEntity(propertyId: String): ActiveRoomStayCacheEntity? {
|
||||
val stayId = roomStayId?.trim()?.ifBlank { null } ?: return null
|
||||
return ActiveRoomStayCacheEntity(
|
||||
roomStayId = stayId,
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
roomId = roomId,
|
||||
roomNumber = roomNumber,
|
||||
roomTypeCode = roomTypeCode,
|
||||
roomTypeName = roomTypeName,
|
||||
fromAt = fromAt,
|
||||
checkinAt = checkinAt,
|
||||
expectedCheckoutAt = expectedCheckoutAt,
|
||||
nightlyRate = nightlyRate,
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
|
||||
internal fun ActiveRoomStayCacheEntity.toApiModel(): ActiveRoomStayDto = ActiveRoomStayDto(
|
||||
roomStayId = roomStayId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
guestName = guestName,
|
||||
guestPhone = guestPhone,
|
||||
roomId = roomId,
|
||||
roomNumber = roomNumber,
|
||||
roomTypeCode = roomTypeCode,
|
||||
roomTypeName = roomTypeName,
|
||||
fromAt = fromAt,
|
||||
checkinAt = checkinAt,
|
||||
expectedCheckoutAt = expectedCheckoutAt,
|
||||
nightlyRate = nightlyRate,
|
||||
currency = currency
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.android.trisolarispms.data.local.roomstay
|
||||
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.ApiService
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class ActiveRoomStayRepository(
|
||||
private val dao: ActiveRoomStayCacheDao,
|
||||
private val createApi: () -> ApiService = { ApiClient.create() }
|
||||
) {
|
||||
fun observeByProperty(propertyId: String): Flow<List<ActiveRoomStayDto>> =
|
||||
dao.observeByProperty(propertyId = propertyId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
fun observeByBooking(propertyId: String, bookingId: String): Flow<List<ActiveRoomStayDto>> =
|
||||
dao.observeByBooking(propertyId = propertyId, bookingId = bookingId).map { rows ->
|
||||
rows.map { it.toApiModel() }
|
||||
}
|
||||
|
||||
suspend fun refresh(propertyId: String): Result<Unit> = runCatching {
|
||||
val response = createApi().listActiveRoomStays(propertyId = propertyId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${response.code()}")
|
||||
}
|
||||
val rows = response.body().orEmpty().mapNotNull { it.toCacheEntity(propertyId = propertyId) }
|
||||
dao.replaceForProperty(propertyId = propertyId, items = rows)
|
||||
}
|
||||
|
||||
suspend fun removeFromCache(propertyId: String, roomStayId: String) {
|
||||
dao.deleteByRoomStay(propertyId = propertyId, roomStayId = roomStayId)
|
||||
}
|
||||
|
||||
suspend fun patchExpectedCheckoutForBooking(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
expectedCheckoutAt: String?
|
||||
) {
|
||||
dao.updateExpectedCheckoutAtForBooking(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
expectedCheckoutAt = expectedCheckoutAt,
|
||||
updatedAtEpochMs = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.android.trisolarispms.ui
|
||||
|
||||
sealed interface AppRoute {
|
||||
data object Home : 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 RoomTypes(val propertyId: String) : AppRoute
|
||||
data class AddRoomType(val propertyId: String) : AppRoute
|
||||
data class EditRoomType(val propertyId: String, val roomTypeId: 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.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.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun BookingCreateScreen(
|
||||
propertyId: String,
|
||||
onBack: () -> Unit,
|
||||
onCreated: (com.android.trisolarispms.data.api.model.BookingCreateResponse, com.android.trisolarispms.data.api.model.GuestDto?, String?) -> Unit,
|
||||
viewModel: BookingCreateViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
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 relationMenuExpanded = remember { mutableStateOf(false) }
|
||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||
val billingModeMenuExpanded = remember { mutableStateOf(false) }
|
||||
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
|
||||
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()
|
||||
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
|
||||
checkOutDate.value = defaultCheckoutDate
|
||||
checkOutTime.value = "11:00"
|
||||
viewModel.onExpectedCheckOutAtChange(formatBookingIso(defaultCheckoutDate, checkOutTime.value))
|
||||
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
|
||||
}
|
||||
|
||||
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)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()
|
||||
}
|
||||
val checkOutDisplay = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()
|
||||
}
|
||||
val cardDateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
val cardTimeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
|
||||
val totalTimeText = remember(state.expectedCheckInAt, state.expectedCheckOutAt) {
|
||||
val start = runCatching { OffsetDateTime.parse(state.expectedCheckInAt) }.getOrNull()
|
||||
val end = runCatching { OffsetDateTime.parse(state.expectedCheckOutAt) }.getOrNull()
|
||||
formatBookingDurationText(start, end)
|
||||
}
|
||||
BookingDateTimeQuickEditorCard(
|
||||
checkInDateText = checkInDisplay?.format(cardDateFormatter) ?: "--/--/----",
|
||||
checkInTimeText = checkInDisplay?.format(cardTimeFormatter) ?: "--:--",
|
||||
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
|
||||
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
|
||||
totalTimeText = totalTimeText,
|
||||
checkInEditable = true,
|
||||
onCheckInDateClick = { showCheckInDatePicker.value = true },
|
||||
onCheckInTimeClick = { showCheckInTimePicker.value = true },
|
||||
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
|
||||
onCheckOutTimeClick = { showCheckOutTimePicker.value = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = billingModeMenuExpanded.value,
|
||||
onExpandedChange = { billingModeMenuExpanded.value = !billingModeMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.billingMode.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Billing Mode") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = billingModeMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = billingModeMenuExpanded.value,
|
||||
onDismissRequest = { billingModeMenuExpanded.value = false }
|
||||
) {
|
||||
BookingBillingMode.entries.forEach { mode ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(mode.name) },
|
||||
onClick = {
|
||||
billingModeMenuExpanded.value = false
|
||||
viewModel.onBillingModeChange(mode)
|
||||
viewModel.refreshExpectedCheckoutPreview(propertyId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
BookingTimePickerTextField(
|
||||
value = state.billingCheckoutTime,
|
||||
label = { Text("Billing check-out (HH:mm)") },
|
||||
onTimeSelected = { selectedTime ->
|
||||
viewModel.onBillingCheckoutTimeChange(selectedTime)
|
||||
viewModel.refreshExpectedCheckoutPreview(propertyId)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
|
||||
val phoneDigitsLength = state.phoneNationalNumber.length
|
||||
val phoneIsComplete = phoneDigitsLength == selectedCountry.maxLength
|
||||
val phoneE164 = if (phoneIsComplete) {
|
||||
"+${selectedCountry.dialCode}${state.phoneNationalNumber}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
LaunchedEffect(propertyId, phoneE164) {
|
||||
if (phoneE164 != null) {
|
||||
viewModel.fetchPhoneVisitCount(propertyId, phoneE164)
|
||||
} else {
|
||||
viewModel.clearPhoneVisitCount()
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Guest Phone (optional)", style = MaterialTheme.typography.titleSmall)
|
||||
if (phoneIsComplete && state.phoneVisitCountLoading) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "|", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "checking...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val count = state.phoneVisitCount ?: 0
|
||||
if (count > 0) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "|", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "${count} times visited",
|
||||
color = Color(0xFF2E7D32),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
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 = relationMenuExpanded.value,
|
||||
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.memberRelation,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Member Relation (optional)") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onDismissRequest = { relationMenuExpanded.value = false }
|
||||
) {
|
||||
BookingProfileOptions.memberRelations.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
viewModel.onMemberRelationChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.transportMode.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transport Mode") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = transportMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onDismissRequest = { transportMenuExpanded.value = false }
|
||||
) {
|
||||
BookingProfileOptions.transportModes.forEach { option ->
|
||||
val optionLabel = option.ifBlank { "Not set" }
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel) },
|
||||
onClick = {
|
||||
transportMenuExpanded.value = false
|
||||
viewModel.onTransportModeChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.childCount,
|
||||
onValueChange = viewModel::onChildCountChange,
|
||||
label = { Text("Child Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.maleCount,
|
||||
onValueChange = viewModel::onMaleCountChange,
|
||||
label = { Text("Male Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.femaleCount,
|
||||
onValueChange = viewModel::onFemaleCountChange,
|
||||
label = { Text("Female Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
val showExpectedGuestCount = state.maleCount.isBlank() && state.femaleCount.isBlank()
|
||||
if (showExpectedGuestCount) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.expectedGuestCount,
|
||||
onValueChange = viewModel::onExpectedGuestCountChange,
|
||||
label = { Text("Expected Guest Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.notes,
|
||||
onValueChange = viewModel::onNotesChange,
|
||||
label = { Text("Notes (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (state.isLoading) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
state.error?.let {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCheckInDatePicker.value) {
|
||||
BookingDatePickerDialog(
|
||||
initialDate = checkInDate.value ?: LocalDate.now(),
|
||||
minDate = LocalDate.now(),
|
||||
onDismiss = { showCheckInDatePicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
applyCheckInSelection(selectedDate, checkInTime.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 = { 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||
|
||||
data class BookingCreateState(
|
||||
val phoneCountryCode: String = "IN",
|
||||
val phoneNationalNumber: String = "",
|
||||
val phoneVisitCount: Int? = null,
|
||||
val phoneVisitCountLoading: Boolean = false,
|
||||
val phoneVisitCountPhone: String? = null,
|
||||
val expectedCheckInAt: String = "",
|
||||
val expectedCheckOutAt: String = "",
|
||||
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 = "",
|
||||
val expectedGuestCount: String = "",
|
||||
val notes: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -0,0 +1,421 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
|
||||
import com.android.trisolarispms.core.viewmodel.CitySearchController
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.GeoSearchRepository
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||
import com.android.trisolarispms.data.api.model.BookingCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingCreateResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
|
||||
import com.android.trisolarispms.data.api.model.GuestDto
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class BookingCreateViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private companion object {
|
||||
const val DEFAULT_PREVIEW_BILLABLE_NIGHTS = 1
|
||||
}
|
||||
|
||||
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 { 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 ->
|
||||
val trimmed = current.phoneNationalNumber.filter { it.isDigit() }.take(option.maxLength)
|
||||
current.copy(
|
||||
phoneCountryCode = value,
|
||||
phoneNationalNumber = trimmed,
|
||||
phoneVisitCount = if (trimmed.length == option.maxLength) current.phoneVisitCount else null,
|
||||
phoneVisitCountPhone = null,
|
||||
phoneVisitCountLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPhoneNationalNumberChange(value: String) {
|
||||
val option = findPhoneCountryOption(_state.value.phoneCountryCode)
|
||||
val trimmed = value.filter { it.isDigit() }.take(option.maxLength)
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneNationalNumber = trimmed,
|
||||
phoneVisitCount = if (trimmed.length == option.maxLength) it.phoneVisitCount else null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPhoneVisitCount() {
|
||||
_state.update { it.copy(phoneVisitCount = null, phoneVisitCountPhone = null, phoneVisitCountLoading = false) }
|
||||
}
|
||||
|
||||
fun fetchPhoneVisitCount(propertyId: String, phoneE164: String) {
|
||||
val current = _state.value
|
||||
if (current.phoneVisitCountLoading || current.phoneVisitCountPhone == phoneE164) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(phoneVisitCountLoading = true, phoneVisitCountPhone = phoneE164, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.getGuestVisitCount(propertyId = propertyId, phone = phoneE164)
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneVisitCount = body.bookingCount ?: 0,
|
||||
phoneVisitCountLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(phoneVisitCountLoading = false) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_state.update { it.copy(phoneVisitCountLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSourceChange(value: String) {
|
||||
_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,
|
||||
isTransportModeAuto = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onChildCountChange(value: String) {
|
||||
_state.update { current ->
|
||||
current.copy(childCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onMaleCountChange(value: String) {
|
||||
_state.update { current ->
|
||||
current.copy(maleCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onFemaleCountChange(value: String) {
|
||||
_state.update { current ->
|
||||
current.copy(femaleCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onExpectedGuestCountChange(value: String) {
|
||||
_state.update { it.copy(expectedGuestCount = value.filter { it.isDigit() }, error = null) }
|
||||
}
|
||||
|
||||
fun onNotesChange(value: String) {
|
||||
_state.update { it.copy(notes = value, error = null) }
|
||||
}
|
||||
|
||||
fun submit(propertyId: String, onDone: (BookingCreateResponse, GuestDto?, String?) -> Unit) {
|
||||
val current = state.value
|
||||
val checkIn = current.expectedCheckInAt.trim()
|
||||
val checkOut = current.expectedCheckOutAt.trim()
|
||||
if (checkIn.isBlank() || checkOut.isBlank()) {
|
||||
_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()
|
||||
val expectedGuestCount = current.expectedGuestCount.toIntOrNull()
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val phoneCountry = findPhoneCountryOption(current.phoneCountryCode)
|
||||
val phoneDigits = current.phoneNationalNumber.trim()
|
||||
val phone = if (phoneDigits.isNotBlank()) {
|
||||
"+${phoneCountry.dialCode}$phoneDigits"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val response = api.createBooking(
|
||||
propertyId = propertyId,
|
||||
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,
|
||||
femaleCount = femaleCount,
|
||||
expectedGuestCount = expectedGuestCount,
|
||||
notes = current.notes.trim().ifBlank { null }
|
||||
)
|
||||
)
|
||||
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 {
|
||||
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncCreateBookingCaches(propertyId: String, bookingId: String?) {
|
||||
activeRoomStayRepository.refresh(propertyId = propertyId)
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "OPEN")
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "CHECKED_IN")
|
||||
val safeBookingId = bookingId?.trim().orEmpty()
|
||||
if (safeBookingId.isNotBlank()) {
|
||||
bookingDetailsRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = safeBookingId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookingCreateState.withDefaultTransportModeForCheckIn(expectedCheckInAt: String): BookingCreateState {
|
||||
if (!isTransportModeAuto) return this
|
||||
val defaultMode = if (isFutureBookingCheckIn(expectedCheckInAt)) "" else "CAR"
|
||||
if (transportMode == defaultMode) return this
|
||||
return copy(transportMode = defaultMode)
|
||||
}
|
||||
|
||||
private fun BookingCreateState.withDefaultMemberRelationForFamily(): BookingCreateState {
|
||||
if (memberRelation.isNotBlank()) return this
|
||||
val child = childCount.toIntOrNull() ?: 0
|
||||
val male = maleCount.toIntOrNull() ?: 0
|
||||
val female = femaleCount.toIntOrNull() ?: 0
|
||||
val shouldDefaultFamily = child >= 1 || (male >= 1 && female >= 1)
|
||||
if (!shouldDefaultFamily) return this
|
||||
return copy(memberRelation = "FAMILY")
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.ui.calendar.CalendarDayCell
|
||||
import com.android.trisolarispms.ui.calendar.CalendarDaysOfWeekHeader
|
||||
import com.android.trisolarispms.ui.calendar.CalendarMonthHeader
|
||||
import com.kizitonwose.calendar.compose.HorizontalCalendar
|
||||
import com.kizitonwose.calendar.compose.rememberCalendarState
|
||||
import com.kizitonwose.calendar.core.DayPosition
|
||||
import com.kizitonwose.calendar.core.daysOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
internal fun BookingDateTimePickerDialog(
|
||||
title: String,
|
||||
initialDate: LocalDate?,
|
||||
initialTime: String,
|
||||
minDate: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (LocalDate, String) -> Unit
|
||||
) {
|
||||
val (selectedDate, timeValue, daysOfWeek, calendarState) = rememberBookingDateTimePickerState(
|
||||
initialDate = initialDate,
|
||||
initialTime = initialTime,
|
||||
minDate = minDate
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
BookingDateTimePickerContent(
|
||||
selectedDate = selectedDate,
|
||||
timeValue = timeValue,
|
||||
minDate = minDate,
|
||||
daysOfWeek = daysOfWeek,
|
||||
calendarState = calendarState
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val time = timeValue.value.ifBlank { initialTime }
|
||||
val date = if (selectedDate.value.isBefore(minDate)) minDate else selectedDate.value
|
||||
onConfirm(date, time)
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BookingDateTimePickerInline(
|
||||
title: String,
|
||||
initialDate: LocalDate?,
|
||||
initialTime: String,
|
||||
minDate: LocalDate,
|
||||
onValueChange: (LocalDate, String) -> Unit
|
||||
) {
|
||||
val (selectedDate, timeValue, daysOfWeek, calendarState) = rememberBookingDateTimePickerState(
|
||||
initialDate = initialDate,
|
||||
initialTime = initialTime,
|
||||
minDate = minDate
|
||||
)
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BookingDateTimePickerContent(
|
||||
selectedDate = selectedDate,
|
||||
timeValue = timeValue,
|
||||
minDate = minDate,
|
||||
daysOfWeek = daysOfWeek,
|
||||
calendarState = calendarState,
|
||||
onDateSelected = { date ->
|
||||
val safeDate = if (date.isBefore(minDate)) minDate else date
|
||||
onValueChange(safeDate, timeValue.value)
|
||||
},
|
||||
onTimeSelected = { time ->
|
||||
val safeDate = if (selectedDate.value.isBefore(minDate)) minDate else selectedDate.value
|
||||
onValueChange(safeDate, time)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookingDateTimePickerContent(
|
||||
selectedDate: MutableState<LocalDate>,
|
||||
timeValue: MutableState<String>,
|
||||
minDate: LocalDate,
|
||||
daysOfWeek: List<java.time.DayOfWeek>,
|
||||
calendarState: com.kizitonwose.calendar.compose.CalendarState,
|
||||
onDateSelected: (LocalDate) -> Unit = {},
|
||||
onTimeSelected: (String) -> Unit = {}
|
||||
) {
|
||||
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
|
||||
Column {
|
||||
CalendarDaysOfWeekHeader(daysOfWeek)
|
||||
HorizontalCalendar(
|
||||
state = calendarState,
|
||||
dayContent = { day ->
|
||||
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
|
||||
CalendarDayCell(
|
||||
day = day,
|
||||
isSelectedStart = selectedDate.value == day.date,
|
||||
isSelectedEnd = false,
|
||||
isInRange = false,
|
||||
hasRate = false,
|
||||
isSelectable = selectable,
|
||||
onClick = {
|
||||
selectedDate.value = day.date
|
||||
onDateSelected(day.date)
|
||||
}
|
||||
)
|
||||
},
|
||||
monthHeader = { month ->
|
||||
CalendarMonthHeader(month)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BookingTimePickerTextField(
|
||||
value = timeValue.value,
|
||||
onTimeSelected = {
|
||||
timeValue.value = it
|
||||
onTimeSelected(it)
|
||||
},
|
||||
label = { Text("Time (HH:MM)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBookingDateTimePickerState(
|
||||
initialDate: LocalDate?,
|
||||
initialTime: String,
|
||||
minDate: LocalDate
|
||||
): PickerUiState {
|
||||
val now = remember { LocalDate.now() }
|
||||
val initialSelectedDate = remember(initialDate, minDate, now) {
|
||||
val seed = initialDate ?: now
|
||||
if (seed.isBefore(minDate)) minDate else seed
|
||||
}
|
||||
val selectedDate = remember(initialSelectedDate) { mutableStateOf(initialSelectedDate) }
|
||||
val timeValue = remember(initialTime) { mutableStateOf(initialTime) }
|
||||
val startMonth = remember(minDate) { YearMonth.from(minDate) }
|
||||
val firstVisibleMonth = remember(initialSelectedDate) { YearMonth.from(initialSelectedDate) }
|
||||
val endMonth = remember(startMonth) { startMonth.plusMonths(24) }
|
||||
val daysOfWeek = remember { daysOfWeek() }
|
||||
val calendarState = rememberCalendarState(
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
firstVisibleMonth = firstVisibleMonth,
|
||||
firstDayOfWeek = daysOfWeek.first()
|
||||
)
|
||||
return PickerUiState(
|
||||
selectedDate = selectedDate,
|
||||
timeValue = timeValue,
|
||||
daysOfWeek = daysOfWeek,
|
||||
calendarState = calendarState
|
||||
)
|
||||
}
|
||||
|
||||
private data class PickerUiState(
|
||||
val selectedDate: MutableState<LocalDate>,
|
||||
val timeValue: MutableState<String>,
|
||||
val daysOfWeek: List<java.time.DayOfWeek>,
|
||||
val calendarState: com.kizitonwose.calendar.compose.CalendarState
|
||||
)
|
||||
|
||||
internal fun formatBookingIso(date: LocalDate, time: String): String {
|
||||
val parts = time.split(":")
|
||||
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
|
||||
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
|
||||
val zone = ZoneId.of("Asia/Kolkata")
|
||||
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
|
||||
val offset = zone.rules.getOffset(localDateTime)
|
||||
return OffsetDateTime.of(localDateTime, offset)
|
||||
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
internal fun BookingDateTimeQuickEditorCard(
|
||||
checkInDateText: String,
|
||||
checkInTimeText: String,
|
||||
checkOutDateText: String,
|
||||
checkOutTimeText: String,
|
||||
totalTimeText: String?,
|
||||
checkInEditable: Boolean,
|
||||
checkOutEditable: Boolean = true,
|
||||
onCheckInDateClick: () -> Unit,
|
||||
onCheckInTimeClick: () -> Unit,
|
||||
onCheckOutDateClick: () -> Unit,
|
||||
onCheckOutTimeClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
BookingDateTimeQuickEditorRow(
|
||||
label = "Check In Time:",
|
||||
dateText = checkInDateText,
|
||||
timeText = checkInTimeText,
|
||||
editable = checkInEditable,
|
||||
onDateClick = onCheckInDateClick,
|
||||
onTimeClick = onCheckInTimeClick
|
||||
)
|
||||
BookingDateTimeQuickEditorRow(
|
||||
label = "Check Out Time:",
|
||||
dateText = checkOutDateText,
|
||||
timeText = checkOutTimeText,
|
||||
editable = checkOutEditable,
|
||||
onDateClick = onCheckOutDateClick,
|
||||
onTimeClick = onCheckOutTimeClick
|
||||
)
|
||||
if (!totalTimeText.isNullOrBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Total Time:",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
text = totalTimeText,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookingDateTimeQuickEditorRow(
|
||||
label: String,
|
||||
dateText: String,
|
||||
timeText: String,
|
||||
editable: Boolean,
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit
|
||||
) {
|
||||
val valueColor = if (editable) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = timeText,
|
||||
color = valueColor,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.clickable(enabled = editable, onClick = onTimeClick)
|
||||
.padding(vertical = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = dateText,
|
||||
color = valueColor,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.clickable(enabled = editable, onClick = onDateClick)
|
||||
.padding(vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BookingDatePickerDialog(
|
||||
initialDate: LocalDate,
|
||||
minDate: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onDateSelected: (LocalDate) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState = rememberUpdatedState(onDismiss)
|
||||
val selectDateState = rememberUpdatedState(onDateSelected)
|
||||
|
||||
DisposableEffect(context, initialDate, minDate) {
|
||||
val dialog = DatePickerDialog(
|
||||
context,
|
||||
{ _, year, monthOfYear, dayOfMonth ->
|
||||
selectDateState.value(LocalDate.of(year, monthOfYear + 1, dayOfMonth))
|
||||
},
|
||||
initialDate.year,
|
||||
initialDate.monthValue - 1,
|
||||
initialDate.dayOfMonth
|
||||
)
|
||||
val minDateMillis = minDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
dialog.datePicker.minDate = minDateMillis
|
||||
dialog.setOnDismissListener { dismissState.value() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BookingTimePickerDialog(
|
||||
initialTime: String,
|
||||
onDismiss: () -> Unit,
|
||||
onTimeSelected: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState = rememberUpdatedState(onDismiss)
|
||||
val selectTimeState = rememberUpdatedState(onTimeSelected)
|
||||
|
||||
val initialHour = initialTime.split(":").getOrNull(0)?.toIntOrNull()?.coerceIn(0, 23) ?: 12
|
||||
val initialMinute = initialTime.split(":").getOrNull(1)?.toIntOrNull()?.coerceIn(0, 59) ?: 0
|
||||
|
||||
DisposableEffect(context, initialHour, initialMinute) {
|
||||
val dialog = TimePickerDialog(
|
||||
context,
|
||||
{ _, hourOfDay, minute ->
|
||||
selectTimeState.value("%02d:%02d".format(hourOfDay, minute))
|
||||
},
|
||||
initialHour,
|
||||
initialMinute,
|
||||
true
|
||||
)
|
||||
dialog.setOnDismissListener { dismissState.value() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatBookingDurationText(
|
||||
start: OffsetDateTime?,
|
||||
end: OffsetDateTime?
|
||||
): String? {
|
||||
if (start == null || end == null || !end.isAfter(start)) return null
|
||||
val totalMinutes = Duration.between(start, end).toMinutes()
|
||||
val totalHours = totalMinutes / 60
|
||||
val minutes = totalMinutes % 60
|
||||
return if (totalHours >= 24) {
|
||||
val days = totalHours / 24
|
||||
val hoursLeft = totalHours % 24
|
||||
"%02dd:%02dh left".format(days, hoursLeft)
|
||||
} else if (totalHours > 0) {
|
||||
"${totalHours}h ${minutes}m"
|
||||
} else {
|
||||
"${minutes}m"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
|
||||
@Composable
|
||||
fun BookingRoomRequestScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
fromAt: String,
|
||||
toAt: String,
|
||||
onBack: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
viewModel: BookingRoomRequestViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(propertyId, fromAt, toAt) {
|
||||
viewModel.load(propertyId, fromAt, toAt)
|
||||
}
|
||||
|
||||
BackTopBarScaffold(
|
||||
title = "Select Room Types",
|
||||
onBack = onBack,
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val hasSelection = state.roomTypes.any { it.quantity > 0 }
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.submit(propertyId, bookingId, fromAt, toAt, onDone)
|
||||
},
|
||||
enabled = hasSelection && !state.isSubmitting,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (state.isSubmitting) "Saving..." else "Proceed")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
PaddedScreenColumn(padding = padding, contentPadding = 16.dp) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (!state.isLoading) {
|
||||
if (state.roomTypes.isEmpty()) {
|
||||
Text(text = "No room types found")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(state.roomTypes) { item ->
|
||||
RoomTypeQuantityCard(
|
||||
item = item,
|
||||
onIncrease = { viewModel.increaseQuantity(item.roomTypeCode) },
|
||||
onDecrease = { viewModel.decreaseQuantity(item.roomTypeCode) },
|
||||
onRateChange = { viewModel.updateRate(item.roomTypeCode, it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomTypeQuantityCard(
|
||||
item: BookingRoomTypeQuantityItem,
|
||||
onIncrease: () -> Unit,
|
||||
onDecrease: () -> Unit,
|
||||
onRateChange: (String) -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = item.roomTypeName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "${item.roomTypeCode} • Available: ${item.maxQuantity}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onDecrease, enabled = item.quantity > 0) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "Decrease")
|
||||
}
|
||||
Text(
|
||||
text = item.quantity.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onIncrease, enabled = item.quantity < item.maxQuantity) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Increase")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = item.rateInput,
|
||||
onValueChange = onRateChange,
|
||||
label = { Text("Rate / night") },
|
||||
placeholder = { Text("Enter rate") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
prefix = { item.currency?.takeIf { it.isNotBlank() }?.let { Text("$it ") } },
|
||||
supportingText = {
|
||||
item.ratePlanCode?.takeIf { it.isNotBlank() }?.let { Text("Plan: $it") }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
data class BookingRoomTypeQuantityItem(
|
||||
val roomTypeCode: String,
|
||||
val roomTypeName: String,
|
||||
val maxQuantity: Int,
|
||||
val quantity: Int = 0,
|
||||
val rateInput: String = "",
|
||||
val currency: String? = null,
|
||||
val ratePlanCode: String? = null
|
||||
)
|
||||
|
||||
data class BookingRoomRequestState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSubmitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
val roomTypes: List<BookingRoomTypeQuantityItem> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.bookinglist.BookingListRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class BookingRoomRequestViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
private val _state = MutableStateFlow(BookingRoomRequestState())
|
||||
val state: StateFlow<BookingRoomRequestState> = _state
|
||||
private val activeRoomStayRepository = ActiveRoomStayRepository(
|
||||
dao = LocalDatabaseProvider.get(application).activeRoomStayCacheDao()
|
||||
)
|
||||
private val bookingListRepository = BookingListRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingListCacheDao()
|
||||
)
|
||||
private val bookingDetailsRepository = BookingDetailsRepository(
|
||||
dao = LocalDatabaseProvider.get(application).bookingDetailsCacheDao()
|
||||
)
|
||||
|
||||
fun load(propertyId: String, fromAt: String, toAt: String) {
|
||||
if (propertyId.isBlank()) return
|
||||
val fromDate = fromAt.toDateOnly() ?: run {
|
||||
_state.update { it.copy(error = "Invalid check-in date") }
|
||||
return
|
||||
}
|
||||
val toDate = toAt.toDateOnly() ?: run {
|
||||
_state.update { it.copy(error = "Invalid check-out date") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val availabilityResponse = api.getRoomAvailabilityRange(propertyId, from = fromDate, to = toDate)
|
||||
if (!availabilityResponse.isSuccessful) {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${availabilityResponse.code()}") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val currentByType = _state.value.roomTypes.associateBy { it.roomTypeCode }
|
||||
val items = availabilityResponse.body().orEmpty()
|
||||
.mapNotNull { entry ->
|
||||
val maxQuantity = (entry.freeCount ?: entry.freeRoomNumbers.size).coerceAtLeast(0)
|
||||
if (maxQuantity <= 0) return@mapNotNull null
|
||||
val code = entry.roomTypeCode?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
val name = entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() } ?: code
|
||||
val previous = currentByType[code]
|
||||
val defaultRateInput = entry.averageRate?.toLong()?.toString().orEmpty()
|
||||
BookingRoomTypeQuantityItem(
|
||||
roomTypeCode = code,
|
||||
roomTypeName = name,
|
||||
maxQuantity = maxQuantity,
|
||||
quantity = previous?.quantity?.coerceAtMost(maxQuantity) ?: 0,
|
||||
rateInput = previous?.rateInput ?: defaultRateInput,
|
||||
currency = entry.currency,
|
||||
ratePlanCode = entry.ratePlanCode
|
||||
)
|
||||
}
|
||||
.sortedBy { it.roomTypeName }
|
||||
|
||||
_state.update { it.copy(isLoading = false, roomTypes = items, error = null) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseQuantity(roomTypeCode: String) {
|
||||
updateQuantity(roomTypeCode, delta = 1)
|
||||
}
|
||||
|
||||
fun decreaseQuantity(roomTypeCode: String) {
|
||||
updateQuantity(roomTypeCode, delta = -1)
|
||||
}
|
||||
|
||||
fun updateRate(roomTypeCode: String, value: String) {
|
||||
val digitsOnly = value.filter { it.isDigit() }
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
roomTypes = current.roomTypes.map { item ->
|
||||
if (item.roomTypeCode == roomTypeCode) {
|
||||
item.copy(rateInput = digitsOnly)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
},
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submit(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
fromAt: String,
|
||||
toAt: String,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank() || fromAt.isBlank() || toAt.isBlank()) {
|
||||
_state.update { it.copy(error = "Booking dates are missing") }
|
||||
return
|
||||
}
|
||||
val selected = _state.value.roomTypes.filter { it.quantity > 0 }
|
||||
if (selected.isEmpty()) {
|
||||
_state.update { it.copy(error = "Select at least one room type") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSubmitting = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
for (item in selected) {
|
||||
val response = api.createRoomRequest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingRoomRequestCreateRequest(
|
||||
roomTypeCode = item.roomTypeCode,
|
||||
quantity = item.quantity,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
_state.update { it.copy(isSubmitting = false, error = "Create failed: ${response.code()}") }
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
syncRoomRequestCaches(propertyId = propertyId, bookingId = bookingId)
|
||||
_state.update { it.copy(isSubmitting = false, error = null) }
|
||||
onDone()
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isSubmitting = false, error = e.localizedMessage ?: "Create failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateQuantity(roomTypeCode: String, delta: Int) {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
roomTypes = current.roomTypes.map { item ->
|
||||
if (item.roomTypeCode != roomTypeCode) {
|
||||
item
|
||||
} else {
|
||||
val updated = (item.quantity + delta).coerceIn(0, item.maxQuantity)
|
||||
item.copy(quantity = updated)
|
||||
}
|
||||
},
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncRoomRequestCaches(propertyId: String, bookingId: String) {
|
||||
activeRoomStayRepository.refresh(propertyId = propertyId)
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "OPEN")
|
||||
bookingListRepository.refreshByStatus(propertyId = propertyId, status = "CHECKED_IN")
|
||||
bookingDetailsRepository.refreshBookingDetails(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toDateOnly(): String? =
|
||||
runCatching {
|
||||
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import android.app.TimePickerDialog
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
internal fun BookingTimePickerTextField(
|
||||
value: String,
|
||||
label: @Composable () -> Unit,
|
||||
onTimeSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val parsed = runCatching {
|
||||
val parts = value.split(":")
|
||||
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 12
|
||||
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
|
||||
(hour.coerceIn(0, 23)) to (minute.coerceIn(0, 59))
|
||||
}.getOrDefault(12 to 0)
|
||||
|
||||
fun openDialog() {
|
||||
TimePickerDialog(
|
||||
context,
|
||||
{ _, hourOfDay, minute ->
|
||||
onTimeSelected("%02d:%02d".format(hourOfDay, minute))
|
||||
},
|
||||
parsed.first,
|
||||
parsed.second,
|
||||
true
|
||||
).show()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = label,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = ::openDialog) {
|
||||
Icon(Icons.Default.Schedule, contentDescription = "Pick time")
|
||||
}
|
||||
},
|
||||
modifier = modifier.clickable(onClick = ::openDialog)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberType
|
||||
import java.util.Locale
|
||||
|
||||
data class PhoneCountryOption(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val dialCode: String,
|
||||
val maxLength: Int
|
||||
)
|
||||
|
||||
private val fallbackPhoneCountries: List<PhoneCountryOption> = listOf(
|
||||
PhoneCountryOption(code = "IN", name = "India", dialCode = "91", maxLength = 10),
|
||||
PhoneCountryOption(code = "US", name = "United States", dialCode = "1", maxLength = 10)
|
||||
)
|
||||
|
||||
private val phoneCountryOptions: List<PhoneCountryOption> by lazy {
|
||||
val util = PhoneNumberUtil.getInstance()
|
||||
val regions = util.supportedRegions
|
||||
val options = regions.mapNotNull { region ->
|
||||
val dialCode = util.getCountryCodeForRegion(region)
|
||||
if (dialCode == 0) return@mapNotNull null
|
||||
val maxLength = guessMaxLength(util, region) ?: 15
|
||||
val name = Locale("", region).displayCountry
|
||||
PhoneCountryOption(
|
||||
code = region,
|
||||
name = name,
|
||||
dialCode = dialCode.toString(),
|
||||
maxLength = maxLength
|
||||
)
|
||||
}.sortedWith(compareBy({ it.name.lowercase() }, { it.dialCode }))
|
||||
if (options.isNotEmpty()) options else fallbackPhoneCountries
|
||||
}
|
||||
|
||||
fun phoneCountryOptions(): List<PhoneCountryOption> = phoneCountryOptions
|
||||
|
||||
fun findPhoneCountryOption(code: String): PhoneCountryOption {
|
||||
return phoneCountryOptions.firstOrNull { it.code == code }
|
||||
?: phoneCountryOptions.first()
|
||||
}
|
||||
|
||||
private fun guessMaxLength(util: PhoneNumberUtil, region: String): Int? {
|
||||
val types = listOf(
|
||||
PhoneNumberType.MOBILE,
|
||||
PhoneNumberType.FIXED_LINE,
|
||||
PhoneNumberType.FIXED_LINE_OR_MOBILE
|
||||
)
|
||||
val lengths = types.mapNotNull { type ->
|
||||
util.getExampleNumberForType(region, type)?.let { example ->
|
||||
util.getNationalSignificantNumber(example).length
|
||||
}
|
||||
}
|
||||
return lengths.maxOrNull()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package com.android.trisolarispms.ui.card
|
||||
|
||||
import android.app.Activity
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.MifareClassic
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
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.core.ApiClient
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun CardInfoScreen(
|
||||
propertyId: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
|
||||
val scope = rememberCoroutineScope()
|
||||
val lastTag = remember { mutableStateOf<Tag?>(null) }
|
||||
val cardId = remember { mutableStateOf<String?>(null) }
|
||||
val roomNumber = remember { mutableStateOf<String?>(null) }
|
||||
val cardIndex = remember { mutableStateOf<String?>(null) }
|
||||
val issuedBy = remember { mutableStateOf<String?>(null) }
|
||||
val issuedById = remember { mutableStateOf<String?>(null) }
|
||||
val issuedAt = remember { mutableStateOf<String?>(null) }
|
||||
val expiresAt = remember { mutableStateOf<String?>(null) }
|
||||
val expired = remember { mutableStateOf<Boolean?>(null) }
|
||||
val error = remember { mutableStateOf<String?>(null) }
|
||||
val revokeError = remember { mutableStateOf<String?>(null) }
|
||||
val revokeStatus = remember { mutableStateOf<String?>(null) }
|
||||
val showRevokeConfirm = remember { mutableStateOf(false) }
|
||||
val showRevokeSuccess = remember { mutableStateOf(false) }
|
||||
|
||||
val composition = rememberLottieComposition(
|
||||
LottieCompositionSpec.Asset("scan_nfc_animation.json")
|
||||
)
|
||||
val progress = animateLottieCompositionAsState(
|
||||
composition = composition.value,
|
||||
iterations = LottieConstants.IterateForever
|
||||
)
|
||||
|
||||
DisposableEffect(activity, nfcAdapter) {
|
||||
if (activity != null && nfcAdapter != null) {
|
||||
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||
val callback = NfcAdapter.ReaderCallback { tag ->
|
||||
lastTag.value = tag
|
||||
handleTag(
|
||||
tag = tag,
|
||||
cardId = cardId,
|
||||
roomNumber = roomNumber,
|
||||
cardIndex = cardIndex,
|
||||
issuedBy = issuedBy,
|
||||
issuedById = issuedById,
|
||||
issuedAt = issuedAt,
|
||||
expiresAt = expiresAt,
|
||||
expired = expired,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
nfcAdapter.enableReaderMode(activity, callback, flags, null)
|
||||
onDispose { nfcAdapter.disableReaderMode(activity) }
|
||||
} else {
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
BackTopBarScaffold(
|
||||
title = "Card Details",
|
||||
onBack = onBack
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
text = "Tap a card on the back of the phone",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LottieAnimation(
|
||||
composition = composition.value,
|
||||
progress = { progress.value },
|
||||
modifier = Modifier.size(180.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
error.value?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
revokeError.value?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
revokeStatus.value?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
infoRow("Card ID", cardId.value)
|
||||
infoRow("Room", roomNumber.value)
|
||||
infoRow("Card Index", cardIndex.value)
|
||||
infoRow("Issued By", issuedBy.value)
|
||||
infoRow("Issuer ID", issuedById.value)
|
||||
infoRow("Issued At", issuedAt.value)
|
||||
infoRow("Expires At", expiresAt.value)
|
||||
expired.value?.let {
|
||||
infoRow("Expired", if (it) "Yes" else "No")
|
||||
}
|
||||
if (!cardId.value.isNullOrBlank() && expired.value != true) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(onClick = { showRevokeConfirm.value = true }) {
|
||||
Text("Revoke Card")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRevokeConfirm.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRevokeConfirm.value = false },
|
||||
title = { Text("Revoke card?") },
|
||||
text = { Text("This will revoke the current card and make it inactive.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRevokeConfirm.value = false
|
||||
val currentIndex = cardIndex.value
|
||||
val currentTag = lastTag.value
|
||||
if (currentIndex.isNullOrBlank() || currentTag == null) {
|
||||
revokeError.value = "Place the card on the phone to revoke."
|
||||
return@TextButton
|
||||
}
|
||||
val indexValue = currentIndex.toIntOrNull()
|
||||
if (indexValue == null) {
|
||||
revokeError.value = "Invalid card index."
|
||||
return@TextButton
|
||||
}
|
||||
revokeError.value = null
|
||||
revokeStatus.value = "Revoking..."
|
||||
scope.launch {
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.revokeCard(propertyId, indexValue.toString())
|
||||
val body = response.body()
|
||||
if (response.isSuccessful && body?.timeData != null) {
|
||||
val writeResult = writeRevokeTimeData(currentTag, body.timeData)
|
||||
if (writeResult == null) {
|
||||
revokeStatus.value = "Card revoked."
|
||||
showRevokeSuccess.value = true
|
||||
expired.value = true
|
||||
cardId.value = null
|
||||
roomNumber.value = null
|
||||
cardIndex.value = null
|
||||
issuedBy.value = null
|
||||
issuedById.value = null
|
||||
issuedAt.value = null
|
||||
expiresAt.value = null
|
||||
error.value = null
|
||||
} else {
|
||||
revokeStatus.value = null
|
||||
revokeError.value = writeResult
|
||||
}
|
||||
} else {
|
||||
revokeStatus.value = null
|
||||
revokeError.value = "Revoke failed: ${response.code()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revokeStatus.value = null
|
||||
revokeError.value = e.localizedMessage ?: "Revoke failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Revoke")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRevokeConfirm.value = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showRevokeSuccess.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRevokeSuccess.value = false },
|
||||
title = { Text("Card revoked") },
|
||||
text = { Text("Card revoked successfully.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showRevokeSuccess.value = false
|
||||
onBack()
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun infoRow(label: String, value: String?) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "$label: $value",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleTag(
|
||||
tag: Tag,
|
||||
cardId: androidx.compose.runtime.MutableState<String?>,
|
||||
roomNumber: androidx.compose.runtime.MutableState<String?>,
|
||||
cardIndex: androidx.compose.runtime.MutableState<String?>,
|
||||
issuedBy: androidx.compose.runtime.MutableState<String?>,
|
||||
issuedById: androidx.compose.runtime.MutableState<String?>,
|
||||
issuedAt: androidx.compose.runtime.MutableState<String?>,
|
||||
expiresAt: androidx.compose.runtime.MutableState<String?>,
|
||||
expired: androidx.compose.runtime.MutableState<Boolean?>,
|
||||
error: androidx.compose.runtime.MutableState<String?>
|
||||
) {
|
||||
val mifare = MifareClassic.get(tag) ?: run {
|
||||
error.value = "Unsupported card type (not Mifare Classic)."
|
||||
return
|
||||
}
|
||||
try {
|
||||
mifare.connect()
|
||||
val sectorIndex = 0
|
||||
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
if (!authA && !authB) {
|
||||
error.value = "Authentication failed for sector 0."
|
||||
return
|
||||
}
|
||||
val key = getBlockHex(1, mifare)
|
||||
val timeData = getBlockHex(2, mifare)
|
||||
cardId.value = tag.id.joinToString("") { String.format("%02X", it) }
|
||||
if (key.length >= 32) {
|
||||
roomNumber.value = key.substring(26, 28)
|
||||
cardIndex.value = key.substring(4, 10)
|
||||
}
|
||||
if (timeData.length >= 32) {
|
||||
val startMin = timeData.substring(10, 12)
|
||||
val startHour = timeData.substring(12, 14)
|
||||
val startDate = timeData.substring(14, 16)
|
||||
val startMonth = timeData.substring(16, 18)
|
||||
val startYear = timeData.substring(18, 20)
|
||||
issuedAt.value = "$startDate/$startMonth/$startYear $startHour:$startMin"
|
||||
|
||||
val endMin = timeData.substring(20, 22)
|
||||
val endHour = timeData.substring(22, 24)
|
||||
val endDate = timeData.substring(24, 26)
|
||||
val endMonth = timeData.substring(26, 28)
|
||||
val endYear = timeData.substring(28, 30)
|
||||
expiresAt.value = "$endDate/$endMonth/$endYear $endHour:$endMin"
|
||||
|
||||
val checkoutTimeLong = Calendar.getInstance().apply {
|
||||
set(Calendar.YEAR, 2000 + endYear.toInt())
|
||||
set(Calendar.MONTH, endMonth.toInt() - 1)
|
||||
set(Calendar.DAY_OF_MONTH, endDate.toInt())
|
||||
set(Calendar.HOUR_OF_DAY, endHour.toInt())
|
||||
set(Calendar.MINUTE, endMin.toInt())
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.time.time
|
||||
expired.value = Date().time > checkoutTimeLong
|
||||
}
|
||||
val authC = mifare.authenticateSectorWithKeyA(3, MifareClassic.KEY_DEFAULT)
|
||||
val authD = mifare.authenticateSectorWithKeyB(3, MifareClassic.KEY_DEFAULT)
|
||||
if (authC || authD) {
|
||||
val issuerBytes = mifare.readBlock(mifare.sectorToBlock(3) + 0)
|
||||
val issuerIdBytes = mifare.readBlock(mifare.sectorToBlock(3) + 1)
|
||||
issuedBy.value = issuerBytes.toString(Charsets.UTF_8).trim()
|
||||
issuedById.value = issuerIdBytes.toString(Charsets.UTF_8).trim()
|
||||
}
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
error.value = e.localizedMessage ?: "Read failed."
|
||||
} finally {
|
||||
try {
|
||||
mifare.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBlockHex(blockIndex: Int, mifareClassic: MifareClassic): String {
|
||||
val data = mifareClassic.readBlock(blockIndex)
|
||||
return data.joinToString("") { String.format("%02X", it) }
|
||||
}
|
||||
|
||||
private fun writeRevokeTimeData(tag: Tag, timeDataHex: String): String? {
|
||||
val mifare = MifareClassic.get(tag) ?: return "Unsupported card type (not Mifare Classic)."
|
||||
return try {
|
||||
mifare.connect()
|
||||
val sectorIndex = 0
|
||||
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
if (!authA && !authB) return "Card authentication failed (sector 0)."
|
||||
val timeBytes = hexToBytes(timeDataHex)
|
||||
if (timeBytes.size != 16) return "Invalid time data size."
|
||||
val blockIndex = mifare.sectorToBlock(sectorIndex)
|
||||
mifare.writeBlock(blockIndex + 2, timeBytes)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e.localizedMessage ?: "Write failed."
|
||||
} finally {
|
||||
try {
|
||||
mifare.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val clean = hex.trim().replace(" ", "")
|
||||
val len = clean.length
|
||||
if (len % 2 != 0) return ByteArray(0)
|
||||
val out = ByteArray(len / 2)
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
out[i / 2] = ((clean.substring(i, i + 2)).toInt(16) and 0xFF).toByte()
|
||||
i += 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -9,20 +9,18 @@ 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
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -34,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)
|
||||
@@ -48,6 +47,9 @@ fun IssueTemporaryCardScreen(
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
|
||||
val showSuccessDialog = remember { mutableStateOf(false) }
|
||||
val lastShownCardId = remember { mutableStateOf<String?>(null) }
|
||||
val resetVersion = remember { mutableStateOf(0) }
|
||||
val composition = rememberLottieComposition(
|
||||
LottieCompositionSpec.Asset("scan_nfc_animation.json")
|
||||
)
|
||||
@@ -56,6 +58,24 @@ fun IssueTemporaryCardScreen(
|
||||
iterations = LottieConstants.IterateForever
|
||||
)
|
||||
|
||||
LaunchedEffect(propertyId, roomId) {
|
||||
viewModel.reset()
|
||||
showSuccessDialog.value = false
|
||||
lastShownCardId.value = null
|
||||
resetVersion.value = resetVersion.value + 1
|
||||
}
|
||||
|
||||
LaunchedEffect(state.lastCardId, resetVersion.value) {
|
||||
val currentId = state.lastCardId
|
||||
if (resetVersion.value > 0 &&
|
||||
!currentId.isNullOrBlank() &&
|
||||
currentId != lastShownCardId.value
|
||||
) {
|
||||
lastShownCardId.value = currentId
|
||||
showSuccessDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(activity, nfcAdapter, propertyId, roomId) {
|
||||
if (activity != null && nfcAdapter != null) {
|
||||
val flags = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||
@@ -71,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
|
||||
@@ -143,4 +154,27 @@ fun IssueTemporaryCardScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showSuccessDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showSuccessDialog.value = false
|
||||
onBack()
|
||||
},
|
||||
title = { Text("Card issued") },
|
||||
text = {
|
||||
Text("Temporary card issued successfully.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showSuccessDialog.value = false
|
||||
onBack()
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
@@ -23,6 +24,10 @@ class IssueTemporaryCardViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(IssueTemporaryCardState())
|
||||
val state: StateFlow<IssueTemporaryCardState> = _state
|
||||
|
||||
fun reset() {
|
||||
_state.value = IssueTemporaryCardState()
|
||||
}
|
||||
|
||||
fun onTagScanned(propertyId: String, roomId: String, tag: Tag) {
|
||||
if (propertyId.isBlank() || roomId.isBlank()) return
|
||||
if (state.value.isProcessing) return
|
||||
@@ -31,6 +36,41 @@ class IssueTemporaryCardViewModel : ViewModel() {
|
||||
_state.update { it.copy(isProcessing = true, error = null, status = "Preparing card...") }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val existingCardIndex = readCardIndex(tag)
|
||||
if (existingCardIndex != null) {
|
||||
val cardResponse = api.getCardByIndex(propertyId, existingCardIndex.toString())
|
||||
val cardBody = cardResponse.body()
|
||||
if (cardResponse.isSuccessful && cardBody != null) {
|
||||
val isTemp = cardBody.roomStayId.isNullOrBlank()
|
||||
val active = isTemp && isCardActive(cardBody.expiresAt, cardBody.revokedAt)
|
||||
if (active) {
|
||||
_state.update { it.copy(status = "Revoking old card...") }
|
||||
val revokeResponse = api.revokeCard(propertyId, existingCardIndex.toString())
|
||||
val revokeBody = revokeResponse.body()
|
||||
if (!revokeResponse.isSuccessful || revokeBody?.timeData.isNullOrBlank()) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isProcessing = false,
|
||||
error = "Revoke failed: ${revokeResponse.code()}",
|
||||
status = null
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val revokeWrite = writeRevokeTimeData(tag, revokeBody!!.timeData!!)
|
||||
if (revokeWrite != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isProcessing = false,
|
||||
error = revokeWrite,
|
||||
status = null
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("IssueTempCard", "Calling prepare-temp")
|
||||
val prepareResponse = api.prepareTemporaryRoomCard(
|
||||
propertyId = propertyId,
|
||||
@@ -196,6 +236,29 @@ class IssueTemporaryCardViewModel : ViewModel() {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun writeRevokeTimeData(tag: Tag, timeDataHex: String): String? {
|
||||
val mifare = MifareClassic.get(tag) ?: return "Unsupported card type (not Mifare Classic)."
|
||||
return try {
|
||||
mifare.connect()
|
||||
val sectorIndex = 0
|
||||
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
if (!authA && !authB) return "Card authentication failed (sector 0)."
|
||||
val timeBytes = hexToBytes(timeDataHex)
|
||||
if (timeBytes.size != 16) return "Invalid time data size."
|
||||
val blockIndex = mifare.sectorToBlock(sectorIndex)
|
||||
mifare.writeBlock(blockIndex + 2, timeBytes)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e.localizedMessage ?: "Write failed."
|
||||
} finally {
|
||||
try {
|
||||
mifare.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeSector3Blocks(
|
||||
mifare: MifareClassic,
|
||||
block0: String?,
|
||||
@@ -230,6 +293,39 @@ class IssueTemporaryCardViewModel : ViewModel() {
|
||||
return tagId.joinToString("") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
|
||||
private fun readCardIndex(tag: Tag): Int? {
|
||||
val mifare = MifareClassic.get(tag) ?: return null
|
||||
return try {
|
||||
mifare.connect()
|
||||
val sectorIndex = 0
|
||||
val authA = mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT)
|
||||
if (!authA && !authB) return null
|
||||
val blockIndex = mifare.sectorToBlock(sectorIndex) + 1
|
||||
val data = mifare.readBlock(blockIndex)
|
||||
val dataString = data.joinToString("") { String.format("%02X", it) }
|
||||
if (dataString.length < 10) return null
|
||||
dataString.substring(4, 10).toIntOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
mifare.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCardActive(expiresAt: String?, revokedAt: String?): Boolean {
|
||||
if (revokedAt != null) return false
|
||||
if (expiresAt.isNullOrBlank()) return false
|
||||
return try {
|
||||
Instant.parse(expiresAt).isAfter(Instant.now())
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val clean = hex.trim().replace(" ", "")
|
||||
val len = clean.length
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CityAutocompleteField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
suggestions: List<String>,
|
||||
isLoading: Boolean,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
val query = value.trim()
|
||||
val canShowMenu = expanded.value && (isLoading || suggestions.isNotEmpty() || query.length >= 2)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = canShowMenu,
|
||||
onExpandedChange = { expanded.value = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { input ->
|
||||
onValueChange(input)
|
||||
expanded.value = input.trim().length >= 2
|
||||
},
|
||||
label = { Text(label) },
|
||||
supportingText = {
|
||||
if (query.length < 2) {
|
||||
Text("Type at least 2 letters")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = expanded.value && (isLoading || suggestions.isNotEmpty())
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = canShowMenu,
|
||||
onDismissRequest = { expanded.value = false }
|
||||
) {
|
||||
if (isLoading) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Searching...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else if (query.length >= 2 && suggestions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No cities found") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
suggestions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
expanded.value = false
|
||||
onSuggestionSelected(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
|
||||
import com.android.trisolarispms.ui.booking.phoneCountryOptions
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PhoneNumberCountryField(
|
||||
phoneCountryCode: String,
|
||||
onPhoneCountryCodeChange: (String) -> Unit,
|
||||
phoneNationalNumber: String,
|
||||
onPhoneNationalNumberChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
countryLabel: String = "Country",
|
||||
numberLabel: String = "Number",
|
||||
countryWeight: Float = 0.35f,
|
||||
numberWeight: Float = 0.65f
|
||||
) {
|
||||
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
|
||||
val phoneCountrySearch = remember { mutableStateOf("") }
|
||||
val phoneCountries = remember { phoneCountryOptions() }
|
||||
val selectedCountry = findPhoneCountryOption(phoneCountryCode)
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
|
||||
modifier = Modifier.weight(countryWeight)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(countryLabel) },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onDismissRequest = { phoneCountryMenuExpanded.value = false }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = phoneCountrySearch.value,
|
||||
onValueChange = { phoneCountrySearch.value = it },
|
||||
label = { Text("Search") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val filtered = phoneCountries.filter { option ->
|
||||
val query = phoneCountrySearch.value.trim()
|
||||
if (query.isBlank()) {
|
||||
true
|
||||
} else {
|
||||
option.name.contains(query, ignoreCase = true) ||
|
||||
option.code.contains(query, ignoreCase = true) ||
|
||||
option.dialCode.contains(query)
|
||||
}
|
||||
}
|
||||
filtered.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.name} (+${option.dialCode})") },
|
||||
onClick = {
|
||||
phoneCountryMenuExpanded.value = false
|
||||
phoneCountrySearch.value = ""
|
||||
onPhoneCountryCodeChange(option.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = phoneNationalNumber,
|
||||
onValueChange = onPhoneNationalNumberChange,
|
||||
label = { Text(numberLabel) },
|
||||
prefix = { Text("+${selectedCountry.dialCode}") },
|
||||
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.weight(numberWeight),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.android.trisolarispms.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BackTopBarScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean = true,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomBar: @Composable (() -> Unit)? = null,
|
||||
floatingActionButton: @Composable (() -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
if (showBack) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = actions,
|
||||
colors = TopAppBarDefaults.topAppBarColors()
|
||||
)
|
||||
},
|
||||
bottomBar = { bottomBar?.invoke() },
|
||||
floatingActionButton = { floatingActionButton?.invoke() },
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SaveTopBarScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
saveEnabled: Boolean = true,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomBar: @Composable (() -> Unit)? = null,
|
||||
floatingActionButton: @Composable (() -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
BackTopBarScaffold(
|
||||
title = title,
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
IconButton(onClick = onSave, enabled = saveEnabled) {
|
||||
Icon(Icons.Default.Done, contentDescription = "Save")
|
||||
}
|
||||
actions()
|
||||
},
|
||||
bottomBar = bottomBar,
|
||||
floatingActionButton = floatingActionButton,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaddedScreenColumn(
|
||||
padding: PaddingValues,
|
||||
contentPadding: Dp = 24.dp,
|
||||
scrollable: Boolean = false,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val baseModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(contentPadding)
|
||||
val scrollModifier = if (scrollable) {
|
||||
baseModifier.verticalScroll(rememberScrollState())
|
||||
} else {
|
||||
baseModifier
|
||||
}
|
||||
Column(
|
||||
modifier = scrollModifier,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingAndError(
|
||||
isLoading: Boolean,
|
||||
error: String?
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
error?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.trisolarispms.core.booking.BookingProfileOptions
|
||||
import com.android.trisolarispms.ui.common.CityAutocompleteField
|
||||
import com.android.trisolarispms.ui.common.PhoneNumberCountryField
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GuestInfoFormFields(
|
||||
phoneCountryCode: String,
|
||||
onPhoneCountryCodeChange: (String) -> Unit,
|
||||
phoneNationalNumber: String,
|
||||
onPhoneNationalNumberChange: (String) -> Unit,
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
nationality: String,
|
||||
onNationalityChange: (String) -> Unit,
|
||||
nationalitySuggestions: List<String>,
|
||||
isNationalitySearchLoading: Boolean,
|
||||
onNationalitySuggestionSelected: (String) -> Unit,
|
||||
age: String,
|
||||
onAgeChange: (String) -> Unit,
|
||||
addressText: String,
|
||||
onAddressChange: (String) -> Unit,
|
||||
fromCity: String,
|
||||
onFromCityChange: (String) -> Unit,
|
||||
fromCitySuggestions: List<String>,
|
||||
isFromCitySearchLoading: Boolean,
|
||||
onFromCitySuggestionSelected: (String) -> Unit,
|
||||
toCity: String,
|
||||
onToCityChange: (String) -> Unit,
|
||||
toCitySuggestions: List<String>,
|
||||
isToCitySearchLoading: Boolean,
|
||||
onToCitySuggestionSelected: (String) -> Unit,
|
||||
memberRelation: String,
|
||||
onMemberRelationChange: (String) -> Unit,
|
||||
transportMode: String,
|
||||
onTransportModeChange: (String) -> Unit,
|
||||
childCount: String,
|
||||
onChildCountChange: (String) -> Unit,
|
||||
maleCount: String,
|
||||
onMaleCountChange: (String) -> Unit,
|
||||
femaleCount: String,
|
||||
onFemaleCountChange: (String) -> Unit,
|
||||
vehicleNumbers: List<String>
|
||||
) {
|
||||
val showDobPicker = remember { mutableStateOf(false) }
|
||||
val nationalityMenuExpanded = remember { mutableStateOf(false) }
|
||||
val relationMenuExpanded = remember { mutableStateOf(false) }
|
||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||
val dobFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
val transportOptions = remember(vehicleNumbers) {
|
||||
if (vehicleNumbers.isNotEmpty()) {
|
||||
listOf("", "CAR", "BIKE")
|
||||
} else {
|
||||
BookingProfileOptions.transportModes
|
||||
}
|
||||
}
|
||||
var dobFieldValue by remember {
|
||||
mutableStateOf(TextFieldValue(text = age, selection = TextRange(age.length)))
|
||||
}
|
||||
|
||||
LaunchedEffect(age) {
|
||||
if (age != dobFieldValue.text) {
|
||||
dobFieldValue = TextFieldValue(text = age, selection = TextRange(age.length))
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
PhoneNumberCountryField(
|
||||
phoneCountryCode = phoneCountryCode,
|
||||
onPhoneCountryCodeChange = onPhoneCountryCodeChange,
|
||||
phoneNationalNumber = phoneNationalNumber,
|
||||
onPhoneNationalNumberChange = onPhoneNationalNumberChange,
|
||||
numberLabel = "Phone (optional)"
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
|
||||
onExpandedChange = { nationalityMenuExpanded.value = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = nationality,
|
||||
onValueChange = { value ->
|
||||
onNationalityChange(value)
|
||||
nationalityMenuExpanded.value = value.trim().length >= 3
|
||||
},
|
||||
label = { Text("Nationality (optional)") },
|
||||
supportingText = {
|
||||
if (nationality.trim().length < 3) {
|
||||
Text("Type at least 3 letters")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty())
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryEditable,
|
||||
enabled = true
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = nationalityMenuExpanded.value &&
|
||||
(isNationalitySearchLoading || nationalitySuggestions.isNotEmpty() || nationality.trim().length >= 3),
|
||||
onDismissRequest = { nationalityMenuExpanded.value = false }
|
||||
) {
|
||||
if (isNationalitySearchLoading) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Searching...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else if (nationality.trim().length >= 3 && nationalitySuggestions.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No countries found") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
} else {
|
||||
nationalitySuggestions.forEach { suggestion ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(suggestion)
|
||||
},
|
||||
onClick = {
|
||||
nationalityMenuExpanded.value = false
|
||||
onNationalitySuggestionSelected(suggestion)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = dobFieldValue,
|
||||
onValueChange = { input ->
|
||||
val formatted = formatDobInput(input.text)
|
||||
dobFieldValue = TextFieldValue(
|
||||
text = formatted,
|
||||
selection = TextRange(formatted.length)
|
||||
)
|
||||
if (formatted != age) onAgeChange(formatted)
|
||||
},
|
||||
label = { Text("DOB (dd/MM/yyyy)") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CalendarMonth,
|
||||
contentDescription = "Pick DOB",
|
||||
modifier = Modifier.clickable { showDobPicker.value = true }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = addressText,
|
||||
onValueChange = onAddressChange,
|
||||
label = { Text("Address (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
CityAutocompleteField(
|
||||
value = fromCity,
|
||||
onValueChange = onFromCityChange,
|
||||
label = "From City (optional)",
|
||||
suggestions = fromCitySuggestions,
|
||||
isLoading = isFromCitySearchLoading,
|
||||
onSuggestionSelected = onFromCitySuggestionSelected
|
||||
)
|
||||
CityAutocompleteField(
|
||||
value = toCity,
|
||||
onValueChange = onToCityChange,
|
||||
label = "To City (optional)",
|
||||
suggestions = toCitySuggestions,
|
||||
isLoading = isToCitySearchLoading,
|
||||
onSuggestionSelected = onToCitySuggestionSelected
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = memberRelation.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Member Relation") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = relationMenuExpanded.value,
|
||||
onDismissRequest = { relationMenuExpanded.value = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Not set") },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
onMemberRelationChange("")
|
||||
}
|
||||
)
|
||||
BookingProfileOptions.memberRelations.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
relationMenuExpanded.value = false
|
||||
onMemberRelationChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = transportMode.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transport Mode") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = transportMenuExpanded.value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(
|
||||
type = ExposedDropdownMenuAnchorType.PrimaryNotEditable,
|
||||
enabled = true
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = transportMenuExpanded.value,
|
||||
onDismissRequest = { transportMenuExpanded.value = false }
|
||||
) {
|
||||
transportOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.ifBlank { "Not set" }) },
|
||||
onClick = {
|
||||
transportMenuExpanded.value = false
|
||||
onTransportModeChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = childCount,
|
||||
onValueChange = onChildCountChange,
|
||||
label = { Text("Child Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = maleCount,
|
||||
onValueChange = onMaleCountChange,
|
||||
label = { Text("Male Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = femaleCount,
|
||||
onValueChange = onFemaleCountChange,
|
||||
label = { Text("Female Count (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDobPicker.value) {
|
||||
val initialDate = age.toLocalDateOrNull(dobFormatter) ?: LocalDate.now().minusYears(18)
|
||||
GuestDobDatePickerDialog(
|
||||
initialDate = initialDate,
|
||||
onDismiss = { showDobPicker.value = false },
|
||||
onDateSelected = { selectedDate ->
|
||||
onAgeChange(selectedDate.format(dobFormatter))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuestDobDatePickerDialog(
|
||||
initialDate: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onDateSelected: (LocalDate) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dismissState by rememberUpdatedState(onDismiss)
|
||||
val selectState by rememberUpdatedState(onDateSelected)
|
||||
|
||||
DisposableEffect(context, initialDate) {
|
||||
val dialog = DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, dayOfMonth ->
|
||||
selectState(LocalDate.of(year, month + 1, dayOfMonth))
|
||||
},
|
||||
initialDate.year,
|
||||
initialDate.monthValue - 1,
|
||||
initialDate.dayOfMonth
|
||||
)
|
||||
val todayMillis = LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
val minDateMillis = LocalDate.of(1900, 1, 1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
dialog.datePicker.maxDate = todayMillis
|
||||
dialog.datePicker.minDate = minDateMillis
|
||||
dialog.setOnDismissListener { dismissState() }
|
||||
dialog.show()
|
||||
onDispose {
|
||||
dialog.setOnDismissListener(null)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLocalDateOrNull(formatter: DateTimeFormatter): LocalDate? =
|
||||
runCatching { LocalDate.parse(this, formatter) }.getOrNull()
|
||||
|
||||
private fun formatDobInput(raw: String): String {
|
||||
val digits = raw.filter { it.isDigit() }.take(8)
|
||||
if (digits.isEmpty()) return ""
|
||||
val builder = StringBuilder(digits.length + 2)
|
||||
digits.forEachIndexed { index, char ->
|
||||
builder.append(char)
|
||||
if ((index == 1 || index == 3) && index != digits.lastIndex) {
|
||||
builder.append('/')
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.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
|
||||
fun GuestInfoScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
guestId: String,
|
||||
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
|
||||
initialPhone: String?,
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
viewModel: GuestInfoViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(propertyId, bookingId, guestId) {
|
||||
viewModel.reset()
|
||||
viewModel.setInitial(initialGuest, initialPhone)
|
||||
viewModel.loadGuest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fallbackPhone = initialPhone
|
||||
)
|
||||
}
|
||||
|
||||
SaveTopBarScaffold(
|
||||
title = "Guest Info",
|
||||
onBack = onBack,
|
||||
onSave = { viewModel.submit(propertyId, bookingId, guestId, onSave) }
|
||||
) { padding ->
|
||||
PaddedScreenColumn(
|
||||
padding = padding,
|
||||
scrollable = true
|
||||
) {
|
||||
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))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
state.error?.let {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
data class GuestInfoState(
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,621 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.core.viewmodel.CitySearchController
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.core.GeoSearchRepository
|
||||
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
|
||||
import com.android.trisolarispms.data.api.model.GuestDto
|
||||
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
|
||||
import com.android.trisolarispms.data.local.booking.BookingDetailsRepository
|
||||
import com.android.trisolarispms.data.local.core.LocalDatabaseProvider
|
||||
import com.android.trisolarispms.data.local.roomstay.ActiveRoomStayRepository
|
||||
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
|
||||
import com.google.gson.JsonNull
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GuestInfoViewModel(
|
||||
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 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) {
|
||||
_state.update { it.copy(name = value, error = null) }
|
||||
}
|
||||
|
||||
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 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) }
|
||||
}
|
||||
|
||||
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()
|
||||
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()}"
|
||||
}
|
||||
}
|
||||
|
||||
if (bookingId.isNotBlank()) {
|
||||
val detailsResponse = api.getBookingDetails(propertyId = propertyId, bookingId = bookingId)
|
||||
val details = detailsResponse.body()
|
||||
if (detailsResponse.isSuccessful && details != null) {
|
||||
val snapshot = BookingProfileSnapshot(
|
||||
transportMode = details.transportMode?.trim()?.ifBlank { null },
|
||||
childCount = details.childCount,
|
||||
maleCount = details.maleCount,
|
||||
femaleCount = details.femaleCount,
|
||||
fromCity = details.fromCity?.trim()?.ifBlank { null },
|
||||
toCity = details.toCity?.trim()?.ifBlank { null },
|
||||
memberRelation = details.memberRelation?.trim()?.ifBlank { null }
|
||||
)
|
||||
initialBookingProfile = snapshot
|
||||
_state.update {
|
||||
it.copy(
|
||||
fromCity = snapshot.fromCity.orEmpty(),
|
||||
fromCitySuggestions = emptyList(),
|
||||
isFromCitySearchLoading = false,
|
||||
toCity = snapshot.toCity.orEmpty(),
|
||||
toCitySuggestions = emptyList(),
|
||||
isToCitySearchLoading = false,
|
||||
memberRelation = snapshot.memberRelation.orEmpty(),
|
||||
transportMode = snapshot.transportMode.orEmpty(),
|
||||
childCount = snapshot.childCount?.toString().orEmpty(),
|
||||
maleCount = snapshot.maleCount?.toString().orEmpty(),
|
||||
femaleCount = snapshot.femaleCount?.toString().orEmpty()
|
||||
)
|
||||
}
|
||||
} else if (loadError == null) {
|
||||
loadError = "Load failed: ${detailsResponse.code()}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
loadError = e.localizedMessage ?: "Load failed"
|
||||
}
|
||||
val parsedPhone = parsePhoneE164(fallbackPhone)
|
||||
_state.update {
|
||||
it.copy(
|
||||
phoneCountryCode = it.phoneCountryCode.ifBlank { parsedPhone.countryCode },
|
||||
phoneNationalNumber = it.phoneNationalNumber.ifBlank { parsedPhone.nationalNumber },
|
||||
isNationalitySearchLoading = false,
|
||||
isLoading = false,
|
||||
error = loadError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submit(propertyId: String, bookingId: String, guestId: String, onDone: () -> Unit) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank() || guestId.isBlank()) return
|
||||
val current = state.value
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
var submitError: String? = null
|
||||
val fullPhoneE164 = composePhoneE164(current)
|
||||
var matchedGuestToLinkId: String? = null
|
||||
if (!fullPhoneE164.isNullOrBlank() && submitError == null) {
|
||||
val searchResponse = api.searchGuests(
|
||||
propertyId = propertyId,
|
||||
phone = fullPhoneE164
|
||||
)
|
||||
if (searchResponse.isSuccessful) {
|
||||
matchedGuestToLinkId = searchResponse.body()
|
||||
.orEmpty()
|
||||
.firstOrNull { it.id != guestId }
|
||||
?.id
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedGuestToLinkId.isNullOrBlank() && submitError == null) {
|
||||
val linkResponse = api.linkGuest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingLinkGuestRequest(guestId = matchedGuestToLinkId)
|
||||
)
|
||||
if (!linkResponse.isSuccessful) {
|
||||
submitError = "Link failed: ${linkResponse.code()}"
|
||||
}
|
||||
} else if (submitError == null) {
|
||||
val countryOption = findPhoneCountryOption(current.phoneCountryCode)
|
||||
val nationalNumber = current.phoneNationalNumber.trim()
|
||||
val phoneE164 = if (nationalNumber.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
"+${countryOption.dialCode}$nationalNumber"
|
||||
}
|
||||
val response = api.updateGuest(
|
||||
propertyId = propertyId,
|
||||
guestId = guestId,
|
||||
body = GuestUpdateRequest(
|
||||
phoneE164 = 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
|
||||
)
|
||||
_state.update { it.copy(isLoading = false, error = null) }
|
||||
onDone()
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = submitError) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_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 }
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user