diff --git a/AGENTS.md b/AGENTS.md index 79f9261..2d7fa8c 100644 --- a/AGENTS.md +++ b/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 diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 3b72888..c36c113 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -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, 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 e450c8e..95a556a 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 @@ -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 + @Multipart + @POST("properties/{propertyId}/guests/{guestId}/signature") + suspend fun uploadSignature( + @Path("propertyId") propertyId: String, + @Path("guestId") guestId: String, + @Part file: MultipartBody.Part + ): Response + @POST("properties/{propertyId}/guests/{guestId}/vehicles") suspend fun addGuestVehicle( @Path("propertyId") propertyId: 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 a4a13e3..c182a7f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt new file mode 100644 index 0000000..e877323 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureScreen.kt @@ -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>() } + 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>, 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("""""") + strokes.forEach { stroke -> + if (stroke.isNotEmpty()) { + sb.append(" + 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("") + return sb.toString() +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureState.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureState.kt new file mode 100644 index 0000000..c1002a8 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureState.kt @@ -0,0 +1,6 @@ +package com.android.trisolarispms.ui.guest + +data class GuestSignatureState( + val isLoading: Boolean = false, + val error: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt new file mode 100644 index 0000000..9990c3a --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guest/GuestSignatureViewModel.kt @@ -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 = _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") } + } + } + } +}