From 9ac0b55b89eff2e25cc720f5ff6cd556099ad52b Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 31 Jan 2026 00:16:12 +0530 Subject: [PATCH] show guest docs images --- app/src/main/AndroidManifest.xml | 1 + .../com/android/trisolarispms/MainActivity.kt | 3 +- .../data/api/GuestDocumentApi.kt | 18 + .../ui/guestdocs/GuestDocumentsState.kt | 10 + .../ui/guestdocs/GuestDocumentsTab.kt | 341 ++++++++++++++++++ .../ui/guestdocs/GuestDocumentsViewModel.kt | 216 +++++++++++ .../ui/roomstay/BookingDetailsTabsScreen.kt | 32 +- 7 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsTab.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2e3469c..26f47ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + BookingPaymentsScreen( propertyId = currentRoute.propertyId, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt index a965379..3b4157d 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/GuestDocumentApi.kt @@ -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 + @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 + @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 + + @DELETE("properties/{propertyId}/guests/{guestId}/documents/{documentId}") + suspend fun deleteGuestDocument( + @Path("propertyId") propertyId: String, + @Path("guestId") guestId: String, + @Path("documentId") documentId: String + ): Response } diff --git a/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsState.kt b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsState.kt new file mode 100644 index 0000000..76b07b8 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsState.kt @@ -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 = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsTab.kt b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsTab.kt new file mode 100644 index 0000000..0518f8c --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsTab.kt @@ -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) + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt new file mode 100644 index 0000000..3c35113 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/guestdocs/GuestDocumentsViewModel.kt @@ -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 = _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 + ) { + 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 + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index 3be1aed..7cd64ad 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -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") + } + } + } } } }