Implement property, rooms, and room types flows

This commit is contained in:
androidlover5842
2026-01-27 03:43:47 +05:30
parent 745a92e579
commit 6cebefc91f
45 changed files with 1992 additions and 103 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">Trisolaris PMS</string> <string name="app_name">Trisolaris PMS</string>
</resources> <string name="google_maps_key">AIzaSyAMuRNFWjccKSmPeR0loQI8etHMDtUIZ_k</string>
</resources>

View File

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