booking: ability to take signature
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.android.trisolarispms.ui.guest
|
||||
|
||||
data class GuestSignatureState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user