From 8bd2c2eeae1c8610f792ebcb50c25f112994fda2 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Thu, 29 Jan 2026 08:48:04 +0530 Subject: [PATCH] add basic booking flow --- AGENTS.md | 10 +- .../com/android/trisolarispms/MainActivity.kt | 32 +- .../trisolarispms/data/api/BookingApi.kt | 16 + .../trisolarispms/data/api/GuestApi.kt | 16 + .../data/api/model/BookingModels.kt | 23 + .../data/api/model/GuestModels.kt | 15 + .../com/android/trisolarispms/ui/AppRoute.kt | 2 + .../ui/booking/BookingCreateScreen.kt | 434 ++++++++++++++++++ .../ui/booking/BookingCreateState.kt | 14 + .../ui/booking/BookingCreateViewModel.kt | 114 +++++ .../trisolarispms/ui/guest/GuestInfoScreen.kt | 111 +++++ .../trisolarispms/ui/guest/GuestInfoState.kt | 10 + .../ui/guest/GuestInfoViewModel.kt | 77 ++++ .../ui/roomstay/ActiveRoomStaysScreen.kt | 9 +- 14 files changed, 879 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt diff --git a/AGENTS.md b/AGENTS.md index 2258bd1..79f9261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,18 +118,18 @@ POST /properties/{propertyId}/guests Auth: property member Body (required): -- phoneE164 (String) - bookingId (UUID) Optional: +- phoneE164 (String) - name (String) - nationality (String) - addressText (String) { - "phoneE164": "+911111111111", "bookingId": "uuid", + "phoneE164": "+911111111111", "name": "John", "nationality": "IN", "addressText": "Varanasi" @@ -183,6 +183,12 @@ Returns image/svg+xml. --- +### Search guest by phone + +GET /properties/{propertyId}/guests/search?phone=+911111111111 + +--- + ## 3) Room Types (default rate + rate resolve) ### Room type create/update diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 43cb714..d1a1b86 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -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(null) } val selectedRoomType = remember { mutableStateOf(null) } val selectedAmenity = remember { mutableStateOf(null) } + val selectedGuest = remember { mutableStateOf(null) } + val selectedGuestPhone = remember { mutableStateOf(null) } val selectedImageTag = remember { mutableStateOf(null) } val roomFormKey = remember { mutableStateOf(0) } val amenitiesReturnRoute = remember { mutableStateOf(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, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt index 5725762..c1e3712 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt @@ -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 + + @POST("properties/{propertyId}/bookings/{bookingId}/link-guest") + suspend fun linkGuest( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: BookingLinkGuestRequest + ): Response + @POST("properties/{propertyId}/bookings/{bookingId}/check-in") suspend fun checkIn( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/GuestApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/GuestApi.kt index 6eb55d4..52a8a09 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/GuestApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/GuestApi.kt @@ -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 + + @PUT("properties/{propertyId}/guests/{guestId}") + suspend fun updateGuest( + @Path("propertyId") propertyId: String, + @Path("guestId") guestId: String, + @Body body: GuestUpdateRequest + ): Response + @GET("properties/{propertyId}/guests/search") suspend fun searchGuests( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt index 90be48a..b9562d5 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt index 5b259fd..6e24230 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/GuestModels.kt @@ -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 ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index 950d817..a4a13e3 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt new file mode 100644 index 0000000..058e8fd --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt @@ -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(null) } + val checkOutDate = remember { mutableStateOf(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) { + 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) +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt new file mode 100644 index 0000000..cff5728 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt new file mode 100644 index 0000000..44536c0 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt new file mode 100644 index 0000000..4ebc618 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt new file mode 100644 index 0000000..34ba7e2 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoState.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt new file mode 100644 index 0000000..8c067dc --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestInfoViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt index 6a9f802..0b3b7fb 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt @@ -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(