Compare commits

...

64 Commits

Author SHA1 Message Date
androidlover5842
f69a01a460 ai added more room db stuff 2026-02-08 19:54:35 +05:30
androidlover5842
1000f2411c add room db 2026-02-08 19:21:07 +05:30
androidlover5842
e9c3b4f669 booking: ability to edit more guest info 2026-02-07 22:33:43 +05:30
androidlover5842
90c2b6fb9f roomStays: show rates 2026-02-05 13:05:43 +05:30
androidlover5842
1e5f412f82 ablity to checkout stay 2026-02-05 10:31:15 +05:30
androidlover5842
a67eacd77f createbooking: improve logic for future bookings 2026-02-04 17:55:35 +05:30
androidlover5842
f9b09e2376 ability to cancel future bookings 2026-02-04 16:45:15 +05:30
androidlover5842
e1250a0f32 stay: improve checkin and checkout time editor 2026-02-04 16:28:50 +05:30
androidlover5842
d69ed60a6e agents -_- 2026-02-04 15:32:44 +05:30
androidlover5842
56f13f5e79 ability to see open bookings list 2026-02-04 15:20:17 +05:30
androidlover5842
9555ae2e40 activeScreen:improve menu 2026-02-04 15:07:27 +05:30
androidlover5842
9d942d6411 createBooking: change checkout date based on property policy while editing checking date 2026-02-04 14:58:16 +05:30
androidlover5842
3a90aa848d billing: add billable nights view and improve payment ledger cash handle logic 2026-02-04 14:12:19 +05:30
androidlover5842
eab5517f9b extend checkout time improve ui 2026-02-04 13:41:06 +05:30
androidlover5842
b0c28d0aa4 add missing screen 2026-02-03 10:21:07 +05:30
androidlover5842
dcaaba92dd AI:remove more boilerplate 2026-02-03 10:20:48 +05:30
androidlover5842
52a6d379b0 AI:remove more boilerplate 2026-02-03 09:55:40 +05:30
androidlover5842
4e5f368256 AI:remove more boilerplate 2026-02-03 09:38:30 +05:30
androidlover5842
d6c8e522de AI:remove boilerplate 2026-02-03 09:28:23 +05:30
androidlover5842
18c5cb814d policy:time related 2026-02-02 11:35:30 +05:30
androidlover5842
a691e84fd8 ai removed boilerplate and orgnized code even more 2026-02-02 07:00:56 +05:30
androidlover5842
d54a9af5ee improve auth screen login 2026-02-02 06:29:49 +05:30
androidlover5842
8c790fbce0 improve auth policy 2026-02-02 06:21:18 +05:30
androidlover5842
f97834291d manage packages 2026-02-02 06:10:34 +05:30
androidlover5842
0f0db0dcf5 mainscreen: split codes 2026-02-02 06:02:38 +05:30
androidlover5842
99ce18a435 remove auth boilerplate 2026-02-02 05:39:20 +05:30
androidlover5842
342ff6a237 roles: fix permissions 2026-02-02 05:27:06 +05:30
androidlover5842
86307a66c8 users and permission manage 2026-02-01 23:33:15 +05:30
androidlover5842
3219e40a02 payments: ability to refund 2026-02-01 16:16:44 +05:30
androidlover5842
4c31a20af4 razorpay: add payment link support 2026-02-01 14:37:05 +05:30
androidlover5842
8f62459d5e fix razorpay qr credit log 2026-02-01 14:03:19 +05:30
androidlover5842
43ee7311e8 remove that crap payu 2026-02-01 13:27:55 +05:30
androidlover5842
2b0f352ced guestInfo: improve ui 2026-02-01 02:07:52 +05:30
androidlover5842
53300a6a84 guest docs : sse 2026-01-31 00:59:00 +05:30
androidlover5842
9ac0b55b89 show guest docs images 2026-01-31 00:16:12 +05:30
androidlover5842
4fc080f146 admins can delete cash payments 2026-01-30 11:40:49 +05:30
androidlover5842
c5e0648dd1 add payment ledger impl and payu impl 2026-01-30 10:17:17 +05:30
androidlover5842
2d75b88892 guest details: improve room stays ui 2026-01-29 23:19:54 +05:30
androidlover5842
be820391bc booking details: add ability to see details of guest 2026-01-29 22:03:06 +05:30
androidlover5842
9d3ade3d03 active stays: ability to extent checkout time 2026-01-29 20:39:15 +05:30
androidlover5842
f593306c50 improve checked in guest ui 2026-01-29 20:14:14 +05:30
androidlover5842
dffa8b14cd ability to see stays from booking id on click 2026-01-29 14:07:01 +05:30
androidlover5842
5f522ca3ab booking create: manage booking rates flow 2026-01-29 12:09:43 +05:30
androidlover5842
29065cee22 booking: ability to take signature 2026-01-29 10:36:49 +05:30
androidlover5842
799c0b44b9 create booking : improve form and ui further 2026-01-29 09:39:17 +05:30
androidlover5842
869e59aaac improve booking section 2026-01-29 09:18:35 +05:30
androidlover5842
3a7667c609 bookingCreate: improve input phone number ui 2026-01-29 09:01:29 +05:30
androidlover5842
8bd2c2eeae add basic booking flow 2026-01-29 08:48:04 +05:30
androidlover5842
726f07bff4 Add rate plan calendar screen 2026-01-29 06:13:05 +05:30
androidlover5842
3fe0730f4c Improve temp card flow and amenity list 2026-01-28 20:49:10 +05:30
androidlover5842
1068e05c4a Handle system back navigation 2026-01-28 18:36:58 +05:30
androidlover5842
65a41863e2 Add card info screen and revoke flow 2026-01-28 18:33:48 +05:30
androidlover5842
be52b58165 Add NFC temporary card issue flow 2026-01-28 07:28:19 +05:30
androidlover5842
6d7edc3022 Room lists filters and room type availability 2026-01-28 05:42:23 +05:30
androidlover5842
d2d60b5074 Auto-refresh auth token on 401 2026-01-28 04:50:20 +05:30
androidlover5842
d8a40e4c9a Add amenity picker and room type active flag 2026-01-28 00:52:30 +05:30
androidlover5842
dbbcb6c4a6 Unify image grids and room type image ordering 2026-01-28 00:41:04 +05:30
androidlover5842
55f139f4f2 Update amenity form and icon selection 2026-01-27 23:20:50 +05:30
androidlover5842
053b7c2544 Add room image management UI 2026-01-27 18:14:42 +05:30
androidlover5842
6e87eb76a1 Update room image models and upload params 2026-01-27 16:24:07 +05:30
androidlover5842
30fcc43f42 Add delete actions for rooms and room types 2026-01-27 05:04:27 +05:30
androidlover5842
4642102ff5 Update amenities API and category suggestions 2026-01-27 04:55:28 +05:30
androidlover5842
94eb4f9be4 Fix amenities back navigation 2026-01-27 04:29:05 +05:30
androidlover5842
2c296a2cb3 Move amenities management to home menu 2026-01-27 04:27:11 +05:30
207 changed files with 19604 additions and 1611 deletions

