add basic booking flow

This commit is contained in:
androidlover5842
2026-01-29 08:48:04 +05:30
parent 726f07bff4
commit 8bd2c2eeae
14 changed files with 879 additions and 4 deletions

View File

@@ -15,6 +15,8 @@ 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.booking.BookingCreateScreen
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen
@@ -59,6 +61,8 @@ class MainActivity : ComponentActivity() {
val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(null) }
val selectedAmenity = remember { mutableStateOf<com.android.trisolarispms.data.api.model.AmenityDto?>(null) }
val selectedGuest = remember { mutableStateOf<com.android.trisolarispms.data.api.model.GuestDto?>(null) }
val selectedGuestPhone = remember { mutableStateOf<String?>(null) }
val selectedImageTag = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomImageTagDto?>(null) }
val roomFormKey = remember { mutableStateOf(0) }
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
@@ -102,6 +106,8 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId,
currentRoute.roomTypeId
)
is AppRoute.CreateBooking -> route.value = AppRoute.Home
is AppRoute.GuestInfo -> route.value = AppRoute.Home
}
}
@@ -132,11 +138,35 @@ class MainActivity : ComponentActivity() {
route.value = AppRoute.Home
}
)
is AppRoute.CreateBooking -> BookingCreateScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Home },
onCreated = { response, guest, phone ->
val bookingId = response.id.orEmpty()
val guestId = (guest?.id ?: response.guestId).orEmpty()
selectedGuest.value = guest
selectedGuestPhone.value = phone
if (bookingId.isNotBlank()) {
route.value = AppRoute.GuestInfo(currentRoute.propertyId, bookingId, guestId)
} else {
route.value = AppRoute.Home
}
}
)
is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId,
guestId = currentRoute.guestId,
initialGuest = selectedGuest.value,
initialPhone = selectedGuestPhone.value,
onBack = { route.value = AppRoute.Home },
onSave = { 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) }
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }
)
is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId,

View File

