show guest docs images

This commit is contained in:
androidlover5842
2026-01-31 00:16:12 +05:30
parent 4fc080f146
commit 9ac0b55b89
7 changed files with 619 additions and 2 deletions

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature

View File

@@ -483,7 +483,8 @@ class MainActivity : ComponentActivity() {
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId
)
}
},
canManageDocuments = canManagePayuSettings(currentRoute.propertyId)
)
is AppRoute.BookingPayments -> BookingPaymentsScreen(
propertyId = currentRoute.propertyId,

View File

@@ -7,9 +7,11 @@ import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.DELETE
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface GuestDocumentApi {
@@ -22,6 +24,15 @@ interface GuestDocumentApi {
@Part("bookingId") bookingId: RequestBody
): Response<GuestDocumentDto>
@Multipart
@POST("properties/{propertyId}/guests/{guestId}/documents")
suspend fun uploadGuestDocumentWithBooking(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Query("bookingId") bookingId: String,
@Part file: MultipartBody.Part
): Response<GuestDocumentDto>
@GET("properties/{propertyId}/guests/{guestId}/documents")
suspend fun listGuestDocuments(
@Path("propertyId") propertyId: String,
@@ -35,4 +46,11 @@ interface GuestDocumentApi {
@Path("guestId") guestId: String,
@Path("documentId") documentId: String
): Response<ResponseBody>
@DELETE("properties/{propertyId}/guests/{guestId}/documents/{documentId}")
suspend fun deleteGuestDocument(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Path("documentId") documentId: String
): Response<Unit>
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.guestdocs
import com.android.trisolarispms.data.api.model.GuestDocumentDto
data class GuestDocumentsState(
val isLoading: Boolean = false,
val isUploading: Boolean = false,
val error: String? = null,
val documents: List<GuestDocumentDto> = emptyList()
)

View File

@@ -0,0 +1,341 @@
package com.android.trisolarispms.ui.guestdocs
import android.graphics.Bitmap
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.ImageLoader
import coil.compose.SubcomposeAsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import com.android.trisolarispms.data.api.ApiConstants
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.GuestDocumentDto
import com.google.firebase.auth.FirebaseAuth
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.io.File
import java.io.FileOutputStream
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.graphics.Brush
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestDocumentsTab(
propertyId: String,
guestId: String,
bookingId: String,
canManageDocuments: Boolean,
viewModel: GuestDocumentsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val showPicker = remember { mutableStateOf(false) }
val auth = remember { FirebaseAuth.getInstance() }
val tokenProvider = remember { FirebaseAuthTokenProvider(auth) }
val imageLoader = remember {
ImageLoader.Builder(context)
.components { add(SvgDecoder.Factory()) }
.okHttpClient(
OkHttpClient.Builder()
.addInterceptor(Interceptor { chain ->
val original = chain.request()
val token = runCatching {
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
}.getOrNull()
if (token.isNullOrBlank()) {
chain.proceed(original)
} else {
val request = original.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
})
.build()
)
.build()
}
LaunchedEffect(propertyId, guestId) {
viewModel.load(propertyId, guestId)
}
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicturePreview()
) { bitmap: Bitmap? ->
if (bitmap != null) {
val file = writeBitmapToCache(bitmap, context.cacheDir)
viewModel.uploadFromFile(
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
file = file,
mimeType = "image/jpeg"
)
}
}
val uploadLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
if (uris.isNotEmpty()) {
viewModel.uploadFromUris(
context = context,
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
uris = uris
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
if (state.isUploading) {
Text(text = "Uploading...", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!canManageDocuments) {
Text(text = "You don't have access to view documents.")
return@Column
}
if (!state.isLoading && state.documents.isEmpty()) {
Text(text = "No documents yet")
}
val imageDocs = state.documents.filter {
it.contentType?.lowercase()?.startsWith("image/") == true
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp)
) {
if (state.isLoading && imageDocs.isEmpty()) {
items(4) {
ShimmerTile()
}
}
items(imageDocs) { doc ->
DocumentImageTile(
propertyId = propertyId,
guestId = guestId,
doc = doc,
imageLoader = imageLoader,
canDelete = canManageDocuments,
onDelete = { documentId ->
viewModel.deleteDocument(propertyId, guestId, documentId)
}
)
}
}
}
if (canManageDocuments) {
FloatingActionButton(
onClick = { showPicker.value = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add document"
)
}
}
}
if (showPicker.value) {
AlertDialog(
onDismissRequest = { showPicker.value = false },
title = { Text("Add document") },
text = { Text("Choose camera or upload") },
confirmButton = {
TextButton(
onClick = {
showPicker.value = false
cameraLauncher.launch(null)
}
) { Text("Camera") }
},
dismissButton = {
TextButton(
onClick = {
showPicker.value = false
uploadLauncher.launch(arrayOf("*/*"))
}
) { Text("Upload") }
}
)
}
}
@Composable
private fun DocumentImageTile(
propertyId: String,
guestId: String,
doc: GuestDocumentDto,
imageLoader: ImageLoader,
canDelete: Boolean,
onDelete: (String) -> Unit
) {
val context = LocalContext.current
val documentId = doc.id ?: return
val baseUrl = ApiConstants.BASE_URL.trimEnd('/')
val url = "$baseUrl/properties/$propertyId/guests/$guestId/documents/$documentId/file"
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp))
) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(context).data(url).build(),
imageLoader = imageLoader,
contentDescription = "Document image",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
loading = { ShimmerOverlay() },
error = { ShimmerOverlay() }
)
if (canDelete) {
IconButton(
onClick = { onDelete(documentId) },
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete document",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
val docType = doc.extractedData?.get("docType")
if (!docType.isNullOrBlank()) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = docType,
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
private fun writeBitmapToCache(bitmap: Bitmap, cacheDir: File): File {
val file = File(cacheDir, "doc_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 92, stream)
}
return file
}
@Composable
private fun ShimmerTile() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp))
) {
ShimmerOverlay()
}
}
@Composable
private fun ShimmerOverlay() {
val transition = rememberInfiniteTransition(label = "shimmer")
val x = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerX"
)
val brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
MaterialTheme.colorScheme.surfaceVariant
),
start = androidx.compose.ui.geometry.Offset(x.value - 200f, 0f),
end = androidx.compose.ui.geometry.Offset(x.value, 200f)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(brush)
)
}