602
AGENTS.md Normal file
View 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`.

View File

@@ -1,18 +1,21 @@
import com.android.build.api.dsl.ApplicationExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
id("com.google.gms.google-services") id("com.google.gms.google-services")
} }
android { extensions.configure<ApplicationExtension>("android") {
namespace = "com.android.trisolarispms" namespace = "com.android.trisolarispms"
compileSdk { compileSdk = 36
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "com.android.trisolarispms" applicationId = "com.android.trisolarispms"
minSdk = 23 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -29,12 +32,21 @@ android {
) )
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
} }
} }
@@ -55,6 +67,16 @@ dependencies {
implementation(libs.retrofit.converter.gson) implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.okhttp.sse)
implementation(libs.coil.compose)
implementation(libs.coil.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(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx) implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services) implementation(libs.kotlinx.coroutines.play.services)

View File

@@ -3,6 +3,12 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"

File diff suppressed because one or more lines are too long

View File

@@ -6,25 +6,12 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.AppRoute
import com.android.trisolarispms.ui.auth.AuthScreen import com.android.trisolarispms.ui.auth.AuthScreen
import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.auth.NameScreen import com.android.trisolarispms.ui.auth.NameScreen
import com.android.trisolarispms.ui.auth.UnauthorizedScreen import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.navigation.MainRouteContent
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.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.theme.TrisolarisPMSTheme import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -36,140 +23,17 @@ class MainActivity : ComponentActivity() {
val authViewModel: AuthViewModel = viewModel() val authViewModel: AuthViewModel = viewModel()
val state by authViewModel.state.collectAsState() val state by authViewModel.state.collectAsState()
if (state.unauthorized) { when {
UnauthorizedScreen( state.unauthorized -> UnauthorizedScreen(
message = state.error ?: "Not authorized. Contact admin.", message = state.error ?: "Not authorized. Contact admin.",
onSignOut = authViewModel::signOut onSignOut = authViewModel::signOut
) )
} else if (state.apiVerified && state.needsName) { state.apiVerified && state.needsName -> NameScreen(viewModel = authViewModel)
NameScreen(viewModel = authViewModel) state.apiVerified -> MainRouteContent(
} else if (state.apiVerified) { state = state,
val route = remember { mutableStateOf<AppRoute>(AppRoute.Home) } authViewModel = authViewModel
val refreshKey = remember { mutableStateOf(0) } )
val selectedPropertyId = remember { mutableStateOf<String?>(null) } else -> AuthScreen(viewModel = authViewModel)
val selectedPropertyName = remember { mutableStateOf<String?>(null) }
val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(null) }
val selectedAmenity = remember { mutableStateOf<com.android.trisolarispms.data.api.model.AmenityDto?>(null) }
val roomFormKey = remember { mutableStateOf(0) }
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 },
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 ?: "")
}
)
is AppRoute.RoomTypes -> RoomTypesScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddRoomType(currentRoute.propertyId) },
onAmenities = { route.value = AppRoute.Amenities(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) }
)
is AppRoute.Amenities -> AmenitiesScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddAmenity(currentRoute.propertyId) },
onEdit = {
selectedAmenity.value = it
route.value = AppRoute.EditAmenity(currentRoute.propertyId, it.id ?: "")
}
)
is AppRoute.AddAmenity -> AddAmenityScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Amenities(currentRoute.propertyId) }
)
is AppRoute.EditAmenity -> EditAmenityScreen(
propertyId = currentRoute.propertyId,
amenity = selectedAmenity.value
?: com.android.trisolarispms.data.api.model.AmenityDto(id = currentRoute.amenityId, name = ""),
onBack = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Amenities(currentRoute.propertyId) }
)
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) }
)
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) }
)
}
} else {
AuthScreen(viewModel = authViewModel)
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
package com.android.trisolarispms.data.api
interface AuthTokenProvider {
suspend fun token(): String?
}

View File

@@ -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>
}

View File

@@ -1,38 +0,0 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.ImageDto
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface RoomImageApi {
@GET("properties/{propertyId}/rooms/{roomId}/images")
suspend fun listRoomImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String
): Response<List<ImageDto>>
@Multipart
@POST("properties/{propertyId}/rooms/{roomId}/images")
suspend fun uploadRoomImage(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Part file: MultipartBody.Part
): Response<ImageDto>
@Streaming
@GET("properties/{propertyId}/rooms/{roomId}/images/{imageId}/file")
suspend fun getRoomImageFile(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Path("imageId") imageId: String,
@Query("size") size: String? = null
): Response<ResponseBody>
}

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.core
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import okhttp3.Authenticator
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -9,16 +10,16 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
fun create( fun createOkHttpClient(
auth: FirebaseAuth = FirebaseAuth.getInstance(), auth: FirebaseAuth = FirebaseAuth.getInstance(),
baseUrl: String = ApiConstants.BASE_URL, enableLogging: Boolean = true,
enableLogging: Boolean = true readTimeoutSeconds: Long = 30
): ApiService { ): OkHttpClient {
val tokenProvider = FirebaseAuthTokenProvider(auth) val tokenProvider = FirebaseAuthTokenProvider(auth)
val authInterceptor = Interceptor { chain -> val authInterceptor = Interceptor { chain ->
val original = chain.request() val original = chain.request()
val token = try { val token = try {
kotlinx.coroutines.runBlocking { tokenProvider.token() } kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -32,17 +33,44 @@ object ApiClient {
chain.proceed(request) chain.proceed(request)
} }
val authenticator = Authenticator { _, response ->
if (response.code != 401) return@Authenticator null
if (response.request.header("X-Auth-Retry") == "true") return@Authenticator null
val newToken = try {
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = true) }
} catch (e: Exception) {
null
}
if (newToken.isNullOrBlank()) return@Authenticator null
response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.header("X-Auth-Retry", "true")
.build()
}
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE level = if (enableLogging) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
} }
val client = OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.authenticator(authenticator)
.addInterceptor(logging) .addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.build() .build()
}
fun create(
auth: FirebaseAuth = FirebaseAuth.getInstance(),
baseUrl: String = ApiConstants.BASE_URL,
enableLogging: Boolean = true
): ApiService {
val client = createOkHttpClient(
auth = auth,
enableLogging = enableLogging
)
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.core
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
@@ -6,8 +6,8 @@ import kotlinx.coroutines.tasks.await
class FirebaseAuthTokenProvider( class FirebaseAuthTokenProvider(
private val auth: FirebaseAuth private val auth: FirebaseAuth
) : AuthTokenProvider { ) : AuthTokenProvider {
override suspend fun token(): String? { override suspend fun token(forceRefresh: Boolean): String? {
val user = auth.currentUser ?: return null val user = auth.currentUser ?: return null
return user.getIdToken(false).await().token return user.getIdToken(forceRefresh).await().token
} }
} }

View File

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

View File

@@ -2,13 +2,19 @@ package com.android.trisolarispms.data.api.model
data class AmenityDto( data class AmenityDto(
val id: String? = null, val id: String? = null,
val name: String? = null val name: String? = null,
val category: String? = null,
val iconKey: String? = null
) )
data class AmenityCreateRequest( data class AmenityCreateRequest(
val name: String val name: String,
val category: String? = null,
val iconKey: String? = null
) )
data class AmenityUpdateRequest( data class AmenityUpdateRequest(
val name: String val name: String,
val category: String? = null,
val iconKey: String? = null
) )

View File

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

View File

@@ -1,5 +1,20 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import java.util.Locale
enum class BookingBillingMode {
PROPERTY_POLICY,
CUSTOM_WINDOW,
FULL_24H;
companion object {
fun from(value: String?): BookingBillingMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class BookingCheckInRequest( data class BookingCheckInRequest(
val roomIds: List<String>, val roomIds: List<String>,
val checkInAt: String? = null, val checkInAt: String? = null,
@@ -8,11 +23,182 @@ data class BookingCheckInRequest(
val notes: String? = null 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( data class BookingCheckOutRequest(
val checkOutAt: String? = null, val checkOutAt: String? = null,
val notes: String? = null val notes: String? = null
) )
data class BookingRoomStayCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest( data class BookingCancelRequest(
val cancelledAt: String? = null, val cancelledAt: String? = null,
val reason: String? = null val reason: String? = null
@@ -23,39 +209,12 @@ data class BookingNoShowRequest(
val reason: String? = null val reason: String? = null
) )
data class BookingRoomStayCreateRequest( data class BookingBalanceResponse(
val roomId: String, val expectedPay: Long? = null,
val fromAt: String, val amountCollected: Long? = null,
val toAt: String, val pending: Long? = null
val notes: String? = null
) )
// Room Stays data class RoomStayVoidRequest(
val reason: String
data class RoomStayCreateRequest(
val roomId: String,
val guestId: String? = null,
val checkIn: String? = null,
val checkOut: String? = null
)
data class RoomStayDto(
val id: String? = null,
val bookingId: String? = null,
val roomId: String? = null,
val status: String? = null
)
data class RoomChangeRequest(
val newRoomId: String,
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: String? = null,
val newRoomStayId: String? = null,
val oldRoomId: String? = null,
val newRoomId: String? = null,
val movedAt: String? = null
) )

View File

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

View File

@@ -8,6 +8,9 @@ data class CardPrepareResponse(
val cardIndex: Int? = null, val cardIndex: Int? = null,
val key: String? = null, val key: String? = null,
val timeData: String? = null, val timeData: String? = null,
val sector3Block0: String? = null,
val sector3Block1: String? = null,
val sector3Block2: String? = null,
val issuedAt: String? = null, val issuedAt: String? = null,
val expiresAt: String? = null val expiresAt: String? = null
) )
@@ -16,7 +19,7 @@ data class IssueCardRequest(
val cardId: String, val cardId: String,
val cardIndex: Int, val cardIndex: Int,
val issuedAt: String? = null, val issuedAt: String? = null,
val expiresAt: String val expiresAt: String? = null
) )
data class IssuedCardResponse( data class IssuedCardResponse(
@@ -31,3 +34,7 @@ data class IssuedCardResponse(
val issuedByUserId: String? = null, val issuedByUserId: String? = null,
val revokedAt: String? = null val revokedAt: String? = null
) )
data class RevokeCardResponse(
val timeData: String? = null
)

View File

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

View File

@@ -1,15 +1,36 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class GuestDto( data class GuestDto(
val id: String? = null, val id: String? = null,
val name: String? = null, val name: String? = null,
val phoneE164: String? = null, val phoneE164: String? = null,
@SerializedName(value = "dob", alternate = ["age"])
val dob: String? = null,
val nationality: String? = null, val nationality: String? = null,
val addressText: String? = null, val addressText: String? = null,
val vehicleNumbers: List<String> = emptyList(), val vehicleNumbers: List<String> = emptyList(),
val averageScore: Double? = null 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( data class GuestVehicleRequest(
val vehicleNumber: String val vehicleNumber: String
) )
@@ -49,3 +70,8 @@ data class GuestDocumentDto(
val extractedData: Map<String, String>? = null, val extractedData: Map<String, String>? = null,
val extractedAt: String? = null val extractedAt: String? = null
) )
data class GuestVisitCountResponse(
val guestId: String? = null,
val bookingCount: Int? = null
)

View File

@@ -0,0 +1,39 @@
package com.android.trisolarispms.data.api.model
data class PaymentDto(
val id: String? = null,
val bookingId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val method: String? = null,
val gatewayPaymentId: String? = null,
val gatewayTxnId: String? = null,
val bankRefNum: String? = null,
val mode: String? = null,
val pgType: String? = null,
val payerVpa: String? = null,
val payerName: String? = null,
val paymentSource: String? = null,
val reference: String? = null,
val notes: String? = null,
val receivedAt: String? = null,
val receivedByUserId: String? = null
)
data class PaymentCreateRequest(
val amount: Long
)
data class RazorpayRefundRequest(
val paymentId: String? = null,
val razorpayPaymentId: String? = null,
val amount: Long,
val notes: String? = null
)
data class RazorpayRefundResponse(
val refundId: String? = null,
val status: String? = null,
val amount: Long? = null,
val currency: String? = null
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class RoomCreateRequest( data class RoomCreateRequest(
val roomNumber: Int, val roomNumber: Int,
val floor: Int? = null, val floor: Int? = null,
@@ -25,11 +27,14 @@ data class RoomDto(
val roomNumber: Int? = null, val roomNumber: Int? = null,
val roomTypeCode: String? = null, val roomTypeCode: String? = null,
val roomTypeName: String? = null, val roomTypeName: String? = null,
val maxOccupancy: Int? = null,
val floor: Int? = null, val floor: Int? = null,
val hasNfc: Boolean? = null, val hasNfc: Boolean? = null,
val active: Boolean? = null, val active: Boolean? = null,
val maintenance: 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( data class RoomBoardDto(
@@ -44,9 +49,14 @@ data class RoomAvailabilityResponse(
) )
data class RoomAvailabilityRangeResponse( data class RoomAvailabilityRangeResponse(
@SerializedName(value = "roomTypeCode", alternate = ["code"])
val roomTypeCode: String? = null,
val roomTypeName: String? = null, val roomTypeName: String? = null,
val freeRoomNumbers: List<Int> = emptyList(), 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 // Images
@@ -55,9 +65,26 @@ data class ImageDto(
val id: String? = null, val id: String? = null,
val propertyId: String? = null, val propertyId: String? = null,
val roomId: String? = null, val roomId: String? = null,
val roomTypeCode: String? = null,
val url: String? = null, val url: String? = null,
val thumbnailUrl: String? = null, val thumbnailUrl: String? = null,
val contentType: String? = null, val contentType: String? = null,
val sizeBytes: Long? = null, val sizeBytes: Long? = null,
val tags: List<RoomImageTagDto>? = null,
val roomSortOrder: Int? = null,
val roomTypeSortOrder: Int? = null,
val createdAt: String? = null val createdAt: String? = null
) )
data class RoomImageReorderRequest(
val imageIds: List<String>
)
data class RoomImageTagDto(
val id: String? = null,
val name: String? = null
)
data class RoomImageTagUpsertRequest(
val tagIds: List<String>
)

View File

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

View File

@@ -1,6 +1,7 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import com.android.trisolarispms.data.api.model.AmenityDto import com.android.trisolarispms.data.api.model.AmenityDto
import com.android.trisolarispms.data.api.model.ImageDto
data class RoomTypeCreateRequest( data class RoomTypeCreateRequest(
val code: String, val code: String,
@@ -16,6 +17,7 @@ data class RoomTypeCreateRequest(
data class RoomTypeUpdateRequest( data class RoomTypeUpdateRequest(
val code: String? = null, val code: String? = null,
val name: String? = null, val name: String? = null,
val active: Boolean? = null,
val baseOccupancy: Int? = null, val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null, val maxOccupancy: Int? = null,
val sqFeet: Int? = null, val sqFeet: Int? = null,
@@ -29,10 +31,12 @@ data class RoomTypeDto(
val propertyId: String? = null, val propertyId: String? = null,
val code: String? = null, val code: String? = null,
val name: String? = null, val name: String? = null,
val active: Boolean? = null,
val baseOccupancy: Int? = null, val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null, val maxOccupancy: Int? = null,
val sqFeet: Int? = null, val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null, val bathroomSqFeet: Int? = null,
val amenities: List<AmenityDto>? = null, val amenities: List<AmenityDto>? = null,
val images: List<ImageDto>? = null,
val otaAliases: List<String>? = null val otaAliases: List<String>? = null
) )

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.AmenityCreateRequest import com.android.trisolarispms.data.api.model.AmenityCreateRequest
import com.android.trisolarispms.data.api.model.AmenityDto import com.android.trisolarispms.data.api.model.AmenityDto
@@ -12,25 +12,25 @@ import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
interface AmenityApi { interface AmenityApi {
@GET("properties/{propertyId}/amenities") @GET("amenities")
suspend fun listAmenities(@Path("propertyId") propertyId: String): Response<List<AmenityDto>> suspend fun listAmenities(): Response<List<AmenityDto>>
@POST("properties/{propertyId}/amenities") @POST("amenities")
suspend fun createAmenity( suspend fun createAmenity(
@Path("propertyId") propertyId: String,
@Body body: AmenityCreateRequest @Body body: AmenityCreateRequest
): Response<AmenityDto> ): Response<AmenityDto>
@PUT("properties/{propertyId}/amenities/{amenityId}") @PUT("amenities/{amenityId}")
suspend fun updateAmenity( suspend fun updateAmenity(
@Path("propertyId") propertyId: String,
@Path("amenityId") amenityId: String, @Path("amenityId") amenityId: String,
@Body body: AmenityUpdateRequest @Body body: AmenityUpdateRequest
): Response<AmenityDto> ): Response<AmenityDto>
@DELETE("properties/{propertyId}/amenities/{amenityId}") @DELETE("amenities/{amenityId}")
suspend fun deleteAmenity( suspend fun deleteAmenity(
@Path("propertyId") propertyId: String,
@Path("amenityId") amenityId: String @Path("amenityId") amenityId: String
): Response<Unit> ): Response<Unit>
@GET("icons/png")
suspend fun listAmenityIconKeys(): Response<List<String>>
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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.CardPrepareRequest
import com.android.trisolarispms.data.api.model.CardPrepareResponse import com.android.trisolarispms.data.api.model.CardPrepareResponse
import com.android.trisolarispms.data.api.model.IssueCardRequest import com.android.trisolarispms.data.api.model.IssueCardRequest
import com.android.trisolarispms.data.api.model.IssuedCardResponse import com.android.trisolarispms.data.api.model.IssuedCardResponse
import com.android.trisolarispms.data.api.model.RevokeCardResponse
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -26,6 +26,26 @@ interface CardApi {
@Body body: IssueCardRequest @Body body: IssueCardRequest
): Response<IssuedCardResponse> ): Response<IssuedCardResponse>
@POST("properties/{propertyId}/rooms/{roomId}/cards/prepare-temp")
suspend fun prepareTemporaryRoomCard(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: CardPrepareRequest
): Response<CardPrepareResponse>
@POST("properties/{propertyId}/rooms/{roomId}/cards/temp")
suspend fun issueTemporaryRoomCard(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@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") @GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
suspend fun listCards( suspend fun listCards(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -36,5 +56,5 @@ interface CardApi {
suspend fun revokeCard( suspend fun revokeCard(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("cardId") cardId: String @Path("cardId") cardId: String
): Response<ActionResponse> ): Response<RevokeCardResponse>
} }

View File

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

View File

@@ -1,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.GuestDto
import com.android.trisolarispms.data.api.model.GuestCreateRequest
import com.android.trisolarispms.data.api.model.GuestRatingDto import com.android.trisolarispms.data.api.model.GuestRatingDto
import com.android.trisolarispms.data.api.model.GuestRatingRequest 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.GuestVehicleDto
import com.android.trisolarispms.data.api.model.GuestVehicleRequest import com.android.trisolarispms.data.api.model.GuestVehicleRequest
import okhttp3.MultipartBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
interface GuestApi { 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") @GET("properties/{propertyId}/guests/search")
suspend fun searchGuests( suspend fun searchGuests(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -20,6 +40,26 @@ interface GuestApi {
@Query("vehicleNumber") vehicleNumber: String? = null @Query("vehicleNumber") vehicleNumber: String? = null
): Response<List<GuestDto>> ): 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") @POST("properties/{propertyId}/guests/{guestId}/vehicles")
suspend fun addGuestVehicle( suspend fun addGuestVehicle(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

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

View File

@@ -0,0 +1,27 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RoomImageTagDto
import retrofit2.Response
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Body
interface ImageTagApi {
@GET("image-tags")
suspend fun listImageTags(): Response<List<RoomImageTagDto>>
@POST("image-tags")
suspend fun createImageTag(@Body body: RoomImageTagDto): Response<RoomImageTagDto>
@PUT("image-tags/{tagId}")
suspend fun updateImageTag(
@Path("tagId") tagId: String,
@Body body: RoomImageTagDto
): Response<RoomImageTagDto>
@DELETE("image-tags/{tagId}")
suspend fun deleteImageTag(@Path("tagId") tagId: String): Response<Unit>
}

View File

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

View File

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

View File

@@ -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>
}

View File

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

View File

@@ -1,4 +1,4 @@
package com.android.trisolarispms.data.api package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse
@@ -9,6 +9,7 @@ import com.android.trisolarispms.data.api.model.RoomUpdateRequest
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
@@ -33,6 +34,12 @@ interface RoomApi {
@Body body: RoomUpdateRequest @Body body: RoomUpdateRequest
): Response<RoomDto> ): Response<RoomDto>
@DELETE("properties/{propertyId}/rooms/{roomId}")
suspend fun deleteRoom(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String
): Response<Unit>
@GET("properties/{propertyId}/rooms/board") @GET("properties/{propertyId}/rooms/board")
suspend fun getRoomBoard(@Path("propertyId") propertyId: String): Response<RoomBoardDto> suspend fun getRoomBoard(@Path("propertyId") propertyId: String): Response<RoomBoardDto>
@@ -47,6 +54,19 @@ interface RoomApi {
suspend fun getRoomAvailabilityRange( suspend fun getRoomAvailabilityRange(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Query("from") from: String, @Query("from") from: String,
@Query("to") to: String @Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailabilityRangeResponse>> ): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available")
suspend fun listAvailableRooms(
@Path("propertyId") propertyId: String
): Response<List<RoomDto>>
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType(
@Path("propertyId") propertyId: String,
@Path("roomTypeCode") roomTypeCode: String,
@Query("availableOnly") availableOnly: Boolean? = null
): Response<List<RoomDto>>
} }

View File

@@ -0,0 +1,77 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.ImageDto
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import retrofit2.http.DELETE
interface RoomImageApi {
@GET("properties/{propertyId}/room-types/{roomTypeCode}/images")
suspend fun listRoomTypeImages(
@Path("propertyId") propertyId: String,
@Path("roomTypeCode") roomTypeCode: String
): Response<List<ImageDto>>
@GET("properties/{propertyId}/rooms/{roomId}/images")
suspend fun listRoomImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String
): Response<List<ImageDto>>
@Multipart
@POST("properties/{propertyId}/rooms/{roomId}/images")
suspend fun uploadRoomImage(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Part file: MultipartBody.Part,
@Query("tagIds") tagIds: List<String>? = null
): Response<ImageDto>
@Streaming
@GET("properties/{propertyId}/rooms/{roomId}/images/{imageId}/file")
suspend fun getRoomImageFile(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Path("imageId") imageId: String,
@Query("size") size: String? = null
): Response<ResponseBody>
@DELETE("properties/{propertyId}/rooms/{roomId}/images/{imageId}")
suspend fun deleteRoomImage(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Path("imageId") imageId: String
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/{imageId}/tags")
suspend fun updateRoomImageTags(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Path("imageId") imageId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageTagUpsertRequest
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room")
suspend fun reorderRoomImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageReorderRequest
): Response<Unit>
@PUT("properties/{propertyId}/rooms/{roomId}/images/reorder-room-type")
suspend fun reorderRoomTypeImages(
@Path("propertyId") propertyId: String,
@Path("roomId") roomId: String,
@Body body: com.android.trisolarispms.data.api.model.RoomImageReorderRequest
): Response<Unit>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +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 RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
data class Amenities(val propertyId: String) : AppRoute
data class AddAmenity(val propertyId: String) : AppRoute
data class EditAmenity(val propertyId: String, val amenityId: String) : AppRoute
}

View File

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

View File

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

View File

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

View File

@@ -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)
}
)
}
}

View File

@@ -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
)

View File

@@ -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")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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()
}

View File

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

View File

@@ -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
}

View File

@@ -0,0 +1,180 @@
package com.android.trisolarispms.ui.card
import android.app.Activity
import android.nfc.NfcAdapter
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.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
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.common.BackTopBarScaffold
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun IssueTemporaryCardScreen(
propertyId: String,
roomId: String,
roomNumber: String?,
onBack: () -> Unit,
viewModel: IssueTemporaryCardViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
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")
)
val progress = animateLottieCompositionAsState(
composition = composition.value,
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
val callback = NfcAdapter.ReaderCallback { tag ->
viewModel.onTagScanned(propertyId, roomId, tag)
}
nfcAdapter.enableReaderMode(activity, callback, flags, null)
onDispose {
nfcAdapter.disableReaderMode(activity)
}
} else {
onDispose { }
}
}
BackTopBarScaffold(
title = "Issue Temporary Card",
onBack = onBack
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = roomNumber?.let { "Room $it" } ?: "Room",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Hold the NFC card near the reader to issue a temporary card.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (nfcAdapter == null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "NFC not supported on this device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (!nfcAdapter.isEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "NFC is off. Please enable NFC.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
state.status?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
state.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(32.dp))
LottieAnimation(
composition = composition.value,
progress = { progress.value },
modifier = Modifier.size(220.dp)
)
}
}
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")
}
}
)
}
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.card
data class IssueTemporaryCardState(
val isLoading: Boolean = false,
val isProcessing: Boolean = false,
val error: String? = null,
val status: String? = null,
val lastCardId: String? = null,
val lastExpiresAt: String? = null
)

View File

@@ -0,0 +1,359 @@
package com.android.trisolarispms.ui.card
import android.nfc.Tag
import android.nfc.tech.MifareClassic
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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
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
viewModelScope.launch(Dispatchers.IO) {
Log.d("IssueTempCard", "Tag detected. roomId=$roomId cardId=${tagIdToHex(tag.id)}")
_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,
roomId = roomId,
body = CardPrepareRequest(expiresAt = null)
)
Log.d("IssueTempCard", "Prepare-temp response=${prepareResponse.code()}")
val prepareBody = prepareResponse.body()
if (!prepareResponse.isSuccessful || prepareBody == null) {
_state.update {
it.copy(
isProcessing = false,
error = "Prepare failed: ${prepareResponse.code()}",
status = null
)
}
return@launch
}
val cardIndex = prepareBody.cardIndex
val keyHex = prepareBody.key
val timeHex = prepareBody.timeData
if (cardIndex == null || keyHex.isNullOrBlank() || timeHex.isNullOrBlank()) {
_state.update {
it.copy(
isProcessing = false,
error = "Prepare response missing data.",
status = null
)
}
return@launch
}
_state.update { it.copy(status = "Writing card...") }
val cardId = tagIdToHex(tag.id)
val writeResult = writeSector0(
tag = tag,
keyHex = keyHex,
timeHex = timeHex,
sector3Block0 = prepareBody.sector3Block0,
sector3Block1 = prepareBody.sector3Block1,
sector3Block2 = prepareBody.sector3Block2
)
if (writeResult != null) {
Log.d("IssueTempCard", "Write failed: $writeResult")
_state.update {
it.copy(
isProcessing = false,
error = writeResult,
status = null
)
}
return@launch
}
Log.d("IssueTempCard", "Calling issue-temp")
_state.update { it.copy(status = "Saving issued card...") }
val issueResponse = api.issueTemporaryRoomCard(
propertyId = propertyId,
roomId = roomId,
body = IssueCardRequest(
cardId = cardId,
cardIndex = cardIndex,
issuedAt = prepareBody.issuedAt
?: nowAndExpiresIso(minutes = 7).first
)
)
Log.d("IssueTempCard", "Issue-temp response=${issueResponse.code()}")
if (issueResponse.isSuccessful) {
val body = issueResponse.body()
_state.update {
it.copy(
isProcessing = false,
error = null,
status = if (body != null) {
"Card issued. Expires in 7 minutes."
} else {
"Card issued (no response body)."
},
lastCardId = cardId,
lastExpiresAt = prepareBody.expiresAt
)
}
} else {
val errorText = issueResponse.errorBody()?.string()
Log.d("IssueTempCard", "Issue-temp error body=$errorText")
_state.update {
it.copy(
isProcessing = false,
error = when (issueResponse.code()) {
409 -> "Active card already exists."
else -> "Issue failed: ${issueResponse.code()}"
},
status = null
)
}
}
} catch (e: Exception) {
Log.d("IssueTempCard", "Issue failed: ${e.localizedMessage}", e)
_state.update {
it.copy(
isProcessing = false,
error = e.localizedMessage ?: "Issue failed",
status = null
)
}
}
}
}
private fun writeSector0(
tag: Tag,
keyHex: String,
timeHex: String,
sector3Block0: String?,
sector3Block1: String?,
sector3Block2: 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) ||
mifare.authenticateSectorWithKeyA(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
val authB = mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_DEFAULT) ||
mifare.authenticateSectorWithKeyB(sectorIndex, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY)
if (!authA && !authB) return "Card authentication failed (sector 0)."
val keyBytes = hexToBytes(keyHex)
val timeBytes = hexToBytes(timeHex)
val blockIndex = mifare.sectorToBlock(sectorIndex)
Log.d("IssueTempCard", "Writing blocks ${blockIndex + 1} and ${blockIndex + 2}")
mifare.writeBlock(blockIndex + 1, keyBytes)
mifare.writeBlock(blockIndex + 2, timeBytes)
val trailerWrite = writeTrailerBlocks(mifare)
if (trailerWrite != null) return trailerWrite
val sector3Write = writeSector3Blocks(
mifare = mifare,
block0 = sector3Block0,
block1 = sector3Block1,
block2 = sector3Block2
)
if (sector3Write != null) return sector3Write
null
} catch (e: Exception) {
e.localizedMessage ?: "Write failed."
} finally {
try {
mifare.close()
} catch (_: Exception) {
}
}
}
private fun writeTrailerBlocks(mifare: MifareClassic): String? {
val sectors = listOf(0, 1, 2, 14)
for (sector in sectors) {
val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Trailer auth failed (sector $sector)."
val blockIndex = mifare.sectorToBlock(sector) + 3
mifare.writeBlock(blockIndex, CARD_SECTOR_INFO)
}
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?,
block1: String?,
block2: String?
): String? {
if (block0.isNullOrBlank() && block1.isNullOrBlank() && block2.isNullOrBlank()) return null
val sector = 3
val authA = mifare.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)
val authB = mifare.authenticateSectorWithKeyB(sector, MifareClassic.KEY_DEFAULT)
if (!authA && !authB) return "Sector 3 authentication failed."
val blockIndex = mifare.sectorToBlock(sector)
block0?.let { mifare.writeBlock(blockIndex + 0, hexToBytes(it)) }
block1?.let { mifare.writeBlock(blockIndex + 1, hexToBytes(it)) }
block2?.let { mifare.writeBlock(blockIndex + 2, hexToBytes(it)) }
return null
}
private fun nowAndExpiresIso(minutes: Int): Pair<String, String> {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
timeZone = TimeZone.getDefault()
}
val calendar = Calendar.getInstance()
val issuedAt = format.format(calendar.time)
calendar.add(Calendar.MINUTE, minutes)
val expiresAt = format.format(calendar.time)
return issuedAt to expiresAt
}
private fun tagIdToHex(tagId: ByteArray?): String {
if (tagId == null) return ""
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
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
}
companion object {
private val CARD_SECTOR_INFO = hexToBytesStatic("1AB23CD45EF6FF078069FFFFFFFFFFFF")
private fun hexToBytesStatic(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
}
}
}

View File

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

View File

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

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