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.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.vectordrawable)
|
||||
implementation(libs.androidx.vectordrawable.animated)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.converter.gson)
|
||||
|
||||
@@ -6,12 +6,21 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.AppRoute
|
||||
import com.android.trisolarispms.ui.auth.AuthScreen
|
||||
import com.android.trisolarispms.ui.auth.AuthViewModel
|
||||
import com.android.trisolarispms.ui.auth.NameScreen
|
||||
import com.android.trisolarispms.ui.auth.UnauthorizedScreen
|
||||
import com.android.trisolarispms.ui.home.HomeScreen
|
||||
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
||||
import com.android.trisolarispms.ui.room.RoomFormScreen
|
||||
import com.android.trisolarispms.ui.room.RoomsScreen
|
||||
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
|
||||
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
|
||||
import com.android.trisolarispms.ui.roomtype.RoomTypesScreen
|
||||
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -31,7 +40,91 @@ class MainActivity : ComponentActivity() {
|
||||
} else if (state.apiVerified && state.needsName) {
|
||||
NameScreen(viewModel = authViewModel)
|
||||
} else if (state.apiVerified) {
|
||||
HomeScreen(userId = state.userId, userName = state.userName)
|
||||
val route = remember { mutableStateOf<AppRoute>(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 {
|
||||
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.AuthMeUpdateRequest
|
||||
import com.android.trisolarispms.data.api.model.AppUserDto
|
||||
import com.android.trisolarispms.data.api.model.UserDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
@@ -18,5 +17,5 @@ interface AuthApi {
|
||||
suspend fun me(): Response<UserDto>
|
||||
|
||||
@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
|
||||
|
||||
import com.android.trisolarispms.data.api.model.ActionResponse
|
||||
import com.android.trisolarispms.data.api.model.CardDto
|
||||
import com.android.trisolarispms.data.api.model.CardIssueRequest
|
||||
import com.android.trisolarispms.data.api.model.CardPrepareRequest
|
||||
import com.android.trisolarispms.data.api.model.CardPrepareResponse
|
||||
import com.android.trisolarispms.data.api.model.IssueCardRequest
|
||||
import com.android.trisolarispms.data.api.model.IssuedCardResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
@@ -16,20 +17,20 @@ interface CardApi {
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("roomStayId") roomStayId: String,
|
||||
@Body body: CardPrepareRequest
|
||||
): Response<CardDto>
|
||||
): Response<CardPrepareResponse>
|
||||
|
||||
@POST("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
||||
suspend fun issueCard(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("roomStayId") roomStayId: String,
|
||||
@Body body: CardIssueRequest
|
||||
): Response<CardDto>
|
||||
@Body body: IssueCardRequest
|
||||
): Response<IssuedCardResponse>
|
||||
|
||||
@GET("properties/{propertyId}/room-stays/{roomStayId}/cards")
|
||||
suspend fun listCards(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("roomStayId") roomStayId: String
|
||||
): Response<List<CardDto>>
|
||||
): Response<List<IssuedCardResponse>>
|
||||
|
||||
@POST("properties/{propertyId}/room-stays/cards/{cardId}/revoke")
|
||||
suspend fun revokeCard(
|
||||
|
||||
@@ -19,7 +19,6 @@ interface GuestDocumentApi {
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("guestId") guestId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("docType") docType: RequestBody,
|
||||
@Part("bookingId") bookingId: RequestBody
|
||||
): Response<GuestDocumentDto>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.android.trisolarispms.data.api
|
||||
|
||||
import com.android.trisolarispms.data.api.model.AvailabilityResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomBoardDto
|
||||
import com.android.trisolarispms.data.api.model.RoomCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.RoomDto
|
||||
@@ -40,12 +41,12 @@ interface RoomApi {
|
||||
suspend fun streamRoomBoard(@Path("propertyId") propertyId: String): Response<ResponseBody>
|
||||
|
||||
@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")
|
||||
suspend fun getRoomAvailabilityRange(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String
|
||||
): Response<AvailabilityResponse>
|
||||
): Response<List<RoomAvailabilityRangeResponse>>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.android.trisolarispms.data.api
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RoomChangeRequest
|
||||
import com.android.trisolarispms.data.api.model.RoomStayDto
|
||||
import com.android.trisolarispms.data.api.model.RoomChangeResponse
|
||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
@@ -13,5 +15,8 @@ interface RoomStayApi {
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("roomStayId") roomStayId: String,
|
||||
@Body body: RoomChangeRequest
|
||||
): Response<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(
|
||||
val status: String? = null,
|
||||
val user: AppUserDto? = null,
|
||||
val user: UserDto? = null,
|
||||
val properties: List<PropertyUserDto>? = null
|
||||
)
|
||||
|
||||
data class AuthMeUpdateRequest(
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class AppUserDto(
|
||||
val id: String? = null,
|
||||
val firebaseUid: String? = null,
|
||||
val phoneE164: String? = null,
|
||||
val name: String? = null,
|
||||
val disabled: Boolean? = null,
|
||||
val superAdmin: Boolean? = null
|
||||
)
|
||||
|
||||
data class PropertyUserDto(
|
||||
val userId: String? = null,
|
||||
val propertyId: String? = null,
|
||||
val roles: List<String>? = null
|
||||
val name: String?
|
||||
)
|
||||
|
||||
@@ -5,12 +5,7 @@ data class BookingCheckInRequest(
|
||||
val checkInAt: String? = null,
|
||||
val transportMode: String? = null,
|
||||
val transportVehicleNumber: String? = null,
|
||||
val notes: String? = null,
|
||||
val adultCount: Int? = null,
|
||||
val childCount: Int? = null,
|
||||
val maleCount: Int? = null,
|
||||
val femaleCount: Int? = null,
|
||||
val totalGuestCount: Int? = null
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class BookingCheckOutRequest(
|
||||
@@ -56,3 +51,11 @@ data class RoomChangeRequest(
|
||||
val movedAt: String? = null,
|
||||
val idempotencyKey: String
|
||||
)
|
||||
|
||||
data class RoomChangeResponse(
|
||||
val oldRoomStayId: String? = null,
|
||||
val newRoomStayId: String? = null,
|
||||
val oldRoomId: String? = null,
|
||||
val newRoomId: String? = null,
|
||||
val movedAt: String? = null
|
||||
)
|
||||
|
||||
@@ -4,15 +4,30 @@ data class CardPrepareRequest(
|
||||
val expiresAt: String? = null
|
||||
)
|
||||
|
||||
data class CardIssueRequest(
|
||||
data class CardPrepareResponse(
|
||||
val cardIndex: Int? = null,
|
||||
val key: String? = null,
|
||||
val timeData: String? = null,
|
||||
val issuedAt: String? = null,
|
||||
val expiresAt: String? = null
|
||||
)
|
||||
|
||||
data class IssueCardRequest(
|
||||
val cardId: String,
|
||||
val cardIndex: Int,
|
||||
val issuedAt: String? = null,
|
||||
val expiresAt: String
|
||||
)
|
||||
|
||||
data class CardDto(
|
||||
data class IssuedCardResponse(
|
||||
val id: String? = null,
|
||||
val cardNumber: String? = null,
|
||||
val status: String? = null
|
||||
val propertyId: String? = null,
|
||||
val roomId: String? = null,
|
||||
val roomStayId: String? = null,
|
||||
val cardId: String? = null,
|
||||
val cardIndex: Int? = null,
|
||||
val issuedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
val issuedByUserId: String? = null,
|
||||
val revokedAt: String? = null
|
||||
)
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.android.trisolarispms.data.api.model
|
||||
data class GuestDto(
|
||||
val id: String? = null,
|
||||
val name: String? = null,
|
||||
val phone: String? = null,
|
||||
val email: String? = null
|
||||
val phoneE164: String? = null,
|
||||
val nationality: String? = null,
|
||||
val addressText: String? = null,
|
||||
val vehicleNumbers: List<String> = emptyList(),
|
||||
val averageScore: Double? = null
|
||||
)
|
||||
|
||||
data class GuestVehicleRequest(
|
||||
@@ -24,14 +27,25 @@ data class GuestRatingRequest(
|
||||
|
||||
data class GuestRatingDto(
|
||||
val id: String? = null,
|
||||
val propertyId: String? = null,
|
||||
val guestId: String? = null,
|
||||
val bookingId: String? = null,
|
||||
val score: String? = null,
|
||||
val notes: String? = null,
|
||||
val createdAt: String? = null
|
||||
val createdAt: String? = null,
|
||||
val createdByUserId: String? = null
|
||||
)
|
||||
|
||||
data class GuestDocumentDto(
|
||||
val id: String? = null,
|
||||
val type: String? = null,
|
||||
val fileName: String? = null,
|
||||
val createdAt: String? = null
|
||||
val propertyId: String? = null,
|
||||
val guestId: String? = null,
|
||||
val bookingId: String? = null,
|
||||
val uploadedByUserId: String? = null,
|
||||
val uploadedAt: String? = null,
|
||||
val originalFilename: String? = null,
|
||||
val contentType: String? = null,
|
||||
val sizeBytes: Long? = null,
|
||||
val extractedData: Map<String, String>? = null,
|
||||
val extractedAt: String? = null
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ data class PropertyCreateRequest(
|
||||
val addressText: String? = null,
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val emailAddresses: List<String>? = null,
|
||||
val otaAliases: List<String>? = null,
|
||||
val allowedTransportModes: List<String>? = null
|
||||
@@ -31,7 +32,7 @@ data class PropertyDto(
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val emailAddresses: List<String>? = null,
|
||||
val otaAliases: List<String>? = null,
|
||||
val emailAddresses: 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
|
||||
|
||||
data class RoomCreateRequest(
|
||||
val roomNumber: String,
|
||||
val floor: String? = null,
|
||||
val roomTypeId: String,
|
||||
val hasNfc: Boolean? = null,
|
||||
val active: Boolean? = null,
|
||||
val maintenance: Boolean? = null,
|
||||
val roomNumber: Int,
|
||||
val floor: Int? = null,
|
||||
val roomTypeCode: String,
|
||||
val hasNfc: Boolean,
|
||||
val active: Boolean,
|
||||
val maintenance: Boolean,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class RoomUpdateRequest(
|
||||
val roomNumber: String,
|
||||
val floor: String? = null,
|
||||
val roomTypeId: String,
|
||||
val hasNfc: Boolean? = null,
|
||||
val active: Boolean? = null,
|
||||
val maintenance: Boolean? = null,
|
||||
val roomNumber: Int,
|
||||
val floor: Int? = null,
|
||||
val roomTypeCode: String,
|
||||
val hasNfc: Boolean,
|
||||
val active: Boolean,
|
||||
val maintenance: Boolean,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class RoomDto(
|
||||
val id: String? = null,
|
||||
val roomNumber: String? = null,
|
||||
val roomTypeId: String? = null,
|
||||
val floor: String? = null,
|
||||
val roomNumber: Int? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val roomTypeName: String? = null,
|
||||
val floor: Int? = null,
|
||||
val hasNfc: Boolean? = null,
|
||||
val active: Boolean? = null,
|
||||
val maintenance: Boolean? = null,
|
||||
@@ -32,31 +33,31 @@ data class RoomDto(
|
||||
)
|
||||
|
||||
data class RoomBoardDto(
|
||||
val items: List<RoomBoardItemDto> = emptyList()
|
||||
val roomNumber: Int? = null,
|
||||
val roomTypeName: String? = null,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
data class RoomBoardItemDto(
|
||||
val roomId: String? = null,
|
||||
val status: String? = null,
|
||||
val roomStayId: String? = null,
|
||||
val guestName: String? = null
|
||||
data class RoomAvailabilityResponse(
|
||||
val roomTypeName: String? = null,
|
||||
val freeRoomNumbers: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
data class AvailabilityResponse(
|
||||
val rooms: List<RoomAvailabilityDto> = emptyList()
|
||||
)
|
||||
|
||||
data class RoomAvailabilityDto(
|
||||
val roomId: String? = null,
|
||||
val date: String? = null,
|
||||
val available: Boolean? = null,
|
||||
val rate: Double? = null
|
||||
data class RoomAvailabilityRangeResponse(
|
||||
val roomTypeName: String? = null,
|
||||
val freeRoomNumbers: List<Int> = emptyList(),
|
||||
val freeCount: Int? = null
|
||||
)
|
||||
|
||||
// Images
|
||||
|
||||
data class ImageDto(
|
||||
val id: String? = null,
|
||||
val propertyId: String? = null,
|
||||
val roomId: String? = null,
|
||||
val url: String? = null,
|
||||
val fileName: String? = null
|
||||
val thumbnailUrl: String? = null,
|
||||
val contentType: String? = null,
|
||||
val sizeBytes: Long? = null,
|
||||
val createdAt: String? = null
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val maxAdults: Int? = null,
|
||||
val maxChildren: Int? = null,
|
||||
val baseOccupancy: Int? = null,
|
||||
val maxOccupancy: Int? = null,
|
||||
val otaAliases: List<String>? = null
|
||||
)
|
||||
|
||||
data class RoomTypeUpdateRequest(
|
||||
val code: String? = null,
|
||||
val name: String? = null,
|
||||
val maxAdults: Int? = null,
|
||||
val maxChildren: Int? = null,
|
||||
val baseOccupancy: Int? = null,
|
||||
val maxOccupancy: Int? = null,
|
||||
val otaAliases: List<String>? = null
|
||||
)
|
||||
|
||||
data class RoomTypeDto(
|
||||
val id: String? = null,
|
||||
val propertyId: String? = null,
|
||||
val code: String? = null,
|
||||
val name: String? = null,
|
||||
val maxAdults: Int? = null,
|
||||
val maxChildren: Int? = null,
|
||||
val baseOccupancy: Int? = null,
|
||||
val maxOccupancy: Int? = null,
|
||||
val otaAliases: List<String>? = null
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class TransportModeDto(
|
||||
val id: String? = null,
|
||||
val name: String? = null
|
||||
val mode: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class UserDto(
|
||||
val id: String? = null,
|
||||
val firebaseUid: String? = null,
|
||||
val phoneE164: String? = null,
|
||||
val name: String? = null,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val roles: List<String>? = null
|
||||
val disabled: Boolean? = null,
|
||||
val superAdmin: Boolean? = null
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.property.PropertyListViewModel
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(userId: String?, userName: String?) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun HomeScreen(
|
||||
userId: String?,
|
||||
userName: String?,
|
||||
isSuperAdmin: Boolean,
|
||||
onAddProperty: () -> Unit,
|
||||
refreshKey: Int,
|
||||
selectedPropertyId: String?,
|
||||
onSelectProperty: (String, String) -> Unit,
|
||||
onRefreshProfile: () -> Unit,
|
||||
viewModel: PropertyListViewModel = viewModel()
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(refreshKey) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
onRefreshProfile()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Trisolaris PMS") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(),
|
||||
actions = {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Add Property") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onAddProperty()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
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>
|
||||
<string name="app_name">Trisolaris PMS</string>
|
||||
<string name="google_maps_key">AIzaSyAMuRNFWjccKSmPeR0loQI8etHMDtUIZ_k</string>
|
||||
</resources>
|
||||
@@ -16,6 +16,7 @@ coroutinesPlayServices = "1.10.2"
|
||||
googleServices = "4.4.4"
|
||||
lifecycleViewModelCompose = "2.10.0"
|
||||
firebaseAuthKtx = "24.0.1"
|
||||
vectordrawable = "1.2.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -33,6 +34,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
@@ -40,6 +42,8 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-auth-ktx = { module = "com.google.firebase:firebase-auth" }
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
|
||||
androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
|
||||
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user