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)
|
### Check-in (creates RoomStay)
|
||||||
|
|
||||||
POST /properties/{propertyId}/bookings/{bookingId}/check-in
|
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.auth.UnauthorizedScreen
|
||||||
import com.android.trisolarispms.ui.booking.BookingCreateScreen
|
import com.android.trisolarispms.ui.booking.BookingCreateScreen
|
||||||
import com.android.trisolarispms.ui.guest.GuestInfoScreen
|
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.home.HomeScreen
|
||||||
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
||||||
import com.android.trisolarispms.ui.room.RoomFormScreen
|
import com.android.trisolarispms.ui.room.RoomFormScreen
|
||||||
@@ -106,8 +107,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
currentRoute.propertyId,
|
currentRoute.propertyId,
|
||||||
currentRoute.roomTypeId
|
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.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,
|
initialGuest = selectedGuest.value,
|
||||||
initialPhone = selectedGuestPhone.value,
|
initialPhone = selectedGuestPhone.value,
|
||||||
onBack = { route.value = AppRoute.Home },
|
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(
|
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
|
||||||
propertyId = currentRoute.propertyId,
|
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.GuestVisitCountResponse
|
||||||
import com.android.trisolarispms.data.api.model.GuestVehicleDto
|
import com.android.trisolarispms.data.api.model.GuestVehicleDto
|
||||||
import com.android.trisolarispms.data.api.model.GuestVehicleRequest
|
import com.android.trisolarispms.data.api.model.GuestVehicleRequest
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
@@ -49,6 +52,14 @@ interface GuestApi {
|
|||||||
@Path("guestId") guestId: String
|
@Path("guestId") guestId: String
|
||||||
): Response<GuestDto>
|
): 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")
|
@POST("properties/{propertyId}/guests/{guestId}/vehicles")
|
||||||
suspend fun addGuestVehicle(
|
suspend fun addGuestVehicle(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ sealed interface AppRoute {
|
|||||||
data object Home : AppRoute
|
data object Home : AppRoute
|
||||||
data class CreateBooking(val propertyId: String) : AppRoute
|
data class CreateBooking(val propertyId: String) : AppRoute
|
||||||
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: 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 object AddProperty : AppRoute
|
||||||
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
|
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
|
||||||
data class Rooms(val propertyId: 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