booking: ability to take signature

This commit is contained in:
androidlover5842
2026-01-29 10:36:49 +05:30
parent 799c0b44b9
commit 29065cee22
7 changed files with 307 additions and 2 deletions

View File

@@ -46,6 +46,20 @@ Response
---
### List bookings
GET /properties/{propertyId}/bookings
Optional query param:
- status (comma-separated), e.g. status=OPEN,CHECKED_IN
Behavior:
- If status is omitted, returns all bookings for the property (newest first).
Response: List of BookingListItem with id, status, guestId, source, times, counts, expectedGuestCount, notes.
---
### Check-in (creates RoomStay)
POST /properties/{propertyId}/bookings/{bookingId}/check-in

View File

@@ -17,6 +17,7 @@ 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.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen
@@ -106,8 +107,16 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId,
currentRoute.roomTypeId
)
is AppRoute.CreateBooking -> route.value = AppRoute.Home
is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.GuestInfo -> route.value = AppRoute.Home
is AppRoute.GuestSignature -> route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
}
@@ -164,7 +173,30 @@ class MainActivity : ComponentActivity() {
initialGuest = selectedGuest.value,
initialPhone = selectedGuestPhone.value,
onBack = { route.value = AppRoute.Home },
onSave = { route.value = AppRoute.Home }
onSave = {
route.value = AppRoute.GuestSignature(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.GuestSignature -> GuestSignatureScreen(
propertyId = currentRoute.propertyId,
guestId = currentRoute.guestId,
onBack = {
route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
propertyId = currentRoute.propertyId,

View File

@@ -8,11 +8,14 @@ import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import com.android.trisolarispms.data.api.model.GuestVisitCountResponse
import com.android.trisolarispms.data.api.model.GuestVehicleDto
import com.android.trisolarispms.data.api.model.GuestVehicleRequest
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
@@ -49,6 +52,14 @@ interface GuestApi {
@Path("guestId") guestId: String
): Response<GuestDto>
@Multipart
@POST("properties/{propertyId}/guests/{guestId}/signature")
suspend fun uploadSignature(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Part file: MultipartBody.Part
): Response<Unit>
@POST("properties/{propertyId}/guests/{guestId}/vehicles")
suspend fun addGuestVehicle(
@Path("propertyId") propertyId: String,

View File

@@ -4,6 +4,7 @@ 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 class GuestSignature(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,195 @@
package com.android.trisolarispms.ui.guest
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
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.layout.size
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.OutlinedButton
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.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.Locale
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestSignatureScreen(
propertyId: String,
guestId: String,
onBack: () -> Unit,
onDone: () -> Unit,
viewModel: GuestSignatureViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val strokes = remember { mutableStateListOf<MutableList<Offset>>() }
val canvasSize = remember { mutableStateOf(IntSize.Zero) }
LaunchedEffect(guestId) {
viewModel.reset()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Guest Signature") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(
onClick = {
val svg = buildSignatureSvg(strokes, canvasSize.value)
if (!svg.isNullOrBlank()) {
viewModel.uploadSignature(propertyId, guestId, svg, onDone)
}
},
enabled = strokes.isNotEmpty() && !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Done, contentDescription = "Upload")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
Text(
text = "Please draw the guest signature below.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
Canvas(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.border(1.dp, MaterialTheme.colorScheme.outline)
.clipToBounds()
.onSizeChanged { canvasSize.value = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val stroke = mutableStateListOf(down.position)
strokes.add(stroke)
drag(down.id) { change ->
stroke.add(change.position)
change.consume()
}
}
}
) {
val strokeColor = Color.Black
val strokeWidth = 3.dp.toPx()
strokes.forEach { stroke ->
if (stroke.size == 1) {
drawCircle(
color = strokeColor,
radius = strokeWidth / 2f,
center = stroke.first()
)
} else {
val path = Path()
path.moveTo(stroke.first().x, stroke.first().y)
for (i in 1 until stroke.size) {
val point = stroke[i]
path.lineTo(point.x, point.y)
}
drawPath(
path = path,
color = strokeColor,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round)
)
}
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { strokes.clear() },
enabled = strokes.isNotEmpty() && !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Clear")
}
}
}
}
private fun buildSignatureSvg(strokes: List<List<Offset>>, canvasSize: IntSize): String? {
if (strokes.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null
val width = canvasSize.width
val height = canvasSize.height
val sb = StringBuilder()
sb.append("""<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" viewBox="0 0 $width $height">""")
strokes.forEach { stroke ->
if (stroke.isNotEmpty()) {
sb.append("<path d=\"")
stroke.forEachIndexed { index, point ->
val x = String.format(Locale.US, "%.2f", point.x)
val y = String.format(Locale.US, "%.2f", point.y)
if (index == 0) {
sb.append("M $x $y ")
} else {
sb.append("L $x $y ")
}
}
sb.append("\" fill=\"none\" stroke=\"#000000\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>")
}
}
sb.append("</svg>")
return sb.toString()
}

View File

@@ -0,0 +1,6 @@
package com.android.trisolarispms.ui.guest
data class GuestSignatureState(
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -0,0 +1,46 @@
package com.android.trisolarispms.ui.guest
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
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
class GuestSignatureViewModel : ViewModel() {
private val _state = MutableStateFlow(GuestSignatureState())
val state: StateFlow<GuestSignatureState> = _state
fun reset() {
_state.value = GuestSignatureState()
}
fun uploadSignature(propertyId: String, guestId: String, svg: String, onDone: () -> Unit) {
if (propertyId.isBlank() || guestId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val requestBody = svg.toRequestBody("image/svg+xml".toMediaType())
val part = MultipartBody.Part.createFormData(
name = "file",
filename = "signature.svg",
body = requestBody
)
val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Upload failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Upload failed") }
}
}
}
}