View File

@@ -0,0 +1,216 @@
package com.android.trisolarispms.ui.guestdocs
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.GuestDocumentDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
class GuestDocumentsViewModel : ViewModel() {
private val _state = MutableStateFlow(GuestDocumentsState())
val state: StateFlow<GuestDocumentsState> = _state
fun load(propertyId: String, guestId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listGuestDocuments(propertyId, guestId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
documents = body,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed"
)
}
}
}
}
fun uploadFromUri(
context: Context,
propertyId: String,
guestId: String,
bookingId: String,
uri: Uri
) {
uploadFromUris(context, propertyId, guestId, bookingId, listOf(uri))
}
fun uploadFromUris(
context: Context,
propertyId: String,
guestId: String,
bookingId: String,
uris: List<Uri>
) {
if (uris.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
var errorMessage: String? = null
val resolver = context.contentResolver
for (uri in uris) {
try {
val mime = resolver.getType(uri) ?: "application/octet-stream"
if (mime.lowercase(Locale.getDefault()).startsWith("video/")) {
errorMessage = "Video files not allowed"
continue
}
val filename = resolveFileName(resolver, uri) ?: "document"
val file = copyToCache(resolver, uri, context.cacheDir, filename)
uploadFile(propertyId, guestId, bookingId, file, mime)
} catch (e: Exception) {
errorMessage = e.localizedMessage ?: "Upload failed"
}
}
_state.update {
it.copy(
isUploading = false,
error = errorMessage
)
}
}
}
fun deleteDocument(propertyId: String, guestId: String, documentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteGuestDocument(propertyId, guestId, documentId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
documents = current.documents.filterNot { it.id == documentId },
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Delete failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Delete failed"
)
}
}
}
}
fun uploadFromFile(
propertyId: String,
guestId: String,
bookingId: String,
file: File,
mimeType: String
) {
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
try {
uploadFile(propertyId, guestId, bookingId, file, mimeType)
} catch (e: Exception) {
_state.update {
it.copy(
isUploading = false,
error = e.localizedMessage ?: "Upload failed"
)
}
}
}
}
private suspend fun uploadFile(
propertyId: String,
guestId: String,
bookingId: String,
file: File,
mimeType: String
) {
val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val api = ApiClient.create()
val response = api.uploadGuestDocumentWithBooking(
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
file = part
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(
isUploading = false,
documents = listOf(body) + current.documents,
error = null
)
}
} else {
_state.update {
it.copy(
isUploading = false,
error = "Upload failed: ${response.code()}"
)
}
}
}
private fun resolveFileName(resolver: ContentResolver, uri: Uri): String? {
val cursor = resolver.query(uri, null, null, null, null) ?: return null
return cursor.use {
val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && it.moveToFirst()) it.getString(nameIndex) else null
}
}
private fun copyToCache(
resolver: ContentResolver,
uri: Uri,
cacheDir: File,
filename: String
): File {
val output = File(cacheDir, "${System.currentTimeMillis()}_$filename")
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(output).use { outputStream ->
input.copyTo(outputStream)
}
}
return output
}
}

View File

@@ -55,6 +55,7 @@ import coil.request.ImageRequest
import com.android.trisolarispms.data.api.ApiConstants
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.launch
import okhttp3.Interceptor
@@ -74,10 +75,12 @@ fun BookingDetailsTabsScreen(
onEditSignature: (String) -> Unit,
onOpenPayuQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit,
canManageDocuments: Boolean,
staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel()
) {
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
val tabCount = if (canManageDocuments) 3 else 2
val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabCount })
val scope = rememberCoroutineScope()
val staysState by staysViewModel.state.collectAsState()
val detailsState by detailsViewModel.state.collectAsState()
@@ -120,6 +123,15 @@ fun BookingDetailsTabsScreen(
},
text = { Text("Room Stays") }
)
if (canManageDocuments) {
Tab(
selected = pagerState.currentPage == 2,
onClick = {
scope.launch { pagerState.animateScrollToPage(2) }
},
text = { Text("Manage Documents") }
)
}
}
HorizontalPager(
state = pagerState,
@@ -138,6 +150,24 @@ fun BookingDetailsTabsScreen(
onOpenPayments = onOpenPayments
)
1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
2 -> if (canManageDocuments) {
val resolvedGuestId = detailsState.details?.guestId ?: guestId
if (!resolvedGuestId.isNullOrBlank()) {
GuestDocumentsTab(
propertyId = propertyId,
guestId = resolvedGuestId,
bookingId = bookingId,
canManageDocuments = canManageDocuments
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "Guest not linked yet")
}
}
}
}
}
}