Implement property, rooms, and room types flows
This commit is contained in:
@@ -47,6 +47,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
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.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.retrofit)
|
implementation(libs.retrofit)
|
||||||
implementation(libs.retrofit.converter.gson)
|
implementation(libs.retrofit.converter.gson)
|
||||||
|
|||||||
@@ -6,12 +6,21 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.android.trisolarispms.ui.AppRoute
|
||||||
import com.android.trisolarispms.ui.auth.AuthScreen
|
import com.android.trisolarispms.ui.auth.AuthScreen
|
||||||
import com.android.trisolarispms.ui.auth.AuthViewModel
|
import com.android.trisolarispms.ui.auth.AuthViewModel
|
||||||
import com.android.trisolarispms.ui.auth.NameScreen
|
import com.android.trisolarispms.ui.auth.NameScreen
|
||||||
import com.android.trisolarispms.ui.auth.UnauthorizedScreen
|
import com.android.trisolarispms.ui.auth.UnauthorizedScreen
|
||||||
import com.android.trisolarispms.ui.home.HomeScreen
|
import com.android.trisolarispms.ui.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
|
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -31,7 +40,91 @@ class MainActivity : ComponentActivity() {
|
|||||||
} else if (state.apiVerified && state.needsName) {
|
} else if (state.apiVerified && state.needsName) {
|
||||||
NameScreen(viewModel = authViewModel)
|
NameScreen(viewModel = authViewModel)
|
||||||
} else if (state.apiVerified) {
|
} else if (state.apiVerified) {
|
||||||
HomeScreen(userId = state.userId, userName = state.userName)
|
val route = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
|
||||||
|
val refreshKey = remember { mutableStateOf(0) }
|
||||||
|
val selectedPropertyId = remember { mutableStateOf<String?>(null) }
|
||||||
|
val selectedPropertyName = remember { mutableStateOf<String?>(null) }
|
||||||
|
val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
|
||||||
|
val 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 {
|
} else {
|
||||||
AuthScreen(viewModel = authViewModel)
|
AuthScreen(viewModel = authViewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.AuthVerifyResponse
|
||||||
import com.android.trisolarispms.data.api.model.AuthMeUpdateRequest
|
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 com.android.trisolarispms.data.api.model.UserDto
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
@@ -18,5 +17,5 @@ interface AuthApi {
|
|||||||
suspend fun me(): Response<UserDto>
|
suspend fun me(): Response<UserDto>
|
||||||
|
|
||||||
@PUT("auth/me")
|
@PUT("auth/me")
|
||||||
suspend fun updateMe(@Body body: AuthMeUpdateRequest): Response<AppUserDto>
|
suspend fun updateMe(@Body body: AuthMeUpdateRequest): Response<UserDto>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.ActionResponse
|
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.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.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
@@ -16,20 +17,20 @@ interface CardApi {
|
|||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("roomStayId") roomStayId: String,
|
@Path("roomStayId") roomStayId: String,
|
||||||
@Body body: CardPrepareRequest
|
@Body body: CardPrepareRequest
|
||||||
): Response<CardDto>
|
): Response<CardPrepareResponse>
|
||||||
|
|
||||||
@POST("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
@POST("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
||||||
suspend fun issueCard(
|
suspend fun issueCard(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("roomStayId") roomStayId: String,
|
@Path("roomStayId") roomStayId: String,
|
||||||
@Body body: CardIssueRequest
|
@Body body: IssueCardRequest
|
||||||
): Response<CardDto>
|
): Response<IssuedCardResponse>
|
||||||
|
|
||||||
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
||||||
suspend fun listCards(
|
suspend fun listCards(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("roomStayId") roomStayId: String
|
@Path("roomStayId") roomStayId: String
|
||||||
): Response<List<CardDto>>
|
): Response<List<IssuedCardResponse>>
|
||||||
|
|
||||||
@POST("properties/{propertyId}/room-stays/cards/{cardId}/revoke")
|
@POST("properties/{propertyId}/room-stays/cards/{cardId}/revoke")
|
||||||
suspend fun revokeCard(
|
suspend fun revokeCard(
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ interface GuestDocumentApi {
|
|||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("guestId") guestId: String,
|
@Path("guestId") guestId: String,
|
||||||
@Part file: MultipartBody.Part,
|
@Part file: MultipartBody.Part,
|
||||||
@Part("docType") docType: RequestBody,
|
|
||||||
@Part("bookingId") bookingId: RequestBody
|
@Part("bookingId") bookingId: RequestBody
|
||||||
): Response<GuestDocumentDto>
|
): Response<GuestDocumentDto>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.android.trisolarispms.data.api
|
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.RoomBoardDto
|
||||||
import com.android.trisolarispms.data.api.model.RoomCreateRequest
|
import com.android.trisolarispms.data.api.model.RoomCreateRequest
|
||||||
import com.android.trisolarispms.data.api.model.RoomDto
|
import com.android.trisolarispms.data.api.model.RoomDto
|
||||||
@@ -40,12 +41,12 @@ interface RoomApi {
|
|||||||
suspend fun streamRoomBoard(@Path("propertyId") propertyId: String): Response<ResponseBody>
|
suspend fun streamRoomBoard(@Path("propertyId") propertyId: String): Response<ResponseBody>
|
||||||
|
|
||||||
@GET("properties/{propertyId}/rooms/availability")
|
@GET("properties/{propertyId}/rooms/availability")
|
||||||
suspend fun getRoomAvailability(@Path("propertyId") propertyId: String): Response<AvailabilityResponse>
|
suspend fun getRoomAvailability(@Path("propertyId") propertyId: String): Response<List<RoomAvailabilityResponse>>
|
||||||
|
|
||||||
@GET("properties/{propertyId}/rooms/availability-range")
|
@GET("properties/{propertyId}/rooms/availability-range")
|
||||||
suspend fun getRoomAvailabilityRange(
|
suspend fun getRoomAvailabilityRange(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Query("from") from: String,
|
@Query("from") from: String,
|
||||||
@Query("to") to: String
|
@Query("to") to: String
|
||||||
): Response<AvailabilityResponse>
|
): Response<List<RoomAvailabilityRangeResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.android.trisolarispms.data.api
|
package com.android.trisolarispms.data.api
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.RoomChangeRequest
|
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.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
@@ -13,5 +15,8 @@ interface RoomStayApi {
|
|||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
@Path("roomStayId") roomStayId: String,
|
@Path("roomStayId") roomStayId: String,
|
||||||
@Body body: RoomChangeRequest
|
@Body body: RoomChangeRequest
|
||||||
): Response<RoomStayDto>
|
): Response<RoomChangeResponse>
|
||||||
|
|
||||||
|
@GET("properties/{propertyId}/room-stays/active")
|
||||||
|
suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response<List<ActiveRoomStayDto>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,10 @@ package com.android.trisolarispms.data.api.model
|
|||||||
|
|
||||||
data class AuthVerifyResponse(
|
data class AuthVerifyResponse(
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val user: AppUserDto? = null,
|
val user: UserDto? = null,
|
||||||
val properties: List<PropertyUserDto>? = null
|
val properties: List<PropertyUserDto>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AuthMeUpdateRequest(
|
data class AuthMeUpdateRequest(
|
||||||
val name: String
|
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<String>? = null
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ data class BookingCheckInRequest(
|
|||||||
val checkInAt: String? = null,
|
val checkInAt: String? = null,
|
||||||
val transportMode: String? = null,
|
val transportMode: String? = null,
|
||||||
val transportVehicleNumber: String? = null,
|
val transportVehicleNumber: String? = null,
|
||||||
val notes: 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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BookingCheckOutRequest(
|
data class BookingCheckOutRequest(
|
||||||
@@ -56,3 +51,11 @@ data class RoomChangeRequest(
|
|||||||
val movedAt: String? = null,
|
val movedAt: String? = null,
|
||||||
val idempotencyKey: String
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,15 +4,30 @@ data class CardPrepareRequest(
|
|||||||
val expiresAt: String? = null
|
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 cardId: String,
|
||||||
val cardIndex: Int,
|
val cardIndex: Int,
|
||||||
val issuedAt: String? = null,
|
val issuedAt: String? = null,
|
||||||
val expiresAt: String
|
val expiresAt: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CardDto(
|
data class IssuedCardResponse(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val cardNumber: String? = null,
|
val propertyId: String? = null,
|
||||||
val status: 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package com.android.trisolarispms.data.api.model
|
|||||||
data class GuestDto(
|
data class GuestDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val phone: String? = null,
|
val phoneE164: String? = null,
|
||||||
val email: String? = null
|
val nationality: String? = null,
|
||||||
|
val addressText: String? = null,
|
||||||
|
val vehicleNumbers: List<String> = emptyList(),
|
||||||
|
val averageScore: Double? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GuestVehicleRequest(
|
data class GuestVehicleRequest(
|
||||||
@@ -24,14 +27,25 @@ data class GuestRatingRequest(
|
|||||||
|
|
||||||
data class GuestRatingDto(
|
data class GuestRatingDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
|
val propertyId: String? = null,
|
||||||
|
val guestId: String? = null,
|
||||||
|
val bookingId: String? = null,
|
||||||
val score: String? = null,
|
val score: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val createdAt: String? = null
|
val createdAt: String? = null,
|
||||||
|
val createdByUserId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GuestDocumentDto(
|
data class GuestDocumentDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val type: String? = null,
|
val propertyId: String? = null,
|
||||||
val fileName: String? = null,
|
val guestId: String? = null,
|
||||||
val createdAt: 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<String, String>? = null,
|
||||||
|
val extractedAt: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ data class PropertyCreateRequest(
|
|||||||
val addressText: String? = null,
|
val addressText: String? = null,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
val currency: String? = null,
|
val currency: String? = null,
|
||||||
|
val active: Boolean? = null,
|
||||||
val emailAddresses: List<String>? = null,
|
val emailAddresses: List<String>? = null,
|
||||||
val otaAliases: List<String>? = null,
|
val otaAliases: List<String>? = null,
|
||||||
val allowedTransportModes: List<String>? = null
|
val allowedTransportModes: List<String>? = null
|
||||||
@@ -31,7 +32,7 @@ data class PropertyDto(
|
|||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
val currency: String? = null,
|
val currency: String? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val emailAddresses: List<String>? = null,
|
|
||||||
val otaAliases: List<String>? = null,
|
val otaAliases: List<String>? = null,
|
||||||
|
val emailAddresses: List<String>? = null,
|
||||||
val allowedTransportModes: List<String>? = null
|
val allowedTransportModes: List<String>? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<String>? = null
|
||||||
|
)
|
||||||
@@ -1,30 +1,31 @@
|
|||||||
package com.android.trisolarispms.data.api.model
|
package com.android.trisolarispms.data.api.model
|
||||||
|
|
||||||
data class RoomCreateRequest(
|
data class RoomCreateRequest(
|
||||||
val roomNumber: String,
|
val roomNumber: Int,
|
||||||
val floor: String? = null,
|
val floor: Int? = null,
|
||||||
val roomTypeId: String,
|
val roomTypeCode: String,
|
||||||
val hasNfc: Boolean? = null,
|
val hasNfc: Boolean,
|
||||||
val active: Boolean? = null,
|
val active: Boolean,
|
||||||
val maintenance: Boolean? = null,
|
val maintenance: Boolean,
|
||||||
val notes: String? = null
|
val notes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomUpdateRequest(
|
data class RoomUpdateRequest(
|
||||||
val roomNumber: String,
|
val roomNumber: Int,
|
||||||
val floor: String? = null,
|
val floor: Int? = null,
|
||||||
val roomTypeId: String,
|
val roomTypeCode: String,
|
||||||
val hasNfc: Boolean? = null,
|
val hasNfc: Boolean,
|
||||||
val active: Boolean? = null,
|
val active: Boolean,
|
||||||
val maintenance: Boolean? = null,
|
val maintenance: Boolean,
|
||||||
val notes: String? = null
|
val notes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomDto(
|
data class RoomDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val roomNumber: String? = null,
|
val roomNumber: Int? = null,
|
||||||
val roomTypeId: String? = null,
|
val roomTypeCode: String? = null,
|
||||||
val floor: String? = null,
|
val roomTypeName: String? = null,
|
||||||
|
val floor: Int? = null,
|
||||||
val hasNfc: Boolean? = null,
|
val hasNfc: Boolean? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val maintenance: Boolean? = null,
|
val maintenance: Boolean? = null,
|
||||||
@@ -32,31 +33,31 @@ data class RoomDto(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class RoomBoardDto(
|
data class RoomBoardDto(
|
||||||
val items: List<RoomBoardItemDto> = emptyList()
|
val roomNumber: Int? = null,
|
||||||
|
val roomTypeName: String? = null,
|
||||||
|
val status: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomBoardItemDto(
|
data class RoomAvailabilityResponse(
|
||||||
val roomId: String? = null,
|
val roomTypeName: String? = null,
|
||||||
val status: String? = null,
|
val freeRoomNumbers: List<Int> = emptyList()
|
||||||
val roomStayId: String? = null,
|
|
||||||
val guestName: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AvailabilityResponse(
|
data class RoomAvailabilityRangeResponse(
|
||||||
val rooms: List<RoomAvailabilityDto> = emptyList()
|
val roomTypeName: String? = null,
|
||||||
)
|
val freeRoomNumbers: List<Int> = emptyList(),
|
||||||
|
val freeCount: Int? = null
|
||||||
data class RoomAvailabilityDto(
|
|
||||||
val roomId: String? = null,
|
|
||||||
val date: String? = null,
|
|
||||||
val available: Boolean? = null,
|
|
||||||
val rate: Double? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
|
|
||||||
data class ImageDto(
|
data class ImageDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
|
val propertyId: String? = null,
|
||||||
|
val roomId: String? = null,
|
||||||
val url: 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -3,24 +3,25 @@ package com.android.trisolarispms.data.api.model
|
|||||||
data class RoomTypeCreateRequest(
|
data class RoomTypeCreateRequest(
|
||||||
val code: String,
|
val code: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val maxAdults: Int? = null,
|
val baseOccupancy: Int? = null,
|
||||||
val maxChildren: Int? = null,
|
val maxOccupancy: Int? = null,
|
||||||
val otaAliases: List<String>? = null
|
val otaAliases: List<String>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomTypeUpdateRequest(
|
data class RoomTypeUpdateRequest(
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val maxAdults: Int? = null,
|
val baseOccupancy: Int? = null,
|
||||||
val maxChildren: Int? = null,
|
val maxOccupancy: Int? = null,
|
||||||
val otaAliases: List<String>? = null
|
val otaAliases: List<String>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RoomTypeDto(
|
data class RoomTypeDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
|
val propertyId: String? = null,
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val maxAdults: Int? = null,
|
val baseOccupancy: Int? = null,
|
||||||
val maxChildren: Int? = null,
|
val maxOccupancy: Int? = null,
|
||||||
val otaAliases: List<String>? = null
|
val otaAliases: List<String>? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.android.trisolarispms.data.api.model
|
package com.android.trisolarispms.data.api.model
|
||||||
|
|
||||||
data class TransportModeDto(
|
data class TransportModeDto(
|
||||||
val id: String? = null,
|
val mode: String? = null,
|
||||||
val name: String? = null
|
val enabled: Boolean? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.android.trisolarispms.data.api.model
|
|||||||
|
|
||||||
data class UserDto(
|
data class UserDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
|
val firebaseUid: String? = null,
|
||||||
|
val phoneE164: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val email: String? = null,
|
val disabled: Boolean? = null,
|
||||||
val phone: String? = null,
|
val superAdmin: Boolean? = null
|
||||||
val roles: List<String>? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UserRolesUpdateRequest(
|
data class UserRolesUpdateRequest(
|
||||||
|
|||||||
@@ -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<PlacePrediction> = 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<Suggestion>? = 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
|
||||||
|
)
|
||||||
12
app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt
Normal file
12
app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,137 @@
|
|||||||
package com.android.trisolarispms.ui.home
|
package com.android.trisolarispms.ui.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.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.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.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.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.android.trisolarispms.ui.property.PropertyListViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(userId: String?, userName: String?) {
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
Column(
|
fun HomeScreen(
|
||||||
modifier = Modifier
|
userId: String?,
|
||||||
.fillMaxSize()
|
userName: String?,
|
||||||
.padding(24.dp),
|
isSuperAdmin: Boolean,
|
||||||
verticalArrangement = Arrangement.Center
|
onAddProperty: () -> Unit,
|
||||||
) {
|
refreshKey: Int,
|
||||||
val title = if (!userName.isNullOrBlank()) "Welcome, $userName" else "Welcome"
|
selectedPropertyId: String?,
|
||||||
Text(text = title, style = MaterialTheme.typography.headlineMedium)
|
onSelectProperty: (String, String) -> Unit,
|
||||||
if (userId != null) {
|
onRefreshProfile: () -> Unit,
|
||||||
Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall)
|
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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Job?>(null) }
|
||||||
|
var predictions by remember { mutableStateOf<List<PlacePrediction>>(emptyList()) }
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var isSearching by remember { mutableStateOf(false) }
|
||||||
|
var placesError by remember { mutableStateOf<String?>(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<AddPropertyState> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PropertyDto> = emptyList()
|
||||||
|
)
|
||||||
@@ -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<PropertyListState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<RoomFormState> = _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<com.android.trisolarispms.data.api.model.RoomTypeDto>) {
|
||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RoomDto> = emptyList()
|
||||||
|
)
|
||||||
@@ -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<RoomListState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ActiveRoomStayDto> = emptyList()
|
||||||
|
)
|
||||||
@@ -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<ActiveRoomStaysState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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<RoomTypeFormState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RoomTypeDto> = emptyList()
|
||||||
|
)
|
||||||
@@ -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<RoomTypeListState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Trisolaris PMS</string>
|
<string name="app_name">Trisolaris PMS</string>
|
||||||
|
<string name="google_maps_key">AIzaSyAMuRNFWjccKSmPeR0loQI8etHMDtUIZ_k</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -16,6 +16,7 @@ coroutinesPlayServices = "1.10.2"
|
|||||||
googleServices = "4.4.4"
|
googleServices = "4.4.4"
|
||||||
lifecycleViewModelCompose = "2.10.0"
|
lifecycleViewModelCompose = "2.10.0"
|
||||||
firebaseAuthKtx = "24.0.1"
|
firebaseAuthKtx = "24.0.1"
|
||||||
|
vectordrawable = "1.2.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", 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" }
|
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-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||||
firebase-auth-ktx = { module = "com.google.firebase:firebase-auth" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user