@@ -4,6 +4,9 @@ import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckInRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.RoomStayDto
@@ -13,6 +16,19 @@ import retrofit2.http.POST
import retrofit2.http.Path
interface BookingApi {
@POST("properties/{propertyId}/bookings")
suspend fun createBooking(
@Path("propertyId") propertyId: String,
@Body body: BookingCreateRequest
): Response<BookingCreateResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest
): Response<BookingCreateResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn(
@Path("propertyId") propertyId: String,

View File

@@ -1,18 +1,34 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.GuestDto
import com.android.trisolarispms.data.api.model.GuestCreateRequest
import com.android.trisolarispms.data.api.model.GuestRatingDto
import com.android.trisolarispms.data.api.model.GuestRatingRequest
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import com.android.trisolarispms.data.api.model.GuestVehicleDto
import com.android.trisolarispms.data.api.model.GuestVehicleRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface GuestApi {
@POST("properties/{propertyId}/guests")
suspend fun createGuest(
@Path("propertyId") propertyId: String,
@Body body: GuestCreateRequest
): Response<GuestDto>
@PUT("properties/{propertyId}/guests/{guestId}")
suspend fun updateGuest(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Body body: GuestUpdateRequest
): Response<GuestDto>
@GET("properties/{propertyId}/guests/search")
suspend fun searchGuests(
@Path("propertyId") propertyId: String,

View File

@@ -8,6 +8,29 @@ data class BookingCheckInRequest(
val notes: String? = null
)
data class BookingCreateRequest(
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val source: String? = null,
val transportMode: String? = null,
val adultCount: Int? = null,
val totalGuestCount: Int? = null,
val notes: String? = null
)
data class BookingCreateResponse(
val id: String? = null,
val status: String? = null,
val checkInAt: String? = null,
val guestId: String? = null,
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingLinkGuestRequest(
val guestId: String
)
data class BookingCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null

View File

@@ -10,6 +10,21 @@ data class GuestDto(
val averageScore: Double? = null
)
data class GuestCreateRequest(
val bookingId: String,
val phoneE164: String? = null,
val name: String? = null,
val nationality: String? = null,
val addressText: String? = null
)
data class GuestUpdateRequest(
val phoneE164: String? = null,
val name: String? = null,
val nationality: String? = null,
val addressText: String? = null
)
data class GuestVehicleRequest(
val vehicleNumber: String
)

View File

@@ -2,6 +2,8 @@ package com.android.trisolarispms.ui
sealed interface AppRoute {
data object Home : AppRoute
data class CreateBooking(val propertyId: String) : AppRoute
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: String) : AppRoute

View File

@@ -0,0 +1,434 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
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.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.android.trisolarispms.data.api.model.GuestDto
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.CalendarMonth
import com.kizitonwose.calendar.core.DayPosition
import com.kizitonwose.calendar.core.daysOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingCreateScreen(
propertyId: String,
onBack: () -> Unit,
onCreated: (com.android.trisolarispms.data.api.model.BookingCreateResponse, com.android.trisolarispms.data.api.model.GuestDto?, String?) -> Unit,
viewModel: BookingCreateViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val showCheckInPicker = remember { mutableStateOf(false) }
val showCheckOutPicker = remember { mutableStateOf(false) }
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val checkInNow = remember { mutableStateOf(true) }
val sourceMenuExpanded = remember { mutableStateOf(false) }
val sourceOptions = listOf("WALKIN", "OTA", "AGENT")
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
LaunchedEffect(propertyId) {
viewModel.reset()
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
checkInNow.value = true
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate
checkOutTime.value = "11:00"
viewModel.onExpectedCheckOutAtChange(formatIso(defaultCheckoutDate, checkOutTime.value))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Booking") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submit(propertyId, onCreated) }) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "Check in now")
Switch(
checked = checkInNow.value,
onCheckedChange = { enabled ->
checkInNow.value = enabled
if (enabled) {
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
} else {
viewModel.onExpectedCheckInAtChange("")
}
}
)
}
if (!checkInNow.value) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
trailingIcon = {
IconButton(onClick = { showCheckInPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.expectedCheckOutAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).format(displayFormatter) }.getOrDefault(it)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-out") },
trailingIcon = {
IconButton(onClick = { showCheckOutPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.phoneE164,
onValueChange = viewModel::onPhoneChange,
label = { Text("Guest Phone E164 (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = sourceMenuExpanded.value,
onExpandedChange = { sourceMenuExpanded.value = !sourceMenuExpanded.value }
) {
OutlinedTextField(
value = state.source,
onValueChange = {},
readOnly = true,
label = { Text("Source") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceMenuExpanded.value) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = sourceMenuExpanded.value,
onDismissRequest = { sourceMenuExpanded.value = false }
) {
sourceOptions.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
sourceMenuExpanded.value = false
viewModel.onSourceChange(option)
}
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.transportMode,
onValueChange = viewModel::onTransportModeChange,
label = { Text("Transport Mode (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.adultCount,
onValueChange = viewModel::onAdultCountChange,
label = { Text("Adult Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.totalGuestCount,
onValueChange = viewModel::onTotalGuestCountChange,
label = { Text("Total Guest Count (optional)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.notes,
onValueChange = viewModel::onNotesChange,
label = { Text("Notes (optional)") },
modifier = Modifier.fillMaxWidth()
)
if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
if (showCheckInPicker.value) {
DateTimePickerDialog(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false },
onConfirm = { date, time ->
checkInDate.value = date
checkInTime.value = time
val formatted = formatIso(date, time)
viewModel.onExpectedCheckInAtChange(formatted)
showCheckInPicker.value = false
}
)
}
if (showCheckOutPicker.value) {
DateTimePickerDialog(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false },
onConfirm = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
val formatted = formatIso(date, time)
viewModel.onExpectedCheckOutAtChange(formatted)
showCheckOutPicker.value = false
}
)
}
}
@Composable
private fun DateTimePickerDialog(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, String) -> Unit
) {
val today = remember { LocalDate.now() }
val currentMonth = remember { YearMonth.from(today) }
val startMonth = remember { currentMonth }
val endMonth = remember { currentMonth.plusMonths(24) }
val daysOfWeek = remember { daysOfWeek() }
val calendarState = rememberCalendarState(
startMonth = startMonth,
endMonth = endMonth,
firstVisibleMonth = currentMonth,
firstDayOfWeek = daysOfWeek.first()
)
val selectedDate = remember { mutableStateOf(initialDate ?: today) }
val timeValue = remember { mutableStateOf(initialTime) }
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
DaysOfWeekHeader(daysOfWeek)
HorizontalCalendar(
state = calendarState,
dayContent = { day ->
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
DayCell(
day = day,
isSelectedStart = selectedDate.value == day.date,
isSelectedEnd = false,
isInRange = false,
hasRate = false,
isSelectable = selectable,
onClick = { selectedDate.value = day.date }
)
},
monthHeader = { month ->
MonthHeader(month)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = timeValue.value,
onValueChange = { timeValue.value = it },
label = { Text("Time (HH:MM)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
val time = timeValue.value.ifBlank { initialTime }
onConfirm(selectedDate.value, time)
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun DaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
daysOfWeek.forEach { day ->
Text(
text = day.name.take(3),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
@Composable
private fun MonthHeader(month: CalendarMonth) {
Text(
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun DayCell(
day: CalendarDay,
isSelectedStart: Boolean,
isSelectedEnd: Boolean,
isInRange: Boolean,
hasRate: Boolean,
isSelectable: Boolean,
onClick: () -> Unit
) {
val isInMonth = day.position == DayPosition.MonthDate
val background = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
isInRange -> MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> Color.Transparent
}
val textColor = when {
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
!isSelectable -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
Column(
modifier = Modifier
.size(40.dp)
.padding(2.dp)
.background(background, shape = MaterialTheme.shapes.small)
.clickable(enabled = isSelectable) { onClick() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
}
}
private fun formatIso(date: LocalDate, time: String): String {
val parts = time.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
val zone = ZoneId.of("Asia/Kolkata")
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
val offset = zone.rules.getOffset(localDateTime)
return OffsetDateTime.of(localDateTime, offset)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}

View File

@@ -0,0 +1,14 @@
package com.android.trisolarispms.ui.booking
data class BookingCreateState(
val phoneE164: String = "",
val expectedCheckInAt: String = "",
val expectedCheckOutAt: String = "",
val source: String = "WALKIN",
val transportMode: String = "",
val adultCount: String = "",
val totalGuestCount: String = "",
val notes: String = "",
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -0,0 +1,114 @@
package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.GuestDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingCreateViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingCreateState())
val state: StateFlow<BookingCreateState> = _state
fun reset() {
_state.value = BookingCreateState()
}
fun onExpectedCheckInAtChange(value: String) {
_state.update { it.copy(expectedCheckInAt = value, error = null) }
}
fun onExpectedCheckOutAtChange(value: String) {
_state.update { it.copy(expectedCheckOutAt = value, error = null) }
}
fun onPhoneChange(value: String) {
_state.update { it.copy(phoneE164 = value, error = null) }
}
fun onSourceChange(value: String) {
_state.update { it.copy(source = value, error = null) }
}
fun onTransportModeChange(value: String) {
_state.update { it.copy(transportMode = value, error = null) }
}
fun onAdultCountChange(value: String) {
_state.update { it.copy(adultCount = value.filter { it.isDigit() }, error = null) }
}
fun onTotalGuestCountChange(value: String) {
_state.update { it.copy(totalGuestCount = value.filter { it.isDigit() }, error = null) }
}
fun onNotesChange(value: String) {
_state.update { it.copy(notes = value, error = null) }
}
fun submit(propertyId: String, onDone: (BookingCreateResponse, GuestDto?, String?) -> Unit) {
val current = state.value
val checkIn = current.expectedCheckInAt.trim()
val checkOut = current.expectedCheckOutAt.trim()
if (checkIn.isBlank() || checkOut.isBlank()) {
_state.update { it.copy(error = "Check-in and check-out are required") }
return
}
val adultCount = current.adultCount.toIntOrNull()
val totalGuestCount = current.totalGuestCount.toIntOrNull()
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val phone = current.phoneE164.trim().ifBlank { null }
val existingGuest = if (!phone.isNullOrBlank()) {
val guestResponse = api.searchGuests(propertyId, phone = phone)
if (guestResponse.isSuccessful) {
guestResponse.body().orEmpty().firstOrNull()
} else {
_state.update { it.copy(isLoading = false, error = "Guest search failed: ${guestResponse.code()}") }
return@launch
}
} else null
val response = api.createBooking(
propertyId = propertyId,
body = BookingCreateRequest(
expectedCheckInAt = checkIn,
expectedCheckOutAt = checkOut,
source = current.source.trim().ifBlank { null },
transportMode = current.transportMode.trim().ifBlank { null },
adultCount = adultCount,
totalGuestCount = totalGuestCount,
notes = current.notes.trim().ifBlank { null }
)
)
val body = response.body()
if (response.isSuccessful && body != null) {
if (existingGuest?.id != null) {
val linkResponse = api.linkGuest(
propertyId = propertyId,
bookingId = body.id.orEmpty(),
body = BookingLinkGuestRequest(existingGuest.id)
)
if (!linkResponse.isSuccessful) {
_state.update { it.copy(isLoading = false, error = "Link guest failed: ${linkResponse.code()}") }
return@launch
}
}
_state.update { it.copy(isLoading = false, error = null) }
onDone(body, existingGuest, phone)
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
}
}
}
}

View File

@@ -0,0 +1,111 @@
package com.android.trisolarispms.ui.guest
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestInfoScreen(
propertyId: String,
guestId: String,
initialGuest: com.android.trisolarispms.data.api.model.GuestDto?,
initialPhone: String?,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: GuestInfoViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(guestId) {
viewModel.reset()
viewModel.setInitial(initialGuest, initialPhone)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Guest Info") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submit(propertyId, guestId, 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.phoneE164,
onValueChange = viewModel::onPhoneChange,
label = { Text("Phone E164 (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.nationality,
onValueChange = viewModel::onNationalityChange,
label = { Text("Nationality (optional)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.addressText,
onValueChange = viewModel::onAddressChange,
label = { Text("Address (optional)") },
modifier = Modifier.fillMaxWidth()
)
if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.guest
data class GuestInfoState(
val phoneE164: String = "",
val name: String = "",
val nationality: String = "",
val addressText: String = "",
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -0,0 +1,77 @@
package com.android.trisolarispms.ui.guest
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.GuestDto
import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GuestInfoViewModel : ViewModel() {
private val _state = MutableStateFlow(GuestInfoState())
val state: StateFlow<GuestInfoState> = _state
fun reset() {
_state.value = GuestInfoState()
}
fun onPhoneChange(value: String) {
_state.update { it.copy(phoneE164 = value, error = null) }
}
fun onNameChange(value: String) {
_state.update { it.copy(name = value, error = null) }
}
fun onNationalityChange(value: String) {
_state.update { it.copy(nationality = value, error = null) }
}
fun onAddressChange(value: String) {
_state.update { it.copy(addressText = value, error = null) }
}
fun setInitial(guest: GuestDto?, phone: String?) {
_state.update {
it.copy(
phoneE164 = guest?.phoneE164 ?: phone.orEmpty(),
name = guest?.name.orEmpty(),
nationality = guest?.nationality.orEmpty(),
addressText = guest?.addressText.orEmpty(),
error = null
)
}
}
fun submit(propertyId: String, guestId: String, onDone: () -> Unit) {
if (propertyId.isBlank() || guestId.isBlank()) return
val current = state.value
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateGuest(
propertyId = propertyId,
guestId = guestId,
body = GuestUpdateRequest(
phoneE164 = current.phoneE164.trim().ifBlank { null },
name = current.name.trim().ifBlank { null },
nationality = current.nationality.trim().ifBlank { null },
addressText = current.addressText.trim().ifBlank { null }
)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
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

@@ -8,9 +8,10 @@ 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.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material3.Button
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -35,6 +36,7 @@ fun ActiveRoomStaysScreen(
propertyName: String,
onBack: () -> Unit,
onViewRooms: () -> Unit,
onCreateBooking: () -> Unit,
viewModel: ActiveRoomStaysViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
@@ -59,6 +61,11 @@ fun ActiveRoomStaysScreen(
},
colors = TopAppBarDefaults.topAppBarColors()
)
},
floatingActionButton = {
FloatingActionButton(onClick = onCreateBooking) {
Icon(Icons.Default.Add, contentDescription = "Create Booking")
}
}
) { padding ->
Column(