From 6cebefc91f82eac03950db78dbe1adbdb145ee68 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Tue, 27 Jan 2026 03:43:47 +0530 Subject: [PATCH] Implement property, rooms, and room types flows --- app/build.gradle.kts | 3 + .../com/android/trisolarispms/MainActivity.kt | 95 ++++++- .../android/trisolarispms/data/api/AuthApi.kt | 3 +- .../android/trisolarispms/data/api/CardApi.kt | 13 +- .../data/api/GuestDocumentApi.kt | 1 - .../android/trisolarispms/data/api/RoomApi.kt | 7 +- .../trisolarispms/data/api/RoomStayApi.kt | 9 +- .../data/api/model/AuthModels.kt | 19 +- .../data/api/model/BookingModels.kt | 15 +- .../data/api/model/CardModels.kt | 23 +- .../data/api/model/GuestModels.kt | 26 +- .../data/api/model/PropertyModels.kt | 3 +- .../data/api/model/PropertyUserModels.kt | 7 + .../data/api/model/RoomModels.kt | 63 ++--- .../data/api/model/RoomStayActiveModels.kt | 16 ++ .../data/api/model/RoomTypeModels.kt | 13 +- .../data/api/model/TransportModels.kt | 4 +- .../data/api/model/UserModels.kt | 7 +- .../data/places/PlacesRestClient.kt | 118 +++++++++ .../com/android/trisolarispms/ui/AppRoute.kt | 12 + .../trisolarispms/ui/auth/AuthViewModel.kt | 20 ++ .../trisolarispms/ui/home/HomeScreen.kt | 132 +++++++++- .../ui/property/AddPropertyScreen.kt | 249 ++++++++++++++++++ .../ui/property/AddPropertyState.kt | 15 ++ .../ui/property/AddPropertyViewModel.kt | 122 +++++++++ .../ui/property/PropertyHomeScreen.kt | 83 ++++++ .../ui/property/PropertyListState.kt | 9 + .../ui/property/PropertyListViewModel.kt | 37 +++ .../trisolarispms/ui/room/RoomFormScreen.kt | 190 +++++++++++++ .../trisolarispms/ui/room/RoomFormState.kt | 15 ++ .../ui/room/RoomFormViewModel.kt | 127 +++++++++ .../trisolarispms/ui/room/RoomListState.kt | 9 + .../ui/room/RoomListViewModel.kt | 38 +++ .../trisolarispms/ui/room/RoomsScreen.kt | 120 +++++++++ .../ui/roomstay/ActiveRoomStaysScreen.kt | 104 ++++++++ .../ui/roomstay/ActiveRoomStaysState.kt | 9 + .../ui/roomstay/ActiveRoomStaysViewModel.kt | 38 +++ .../ui/roomtype/AddRoomTypeScreen.kt | 114 ++++++++ .../ui/roomtype/RoomTypeFormState.kt | 12 + .../ui/roomtype/RoomTypeFormViewModel.kt | 52 ++++ .../ui/roomtype/RoomTypeListState.kt | 9 + .../ui/roomtype/RoomTypeListViewModel.kt | 38 +++ .../ui/roomtype/RoomTypesScreen.kt | 89 +++++++ app/src/main/res/values/strings.xml | 3 +- gradle/libs.versions.toml | 4 + 45 files changed, 1992 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/PropertyUserModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/RoomStayActiveModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/places/PlacesRestClient.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/property/PropertyHomeScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/property/PropertyListState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/room/RoomFormState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ee308c..3d595b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,6 +47,9 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.vectordrawable) + implementation(libs.androidx.vectordrawable.animated) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.retrofit) implementation(libs.retrofit.converter.gson) diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index d99de20..7ce3124 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -6,12 +6,21 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.trisolarispms.ui.AppRoute import com.android.trisolarispms.ui.auth.AuthScreen import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.auth.NameScreen import com.android.trisolarispms.ui.auth.UnauthorizedScreen import com.android.trisolarispms.ui.home.HomeScreen +import com.android.trisolarispms.ui.property.AddPropertyScreen +import com.android.trisolarispms.ui.room.RoomFormScreen +import com.android.trisolarispms.ui.room.RoomsScreen +import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen +import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen +import com.android.trisolarispms.ui.roomtype.RoomTypesScreen import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme class MainActivity : ComponentActivity() { @@ -31,7 +40,91 @@ class MainActivity : ComponentActivity() { } else if (state.apiVerified && state.needsName) { NameScreen(viewModel = authViewModel) } else if (state.apiVerified) { - HomeScreen(userId = state.userId, userName = state.userName) + val route = remember { mutableStateOf(AppRoute.Home) } + val refreshKey = remember { mutableStateOf(0) } + val selectedPropertyId = remember { mutableStateOf(null) } + val selectedPropertyName = remember { mutableStateOf(null) } + val selectedRoom = remember { mutableStateOf(null) } + val roomFormKey = remember { mutableStateOf(0) } + val currentRoute = route.value + + 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 = state.isSuperAdmin, + 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) } + ) + is AppRoute.AddRoomType -> AddRoomTypeScreen( + propertyId = currentRoute.propertyId, + onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }, + onSave = { route.value = AppRoute.RoomTypes(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) } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/AuthApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/AuthApi.kt index ddef436..c71826b 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/AuthApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/AuthApi.kt @@ -2,7 +2,6 @@ package com.android.trisolarispms.data.api import com.android.trisolarispms.data.api.model.AuthVerifyResponse import com.android.trisolarispms.data.api.model.AuthMeUpdateRequest -import com.android.trisolarispms.data.api.model.AppUserDto import com.android.trisolarispms.data.api.model.UserDto import retrofit2.Response import retrofit2.http.GET @@ -18,5 +17,5 @@ interface AuthApi { suspend fun me(): Response @PUT("auth/me") - suspend fun updateMe(@Body body: AuthMeUpdateRequest): Response + suspend fun updateMe(@Body body: AuthMeUpdateRequest): Response } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt index 18a1f57..3121c89 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/CardApi.kt @@ -1,9 +1,10 @@ package com.android.trisolarispms.data.api import com.android.trisolarispms.data.api.model.ActionResponse -import com.android.trisolarispms.data.api.model.CardDto -import com.android.trisolarispms.data.api.model.CardIssueRequest import com.android.trisolarispms.data.api.model.CardPrepareRequest +import com.android.trisolarispms.data.api.model.CardPrepareResponse +import com.android.trisolarispms.data.api.model.IssueCardRequest +import com.android.trisolarispms.data.api.model.IssuedCardResponse import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -16,20 +17,20 @@ interface CardApi { @Path("propertyId") propertyId: String, @Path("roomStayId") roomStayId: String, @Body body: CardPrepareRequest - ): Response + ): Response @POST("properties/{propertyId}/room-stays/{roomStayId}/cards") suspend fun issueCard( @Path("propertyId") propertyId: String, @Path("roomStayId") roomStayId: String, - @Body body: CardIssueRequest - ): Response + @Body body: IssueCardRequest + ): Response @GET("properties/{propertyId}/room-stays/{roomStayId}/cards") suspend fun listCards( @Path("propertyId") propertyId: String, @Path("roomStayId") roomStayId: String - ): Response> + ): Response> @POST("properties/{propertyId}/room-stays/cards/{cardId}/revoke") suspend fun revokeCard( diff --git a/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt index dc57034..a965379 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt @@ -19,7 +19,6 @@ interface GuestDocumentApi { @Path("propertyId") propertyId: String, @Path("guestId") guestId: String, @Part file: MultipartBody.Part, - @Part("docType") docType: RequestBody, @Part("bookingId") bookingId: RequestBody ): Response diff --git a/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt index b21fcde..9e211f1 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt @@ -1,6 +1,7 @@ package com.android.trisolarispms.data.api -import com.android.trisolarispms.data.api.model.AvailabilityResponse +import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse +import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse import com.android.trisolarispms.data.api.model.RoomBoardDto import com.android.trisolarispms.data.api.model.RoomCreateRequest import com.android.trisolarispms.data.api.model.RoomDto @@ -40,12 +41,12 @@ interface RoomApi { suspend fun streamRoomBoard(@Path("propertyId") propertyId: String): Response @GET("properties/{propertyId}/rooms/availability") - suspend fun getRoomAvailability(@Path("propertyId") propertyId: String): Response + suspend fun getRoomAvailability(@Path("propertyId") propertyId: String): Response> @GET("properties/{propertyId}/rooms/availability-range") suspend fun getRoomAvailabilityRange( @Path("propertyId") propertyId: String, @Query("from") from: String, @Query("to") to: String - ): Response + ): Response> } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/RoomStayApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RoomStayApi.kt index 4bcd651..f6dd392 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/RoomStayApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/RoomStayApi.kt @@ -1,9 +1,11 @@ package com.android.trisolarispms.data.api import com.android.trisolarispms.data.api.model.RoomChangeRequest -import com.android.trisolarispms.data.api.model.RoomStayDto +import com.android.trisolarispms.data.api.model.RoomChangeResponse +import com.android.trisolarispms.data.api.model.ActiveRoomStayDto import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path @@ -13,5 +15,8 @@ interface RoomStayApi { @Path("propertyId") propertyId: String, @Path("roomStayId") roomStayId: String, @Body body: RoomChangeRequest - ): Response + ): Response + + @GET("properties/{propertyId}/room-stays/active") + suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response> } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/AuthModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/AuthModels.kt index a3555b0..db378ca 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/AuthModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/AuthModels.kt @@ -2,25 +2,10 @@ package com.android.trisolarispms.data.api.model data class AuthVerifyResponse( val status: String? = null, - val user: AppUserDto? = null, + val user: UserDto? = null, val properties: List? = null ) data class AuthMeUpdateRequest( - val name: String -) - -data class AppUserDto( - val id: String? = null, - val firebaseUid: String? = null, - val phoneE164: String? = null, - val name: String? = null, - val disabled: Boolean? = null, - val superAdmin: Boolean? = null -) - -data class PropertyUserDto( - val userId: String? = null, - val propertyId: String? = null, - val roles: List? = null + val name: String? ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt index 5a245d9..90be48a 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt @@ -5,12 +5,7 @@ data class BookingCheckInRequest( val checkInAt: String? = null, val transportMode: String? = null, val transportVehicleNumber: String? = null, - val notes: String? = null, - val adultCount: Int? = null, - val childCount: Int? = null, - val maleCount: Int? = null, - val femaleCount: Int? = null, - val totalGuestCount: Int? = null + val notes: String? = null ) data class BookingCheckOutRequest( @@ -56,3 +51,11 @@ data class RoomChangeRequest( 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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt index fe49eb4..0a83e97 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/CardModels.kt @@ -4,15 +4,30 @@ data class CardPrepareRequest( val expiresAt: String? = null ) -data class CardIssueRequest( +data class CardPrepareResponse( + val cardIndex: Int? = null, + val key: String? = null, + val timeData: String? = null, + val issuedAt: String? = null, + val expiresAt: String? = null +) + +data class IssueCardRequest( val cardId: String, val cardIndex: Int, val issuedAt: String? = null, val expiresAt: String ) -data class CardDto( +data class IssuedCardResponse( val id: String? = null, - val cardNumber: String? = null, - val status: String? = null + val propertyId: String? = null, + val roomId: String? = null, + val roomStayId: String? = null, + val cardId: String? = null, + val cardIndex: Int? = null, + val issuedAt: String? = null, + val expiresAt: String? = null, + val issuedByUserId: String? = null, + val revokedAt: String? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt index e4bf097..5b259fd 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt @@ -3,8 +3,11 @@ package com.android.trisolarispms.data.api.model data class GuestDto( val id: String? = null, val name: String? = null, - val phone: String? = null, - val email: String? = null + val phoneE164: String? = null, + val nationality: String? = null, + val addressText: String? = null, + val vehicleNumbers: List = emptyList(), + val averageScore: Double? = null ) data class GuestVehicleRequest( @@ -24,14 +27,25 @@ data class GuestRatingRequest( data class GuestRatingDto( val id: String? = null, + val propertyId: String? = null, + val guestId: String? = null, + val bookingId: String? = null, val score: String? = null, val notes: String? = null, - val createdAt: String? = null + val createdAt: String? = null, + val createdByUserId: String? = null ) data class GuestDocumentDto( val id: String? = null, - val type: String? = null, - val fileName: String? = null, - val createdAt: String? = null + val propertyId: String? = null, + val guestId: String? = null, + 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 extractedData: Map? = null, + val extractedAt: String? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt index 354700a..167d186 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyModels.kt @@ -6,6 +6,7 @@ data class PropertyCreateRequest( val addressText: String? = null, val timezone: String? = null, val currency: String? = null, + val active: Boolean? = null, val emailAddresses: List? = null, val otaAliases: List? = null, val allowedTransportModes: List? = null @@ -31,7 +32,7 @@ data class PropertyDto( val timezone: String? = null, val currency: String? = null, val active: Boolean? = null, - val emailAddresses: List? = null, val otaAliases: List? = null, + val emailAddresses: List? = null, val allowedTransportModes: List? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyUserModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyUserModels.kt new file mode 100644 index 0000000..0c09dc2 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/PropertyUserModels.kt @@ -0,0 +1,7 @@ +package com.android.trisolarispms.data.api.model + +data class PropertyUserDto( + val userId: String? = null, + val propertyId: String? = null, + val roles: List? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt index 6b64ecf..c62769b 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt @@ -1,30 +1,31 @@ package com.android.trisolarispms.data.api.model data class RoomCreateRequest( - val roomNumber: String, - val floor: String? = null, - val roomTypeId: String, - val hasNfc: Boolean? = null, - val active: Boolean? = null, - val maintenance: Boolean? = null, + val roomNumber: Int, + val floor: Int? = null, + val roomTypeCode: String, + val hasNfc: Boolean, + val active: Boolean, + val maintenance: Boolean, val notes: String? = null ) data class RoomUpdateRequest( - val roomNumber: String, - val floor: String? = null, - val roomTypeId: String, - val hasNfc: Boolean? = null, - val active: Boolean? = null, - val maintenance: Boolean? = null, + val roomNumber: Int, + val floor: Int? = null, + val roomTypeCode: String, + val hasNfc: Boolean, + val active: Boolean, + val maintenance: Boolean, val notes: String? = null ) data class RoomDto( val id: String? = null, - val roomNumber: String? = null, - val roomTypeId: String? = null, - val floor: String? = null, + val roomNumber: Int? = null, + val roomTypeCode: String? = null, + val roomTypeName: String? = null, + val floor: Int? = null, val hasNfc: Boolean? = null, val active: Boolean? = null, val maintenance: Boolean? = null, @@ -32,31 +33,31 @@ data class RoomDto( ) data class RoomBoardDto( - val items: List = emptyList() + val roomNumber: Int? = null, + val roomTypeName: String? = null, + val status: String? = null ) -data class RoomBoardItemDto( - val roomId: String? = null, - val status: String? = null, - val roomStayId: String? = null, - val guestName: String? = null +data class RoomAvailabilityResponse( + val roomTypeName: String? = null, + val freeRoomNumbers: List = emptyList() ) -data class AvailabilityResponse( - val rooms: List = emptyList() -) - -data class RoomAvailabilityDto( - val roomId: String? = null, - val date: String? = null, - val available: Boolean? = null, - val rate: Double? = null +data class RoomAvailabilityRangeResponse( + val roomTypeName: String? = null, + val freeRoomNumbers: List = emptyList(), + val freeCount: Int? = null ) // Images data class ImageDto( val id: String? = null, + val propertyId: String? = null, + val roomId: String? = null, val url: String? = null, - val fileName: String? = null + val thumbnailUrl: String? = null, + val contentType: String? = null, + val sizeBytes: Long? = null, + val createdAt: String? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomStayActiveModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomStayActiveModels.kt new file mode 100644 index 0000000..6da42cb --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomStayActiveModels.kt @@ -0,0 +1,16 @@ +package com.android.trisolarispms.data.api.model + +data class ActiveRoomStayDto( + val roomStayId: String? = null, + 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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt index 222ed42..66e444f 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt @@ -3,24 +3,25 @@ package com.android.trisolarispms.data.api.model data class RoomTypeCreateRequest( val code: String, val name: String, - val maxAdults: Int? = null, - val maxChildren: Int? = null, + val baseOccupancy: Int? = null, + val maxOccupancy: Int? = null, val otaAliases: List? = null ) data class RoomTypeUpdateRequest( val code: String? = null, val name: String? = null, - val maxAdults: Int? = null, - val maxChildren: Int? = null, + val baseOccupancy: Int? = null, + val maxOccupancy: Int? = null, val otaAliases: List? = null ) data class RoomTypeDto( val id: String? = null, + val propertyId: String? = null, val code: String? = null, val name: String? = null, - val maxAdults: Int? = null, - val maxChildren: Int? = null, + val baseOccupancy: Int? = null, + val maxOccupancy: Int? = null, val otaAliases: List? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/TransportModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/TransportModels.kt index 81fc4ea..3d493a0 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/TransportModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/TransportModels.kt @@ -1,6 +1,6 @@ package com.android.trisolarispms.data.api.model data class TransportModeDto( - val id: String? = null, - val name: String? = null + val mode: String? = null, + val enabled: Boolean? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/UserModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/UserModels.kt index 3dead1c..78880a5 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/UserModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/UserModels.kt @@ -2,10 +2,11 @@ package com.android.trisolarispms.data.api.model data class UserDto( val id: String? = null, + val firebaseUid: String? = null, + val phoneE164: String? = null, val name: String? = null, - val email: String? = null, - val phone: String? = null, - val roles: List? = null + val disabled: Boolean? = null, + val superAdmin: Boolean? = null ) data class UserRolesUpdateRequest( diff --git a/app/src/main/java/com/android/trisolarispms/data/places/PlacesRestClient.kt b/app/src/main/java/com/android/trisolarispms/data/places/PlacesRestClient.kt new file mode 100644 index 0000000..0f95511 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/places/PlacesRestClient.kt @@ -0,0 +1,118 @@ +package com.android.trisolarispms.data.places + +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +object PlacesRestClient { + private const val BASE_URL = "https://places.googleapis.com/v1" + private val gson = Gson() + private val client = OkHttpClient() + private val jsonMedia = "application/json; charset=utf-8".toMediaType() + + suspend fun autocomplete( + apiKey: String, + input: String, + sessionToken: String, + regionCode: String? = "IN" + ): List = withContext(Dispatchers.IO) { + val body = AutocompleteRequest( + input = input, + sessionToken = sessionToken, + regionCode = regionCode + ) + val request = Request.Builder() + .url("$BASE_URL/places:autocomplete") + .addHeader("X-Goog-Api-Key", apiKey) + .addHeader("X-Goog-FieldMask", "suggestions.placePrediction.placeId,suggestions.placePrediction.text.text") + .post(gson.toJson(body).toRequestBody(jsonMedia)) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw RuntimeException("Places autocomplete failed: ${response.code}") + } + val payload = response.body?.string().orEmpty() + val parsed = gson.fromJson(payload, AutocompleteResponse::class.java) + return@withContext parsed.suggestions + ?.mapNotNull { it.placePrediction } + ?.mapNotNull { pred -> + val text = pred.text?.text + val id = pred.placeId + if (id.isNullOrBlank() || text.isNullOrBlank()) null else PlacePrediction(id, text) + } + .orEmpty() + } + } + + suspend fun placeDetails( + apiKey: String, + placeId: String, + sessionToken: String + ): PlaceDetails = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$BASE_URL/places/$placeId") + .addHeader("X-Goog-Api-Key", apiKey) + .addHeader("X-Goog-FieldMask", "displayName,formattedAddress") + .addHeader("X-Goog-Session-Token", sessionToken) + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw RuntimeException("Place details failed: ${response.code}") + } + val payload = response.body?.string().orEmpty() + val parsed = gson.fromJson(payload, PlaceDetailsResponse::class.java) + val name = parsed.displayName?.text + val address = parsed.formattedAddress + return@withContext PlaceDetails(name = name, address = address) + } + } +} + +data class PlacePrediction( + val placeId: String, + val text: String +) + +data class PlaceDetails( + val name: String?, + val address: String? +) + +private data class AutocompleteRequest( + val input: String, + val sessionToken: String, + val regionCode: String? = null +) + +private data class AutocompleteResponse( + val suggestions: List? = null +) + +private data class Suggestion( + val placePrediction: PlacePredictionResponse? = null +) + +private data class PlacePredictionResponse( + val placeId: String? = null, + val text: PlaceText? = null +) + +private data class PlaceText( + val text: String? = null +) + +private data class PlaceDetailsResponse( + val displayName: DisplayName? = null, + val formattedAddress: String? = null +) + +private data class DisplayName( + val text: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt new file mode 100644 index 0000000..deb8db9 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -0,0 +1,12 @@ +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 +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt index 293c190..08aef09 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt @@ -256,4 +256,24 @@ class AuthViewModel( } } + fun refreshMe() { + viewModelScope.launch { + try { + val api = ApiClient.create(auth = auth) + val response = api.me() + if (response.isSuccessful) { + val user = response.body() + _state.update { + it.copy( + userName = user?.name ?: it.userName, + isSuperAdmin = user?.superAdmin ?: it.isSuperAdmin + ) + } + } + } catch (_: Exception) { + // ignore refresh errors + } + } + } + } diff --git a/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt index 1d162d1..b87c046 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/home/HomeScreen.kt @@ -1,27 +1,137 @@ package com.android.trisolarispms.ui.home +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.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.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.trisolarispms.ui.property.PropertyListViewModel @Composable -fun HomeScreen(userId: String?, userName: String?) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center - ) { - val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome" - Text(text = title, style = MaterialTheme.typography.headlineMedium) - if (userId != null) { - Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall) +@OptIn(ExperimentalMaterial3Api::class) +fun HomeScreen( + userId: String?, + userName: String?, + isSuperAdmin: Boolean, + onAddProperty: () -> Unit, + refreshKey: Int, + selectedPropertyId: String?, + onSelectProperty: (String, String) -> Unit, + onRefreshProfile: () -> Unit, + viewModel: PropertyListViewModel = viewModel() +) { + var menuExpanded by remember { mutableStateOf(false) } + val state by viewModel.state.collectAsState() + + LaunchedEffect(refreshKey) { + viewModel.refresh() + } + + LaunchedEffect(Unit) { + onRefreshProfile() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Trisolaris PMS") }, + colors = TopAppBarDefaults.topAppBarColors(), + actions = { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "Menu") + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("Add Property") }, + onClick = { + menuExpanded = false + onAddProperty() + } + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome" + Text(text = title, style = MaterialTheme.typography.headlineMedium) + if (isSuperAdmin) { + Text(text = "Super Admin", style = MaterialTheme.typography.labelLarge) + } + if (userId != null) { + Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall) + } + + 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 (!state.isLoading && state.error == null) { + Spacer(modifier = Modifier.height(12.dp)) + if (state.properties.isEmpty()) { + Text(text = "No properties yet") + } else { + Text(text = "Properties", style = MaterialTheme.typography.titleLarge) + state.properties.forEach { property -> + val label = property.name ?: property.code ?: "Unnamed" + val isSelected = property.id == selectedPropertyId + Text( + text = if (isSelected) "• $label (selected)" else "• $label", + style = if (isSelected) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + .clickable(enabled = property.id != null) { + val id = property.id + val name = property.name ?: property.code ?: "Property" + if (id != null) onSelectProperty(id, name) + } + ) + } + } + } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt new file mode 100644 index 0000000..51e5061 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyScreen.kt @@ -0,0 +1,249 @@ +package com.android.trisolarispms.ui.property + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.trisolarispms.R +import com.android.trisolarispms.data.places.PlacePrediction +import com.android.trisolarispms.data.places.PlacesRestClient +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.UUID + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun AddPropertyScreen( + onBack: () -> Unit, + onCreated: () -> Unit, + viewModel: AddPropertyViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val apiKey = context.getString(R.string.google_maps_key) + val sessionToken = remember { UUID.randomUUID().toString() } + val coroutineScope = rememberCoroutineScope() + var searchJob by remember { mutableStateOf(null) } + var predictions by remember { mutableStateOf>(emptyList()) } + var expanded by remember { mutableStateOf(false) } + var isSearching by remember { mutableStateOf(false) } + var placesError by remember { mutableStateOf(null) } + + LaunchedEffect(state.createdPropertyId) { + if (state.createdPropertyId != null) { + viewModel.reset() + onCreated() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Property") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = state.code, + onValueChange = viewModel::onCodeChange, + label = { Text("Code") }, + isError = state.codeError != null, + modifier = Modifier.fillMaxWidth() + ) + state.codeError?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + + Spacer(modifier = Modifier.height(12.dp)) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = state.name, + onValueChange = { value -> + viewModel.onNameChange(value) + val query = value.trim() + searchJob?.cancel() + placesError = null + if (query.length >= 4) { + searchJob = coroutineScope.launch { + delay(300) + isSearching = true + try { + predictions = PlacesRestClient.autocomplete( + apiKey = apiKey, + input = query, + sessionToken = sessionToken, + regionCode = "IN" + ) + expanded = predictions.isNotEmpty() + } catch (e: Exception) { + predictions = emptyList() + expanded = false + placesError = e.localizedMessage ?: "Places search failed" + } finally { + isSearching = false + } + } + } else { + predictions = emptyList() + expanded = false + isSearching = false + } + }, + label = { Text("Name") }, + isError = state.nameError != null, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + predictions.forEach { prediction -> + DropdownMenuItem( + text = { Text(prediction.text) }, + onClick = { + expanded = false + predictions = emptyList() + coroutineScope.launch { + try { + val details = PlacesRestClient.placeDetails( + apiKey = apiKey, + placeId = prediction.placeId, + sessionToken = sessionToken + ) + viewModel.applyPlace(details.name, details.address) + } catch (e: Exception) { + placesError = e.localizedMessage ?: "Place lookup failed" + } + } + } + ) + } + } + } + state.nameError?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + if (isSearching) { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = "Searching…", style = MaterialTheme.typography.bodySmall) + } + placesError?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.addressText, + onValueChange = viewModel::onAddressChange, + label = { Text("Address") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.timezone, + onValueChange = viewModel::onTimezoneChange, + label = { Text("Timezone") }, + placeholder = { Text("Asia/Kolkata") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.currency, + onValueChange = viewModel::onCurrencyChange, + label = { Text("Currency") }, + placeholder = { Text("INR") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = viewModel::submit, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Create") + } + + 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) + } + + state.createdPropertyId?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "Created property: $it") + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt new file mode 100644 index 0000000..066a9a5 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyState.kt @@ -0,0 +1,15 @@ +package com.android.trisolarispms.ui.property + +data class AddPropertyState( + val code: String = "", + val name: String = "", + val addressText: String = "", + val timezone: String = "Asia/Kolkata", + val currency: String = "INR", + val isLoading: Boolean = false, + val codeError: String? = null, + val nameError: String? = null, + val error: String? = null, + val createdPropertyId: String? = null, + val codeAuto: Boolean = true +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt new file mode 100644 index 0000000..bcc8601 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/property/AddPropertyViewModel.kt @@ -0,0 +1,122 @@ +package com.android.trisolarispms.ui.property + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.PropertyCreateRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AddPropertyViewModel : ViewModel() { + private val _state = MutableStateFlow(AddPropertyState()) + val state: StateFlow = _state + + fun onCodeChange(value: String) { + val normalized = value.trim().uppercase() + _state.update { it.copy(code = normalized, codeError = null, error = null, codeAuto = false) } + } + + fun onNameChange(value: String) { + _state.update { current -> + val nextCode = if (current.codeAuto) generateCode(value) else current.code + current.copy(name = value, nameError = null, error = null, code = nextCode) + } + } + + fun onAddressChange(value: String) { + _state.update { it.copy(addressText = value, error = null) } + } + + fun onTimezoneChange(value: String) { + _state.update { it.copy(timezone = value, error = null) } + } + + fun onCurrencyChange(value: String) { + _state.update { it.copy(currency = value, error = null) } + } + + fun applyPlace(name: String?, address: String?) { + _state.update { current -> + val nextName = if (current.name.isBlank() && !name.isNullOrBlank()) name else current.name + val nextCode = if (current.codeAuto && nextName.isNotBlank()) generateCode(nextName) else current.code + current.copy( + name = nextName, + addressText = address ?: current.addressText, + code = nextCode, + error = null, + nameError = null, + codeError = null + ) + } + } + + fun submit() { + val code = state.value.code.trim() + val name = state.value.name.trim() + val codeError = validateCode(code) + val nameError = validateName(name) + if (codeError != null || nameError != null) { + _state.update { + it.copy( + codeError = codeError, + nameError = nameError, + error = "Please fix the highlighted fields" + ) + } + return + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val body = PropertyCreateRequest( + code = code, + name = name, + addressText = state.value.addressText.takeIf { it.isNotBlank() }, + timezone = state.value.timezone.takeIf { it.isNotBlank() }, + currency = state.value.currency.takeIf { it.isNotBlank() } + ) + val response = api.createProperty(body) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + createdPropertyId = response.body()?.id, + error = null + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } + } + } + } + + fun reset() { + _state.update { AddPropertyState() } + } + + private fun generateCode(name: String): String { + val base = name.filter { it.isLetterOrDigit() }.uppercase().take(4).padEnd(4, 'X') + val suffix = kotlin.random.Random.nextInt(100, 999) + return "$base$suffix" + } + + private fun validateCode(code: String): String? { + if (code.isBlank()) return "Code is required" + if (code.length < 2) return "Code is too short" + if (!code.matches(Regex("^[A-Z0-9_-]+$"))) return "Only A-Z, 0-9, _ and -" + return null + } + + private fun validateName(name: String): String? { + if (name.isBlank()) return "Name is required" + if (name.length < 2) return "Name is too short" + return null + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/PropertyHomeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyHomeScreen.kt new file mode 100644 index 0000000..d6ae09b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyHomeScreen.kt @@ -0,0 +1,83 @@ +package com.android.trisolarispms.ui.property + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun PropertyHomeScreen( + propertyId: String, + onBack: () -> Unit, + onActiveStays: () -> Unit, + onRooms: () -> Unit, + onRoomTypes: () -> Unit, + canManageRooms: Boolean +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Property") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + PropertyTile(title = "Checked-in Rooms", subtitle = "Active stays", onClick = onActiveStays) + Spacer(modifier = Modifier.height(12.dp)) + PropertyTile(title = "Available Rooms", subtitle = "Room list", onClick = onRooms) + if (canManageRooms) { + Spacer(modifier = Modifier.height(12.dp)) + PropertyTile(title = "Room Types", subtitle = "Create/edit", onClick = onRoomTypes) + } + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "Property ID: $propertyId", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun PropertyTile(title: String, subtitle: String, onClick: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + tonalElevation = 2.dp, + shape = MaterialTheme.shapes.medium + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text(text = subtitle, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListState.kt b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListState.kt new file mode 100644 index 0000000..87a01f9 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListState.kt @@ -0,0 +1,9 @@ +package com.android.trisolarispms.ui.property + +import com.android.trisolarispms.data.api.model.PropertyDto + +data class PropertyListState( + val isLoading: Boolean = false, + val error: String? = null, + val properties: List = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt new file mode 100644 index 0000000..038a7bc --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/property/PropertyListViewModel.kt @@ -0,0 +1,37 @@ +package com.android.trisolarispms.ui.property + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PropertyListViewModel : ViewModel() { + private val _state = MutableStateFlow(PropertyListState()) + val state: StateFlow = _state + + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.listProperties() + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + properties = response.body().orEmpty(), + error = null + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt new file mode 100644 index 0000000..151cad7 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormScreen.kt @@ -0,0 +1,190 @@ +package com.android.trisolarispms.ui.room + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RoomFormScreen( + title: String, + propertyId: String, + roomId: String?, + roomData: com.android.trisolarispms.data.api.model.RoomDto?, + formKey: Int, + onBack: () -> Unit, + onSave: () -> Unit, + viewModel: RoomFormViewModel = viewModel(), + roomTypesViewModel: RoomTypeListViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val typesState by roomTypesViewModel.state.collectAsState() + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(formKey, roomId, roomData) { + viewModel.reset() + roomData?.let { viewModel.setRoom(it) } + } + LaunchedEffect(propertyId) { + roomTypesViewModel.load(propertyId) + } + LaunchedEffect(typesState.items, state.roomTypeLabel) { + if (typesState.items.isNotEmpty()) { + viewModel.ensureRoomTypeCodeFromName(typesState.items) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { + if (roomId == null) { + viewModel.submitCreate(propertyId, onSave) + } else { + viewModel.submitUpdate(propertyId, roomId, onSave) + } + }) { + Icon(Icons.Default.Done, contentDescription = "Save") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + OutlinedTextField( + value = state.roomNumber, + onValueChange = viewModel::onRoomNumberChange, + label = { Text("Room Number (Code)") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.floor, + onValueChange = viewModel::onFloorChange, + label = { Text("Floor") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = state.roomTypeLabel, + onValueChange = {}, + readOnly = true, + label = { Text("Room Type") }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + typesState.items.forEach { type -> + DropdownMenuItem( + text = { Text("${type.code} • ${type.name}") }, + onClick = { + viewModel.onRoomTypeSelected( + code = type.code ?: "", + name = type.name ?: "" + ) + expanded = false + } + ) + } + } + } + if (typesState.items.isEmpty() && !typesState.isLoading && typesState.error == null) { + Text(text = "No room types. Add one first.", style = MaterialTheme.typography.bodySmall) + } + Spacer(modifier = Modifier.height(12.dp)) + + RowToggle(label = "Has NFC", checked = state.hasNfc, onChecked = viewModel::onHasNfcChange) + RowToggle(label = "Active", checked = state.active, onChecked = viewModel::onActiveChange) + RowToggle(label = "Maintenance", checked = state.maintenance, onChecked = viewModel::onMaintenanceChange) + + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.notes, + onValueChange = viewModel::onNotesChange, + label = { Text("Notes") }, + modifier = Modifier.fillMaxWidth() + ) + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + + if (!state.isLoading && state.success) { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "Saved") + } + } + } + +} + +@Composable +private fun RowToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween + ) { + Text(text = label) + Switch(checked = checked, onCheckedChange = onChecked) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormState.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormState.kt new file mode 100644 index 0000000..fc006ac --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormState.kt @@ -0,0 +1,15 @@ +package com.android.trisolarispms.ui.room + +data class RoomFormState( + val roomNumber: String = "", + val floor: String = "", + val roomTypeCode: String = "", + val roomTypeLabel: String = "", + val hasNfc: Boolean = false, + val active: Boolean = true, + val maintenance: Boolean = false, + val notes: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val success: Boolean = false +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt new file mode 100644 index 0000000..541606e --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomFormViewModel.kt @@ -0,0 +1,127 @@ +package com.android.trisolarispms.ui.room + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.RoomCreateRequest +import com.android.trisolarispms.data.api.model.RoomUpdateRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class RoomFormViewModel : ViewModel() { + private val _state = MutableStateFlow(RoomFormState()) + val state: StateFlow = _state + + fun reset() { + _state.update { RoomFormState() } + } + + fun setRoom(room: com.android.trisolarispms.data.api.model.RoomDto) { + _state.update { + it.copy( + roomNumber = room.roomNumber?.toString() ?: "", + floor = room.floor?.toString() ?: "", + roomTypeCode = room.roomTypeCode ?: "", + roomTypeLabel = room.roomTypeName ?: room.roomTypeCode ?: "", + hasNfc = room.hasNfc ?: false, + active = room.active ?: true, + maintenance = room.maintenance ?: false, + notes = room.notes ?: "" + ) + } + } + + fun onRoomNumberChange(value: String) = _state.update { it.copy(roomNumber = value, error = null) } + fun onFloorChange(value: String) = _state.update { it.copy(floor = value, error = null) } + fun onRoomTypeSelected(code: String, name: String) = _state.update { + it.copy( + roomTypeCode = code, + roomTypeLabel = "$code • $name", + error = null + ) + } + + fun ensureRoomTypeCodeFromName(types: List) { + val current = state.value + if (current.roomTypeCode.isNotBlank()) return + val nameOnly = current.roomTypeLabel.substringAfter("•", current.roomTypeLabel).trim() + val match = types.firstOrNull { it.name == nameOnly } + if (match?.code != null) { + onRoomTypeSelected(match.code, match.name ?: match.code) + } + } + fun onHasNfcChange(value: Boolean) = _state.update { it.copy(hasNfc = value) } + fun onActiveChange(value: Boolean) = _state.update { it.copy(active = value) } + fun onMaintenanceChange(value: Boolean) = _state.update { it.copy(maintenance = value) } + fun onNotesChange(value: String) = _state.update { it.copy(notes = value, error = null) } + + fun submitCreate(propertyId: String, onDone: () -> Unit) { + val roomNumberText = state.value.roomNumber.trim() + val roomTypeCode = state.value.roomTypeCode.trim() + val roomNumber = roomNumberText.toIntOrNull() + if (roomNumber == null || roomTypeCode.isBlank()) { + _state.update { it.copy(error = "Room number must be a number and room type is required") } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val body = RoomCreateRequest( + roomNumber = roomNumber, + floor = state.value.floor.trim().toIntOrNull(), + roomTypeCode = roomTypeCode, + hasNfc = state.value.hasNfc, + active = state.value.active, + maintenance = state.value.maintenance, + notes = state.value.notes.takeIf { it.isNotBlank() } + ) + val response = api.createRoom(propertyId, body) + if (response.isSuccessful) { + _state.update { it.copy(isLoading = false, success = true) } + onDone() + } else { + _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } + } + } + } + + fun submitUpdate(propertyId: String, roomId: String, onDone: () -> Unit) { + val roomNumberText = state.value.roomNumber.trim() + val roomTypeCode = state.value.roomTypeCode.trim() + val roomNumber = roomNumberText.toIntOrNull() + if (roomNumber == null || roomTypeCode.isBlank()) { + _state.update { it.copy(error = "Room number must be a number and room type is required") } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val body = RoomUpdateRequest( + roomNumber = roomNumber, + floor = state.value.floor.trim().toIntOrNull(), + roomTypeCode = roomTypeCode, + hasNfc = state.value.hasNfc, + active = state.value.active, + maintenance = state.value.maintenance, + notes = state.value.notes.takeIf { it.isNotBlank() } + ) + val response = api.updateRoom(propertyId, roomId, body) + if (response.isSuccessful) { + _state.update { it.copy(isLoading = false, success = true) } + onDone() + } else { + _state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt new file mode 100644 index 0000000..bff5d55 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt @@ -0,0 +1,9 @@ +package com.android.trisolarispms.ui.room + +import com.android.trisolarispms.data.api.model.RoomDto + +data class RoomListState( + val isLoading: Boolean = false, + val error: String? = null, + val rooms: List = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt new file mode 100644 index 0000000..b481081 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt @@ -0,0 +1,38 @@ +package com.android.trisolarispms.ui.room + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class RoomListViewModel : ViewModel() { + private val _state = MutableStateFlow(RoomListState()) + val state: StateFlow = _state + + fun load(propertyId: String) { + if (propertyId.isBlank()) return + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.listRooms(propertyId) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + rooms = response.body().orEmpty(), + error = null + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt new file mode 100644 index 0000000..101f0b2 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt @@ -0,0 +1,120 @@ +package com.android.trisolarispms.ui.room + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.draw.alpha +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Category +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RoomsScreen( + propertyId: String, + onBack: () -> Unit, + onAddRoom: () -> Unit, + onViewRoomTypes: () -> Unit, + canManageRooms: Boolean, + onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, + viewModel: RoomListViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(propertyId) { + viewModel.load(propertyId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Available Rooms") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (canManageRooms) { + IconButton(onClick = onViewRoomTypes) { + Icon(Icons.Default.Category, contentDescription = "Room Types") + } + IconButton(onClick = onAddRoom) { + Icon(Icons.Default.Add, contentDescription = "Add Room") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + if (state.isLoading) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + } + state.error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + if (!state.isLoading && state.error == null) { + if (state.rooms.isEmpty()) { + Text(text = "No rooms found") + } else { + state.rooms.forEach { room -> + val label = room.roomNumber?.toString() ?: "-" + val details = listOfNotNull( + room.floor?.let { "Floor $it" }, + room.roomTypeName ?: room.roomTypeCode + ).joinToString(" • ") + val isDimmed = (room.active == false) || (room.maintenance == true) + val alpha = if (isDimmed) 0.5f else 1f + Column( + modifier = Modifier + .fillMaxWidth() + .alpha(alpha) + .clickable(enabled = room.id != null) { + onEditRoom(room) + } + .padding(vertical = 10.dp) + ) { + Text(text = label, style = MaterialTheme.typography.titleMedium) + if (details.isNotBlank()) { + Text(text = details, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt new file mode 100644 index 0000000..6a9f802 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt @@ -0,0 +1,104 @@ +package com.android.trisolarispms.ui.roomstay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.MeetingRoom +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ActiveRoomStaysScreen( + propertyId: String, + propertyName: String, + onBack: () -> Unit, + onViewRooms: () -> Unit, + viewModel: ActiveRoomStaysViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(propertyId) { + viewModel.load(propertyId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(propertyName) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onViewRooms) { + Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(8.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 && state.error == null) { + if (state.items.isEmpty()) { + Text(text = "No active room stays") + } else { + state.items.forEach { item -> + val roomLine = "Room ${item.roomNumber ?: "-"} • ${item.roomTypeName ?: ""}".trim() + Text(text = roomLine, style = MaterialTheme.typography.titleMedium) + val guestLine = listOfNotNull(item.guestName, item.guestPhone).joinToString(" • ") + if (guestLine.isNotBlank()) { + Text(text = guestLine, style = MaterialTheme.typography.bodyMedium) + } + val timeLine = listOfNotNull(item.fromAt, item.expectedCheckoutAt).joinToString(" → ") + if (timeLine.isNotBlank()) { + Text(text = timeLine, style = MaterialTheme.typography.bodySmall) + } + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysState.kt new file mode 100644 index 0000000..a7b7e8a --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysState.kt @@ -0,0 +1,9 @@ +package com.android.trisolarispms.ui.roomstay + +import com.android.trisolarispms.data.api.model.ActiveRoomStayDto + +data class ActiveRoomStaysState( + val isLoading: Boolean = false, + val error: String? = null, + val items: List = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt new file mode 100644 index 0000000..03dd054 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysViewModel.kt @@ -0,0 +1,38 @@ +package com.android.trisolarispms.ui.roomstay + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ActiveRoomStaysViewModel : ViewModel() { + private val _state = MutableStateFlow(ActiveRoomStaysState()) + val state: StateFlow = _state + + fun load(propertyId: String) { + if (propertyId.isBlank()) return + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.listActiveRoomStays(propertyId) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + items = response.body().orEmpty(), + error = null + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt new file mode 100644 index 0000000..91b61ef --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt @@ -0,0 +1,114 @@ +package com.android.trisolarispms.ui.roomtype + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun AddRoomTypeScreen( + propertyId: String, + onBack: () -> Unit, + onSave: () -> Unit, + viewModel: RoomTypeFormViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Room Type") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { viewModel.submit(propertyId, onSave) }) { + Icon(Icons.Default.Done, contentDescription = "Save") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + OutlinedTextField( + value = state.code, + onValueChange = viewModel::onCodeChange, + label = { Text("Code") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.name, + onValueChange = viewModel::onNameChange, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.baseOccupancy, + onValueChange = viewModel::onBaseOccupancyChange, + label = { Text("Base Occupancy") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.maxOccupancy, + onValueChange = viewModel::onMaxOccupancyChange, + label = { Text("Max Occupancy") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.otaAliases, + onValueChange = viewModel::onAliasesChange, + label = { Text("OTA Aliases (comma separated)") }, + modifier = Modifier.fillMaxWidth() + ) + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt new file mode 100644 index 0000000..cab0f00 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt @@ -0,0 +1,12 @@ +package com.android.trisolarispms.ui.roomtype + +data class RoomTypeFormState( + val code: String = "", + val name: String = "", + val baseOccupancy: String = "", + val maxOccupancy: String = "", + val otaAliases: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val success: Boolean = false +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt new file mode 100644 index 0000000..4516d99 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt @@ -0,0 +1,52 @@ +package com.android.trisolarispms.ui.roomtype + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import com.android.trisolarispms.data.api.model.RoomTypeCreateRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class RoomTypeFormViewModel : ViewModel() { + private val _state = MutableStateFlow(RoomTypeFormState()) + val state: StateFlow = _state + + fun onCodeChange(value: String) = _state.update { it.copy(code = value.trim().uppercase(), error = null) } + fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) } + fun onBaseOccupancyChange(value: String) = _state.update { it.copy(baseOccupancy = value, error = null) } + fun onMaxOccupancyChange(value: String) = _state.update { it.copy(maxOccupancy = value, error = null) } + fun onAliasesChange(value: String) = _state.update { it.copy(otaAliases = value, error = null) } + + fun submit(propertyId: String, onDone: () -> Unit) { + val code = state.value.code.trim() + val name = state.value.name.trim() + if (code.isBlank() || name.isBlank()) { + _state.update { it.copy(error = "Code and name are required") } + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val body = RoomTypeCreateRequest( + code = code, + name = name, + baseOccupancy = state.value.baseOccupancy.toIntOrNull(), + maxOccupancy = state.value.maxOccupancy.toIntOrNull(), + otaAliases = state.value.otaAliases.split(',').map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null } + ) + val response = api.createRoomType(propertyId, body) + if (response.isSuccessful) { + _state.update { it.copy(isLoading = false, success = true) } + onDone() + } else { + _state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt new file mode 100644 index 0000000..0380870 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt @@ -0,0 +1,9 @@ +package com.android.trisolarispms.ui.roomtype + +import com.android.trisolarispms.data.api.model.RoomTypeDto + +data class RoomTypeListState( + val isLoading: Boolean = false, + val error: String? = null, + val items: List = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt new file mode 100644 index 0000000..46f2b4e --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt @@ -0,0 +1,38 @@ +package com.android.trisolarispms.ui.roomtype + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.trisolarispms.data.api.ApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class RoomTypeListViewModel : ViewModel() { + private val _state = MutableStateFlow(RoomTypeListState()) + val state: StateFlow = _state + + fun load(propertyId: String) { + if (propertyId.isBlank()) return + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + try { + val api = ApiClient.create() + val response = api.listRoomTypes(propertyId) + if (response.isSuccessful) { + _state.update { + it.copy( + isLoading = false, + items = response.body().orEmpty(), + error = null + ) + } + } else { + _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } + } + } catch (e: Exception) { + _state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt new file mode 100644 index 0000000..99313dd --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt @@ -0,0 +1,89 @@ +package com.android.trisolarispms.ui.roomtype + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RoomTypesScreen( + propertyId: String, + onBack: () -> Unit, + onAdd: () -> Unit, + viewModel: RoomTypeListViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(propertyId) { + viewModel.load(propertyId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Room Types") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onAdd) { + Icon(Icons.Default.Add, contentDescription = "Add Room Type") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + verticalArrangement = Arrangement.Top + ) { + if (state.isLoading) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + } + state.error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + if (!state.isLoading && state.error == null) { + if (state.items.isEmpty()) { + Text(text = "No room types") + } else { + state.items.forEach { item -> + Text(text = "${item.code} • ${item.name}", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 645f9c0..e081be8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Trisolaris PMS - \ No newline at end of file + AIzaSyAMuRNFWjccKSmPeR0loQI8etHMDtUIZ_k + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 307b276..1ab3f7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ coroutinesPlayServices = "1.10.2" googleServices = "4.4.4" lifecycleViewModelCompose = "2.10.0" firebaseAuthKtx = "24.0.1" +vectordrawable = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -33,6 +34,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -40,6 +42,8 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-auth-ktx = { module = "com.google.firebase:firebase-auth" } kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" } +androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" } +androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }