Compare commits

...

2 Commits

Author SHA1 Message Date
androidlover5842
c5e0648dd1 add payment ledger impl and payu impl 2026-01-30 10:17:17 +05:30
androidlover5842
2d75b88892 guest details: improve room stays ui 2026-01-29 23:19:54 +05:30
26 changed files with 1802 additions and 82 deletions

View File

@@ -35,6 +35,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
@@ -60,6 +61,7 @@ dependencies {
implementation(libs.lottie.compose) implementation(libs.lottie.compose)
implementation(libs.calendar.compose) implementation(libs.calendar.compose)
implementation(libs.libphonenumber) implementation(libs.libphonenumber)
implementation(libs.zxing.core)
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx) implementation(libs.firebase.auth.ktx)
implementation(libs.kotlinx.coroutines.play.services) implementation(libs.kotlinx.coroutines.play.services)

View File

@@ -25,6 +25,7 @@ import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.payment.BookingPaymentsScreen
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
import com.android.trisolarispms.ui.room.RoomsScreen import com.android.trisolarispms.ui.room.RoomsScreen
@@ -35,6 +36,8 @@ import com.android.trisolarispms.ui.roomimage.ImageTagsScreen
import com.android.trisolarispms.ui.roomimage.AddImageTagScreen import com.android.trisolarispms.ui.roomimage.AddImageTagScreen
import com.android.trisolarispms.ui.roomimage.EditImageTagScreen import com.android.trisolarispms.ui.roomimage.EditImageTagScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.payu.PayuSettingsScreen
import com.android.trisolarispms.ui.payu.PayuQrScreen
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
@@ -83,6 +86,11 @@ class MainActivity : ComponentActivity() {
it == "ADMIN" || it == "MANAGER" || it == "STAFF" it == "ADMIN" || it == "MANAGER" || it == "STAFF"
} == true } == true
} }
val canManagePayuSettings: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER"
} == true
}
BackHandler(enabled = currentRoute != AppRoute.Home) { BackHandler(enabled = currentRoute != AppRoute.Home) {
when (currentRoute) { when (currentRoute) {
@@ -114,6 +122,15 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
currentRoute.roomTypeId currentRoute.roomTypeId
) )
is AppRoute.PayuSettings -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.PayuQr -> route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays( is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
@@ -157,6 +174,11 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
) )
is AppRoute.BookingPayments -> route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
} }
} }
@@ -253,6 +275,8 @@ class MainActivity : ComponentActivity() {
onBack = { route.value = AppRoute.Home }, onBack = { route.value = AppRoute.Home },
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
showPayuSettings = canManagePayuSettings(currentRoute.propertyId),
onPayuSettings = { route.value = AppRoute.PayuSettings(currentRoute.propertyId) },
onManageRoomStay = { booking -> onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty() ?: booking.expectedCheckInAt.orEmpty()
@@ -273,15 +297,6 @@ class MainActivity : ComponentActivity() {
bookingId = booking.id.orEmpty() bookingId = booking.id.orEmpty()
) )
}, },
onExtendBooking = { booking ->
route.value = AppRoute.BookingExpectedDates(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
status = booking.status,
expectedCheckInAt = booking.expectedCheckInAt,
expectedCheckOutAt = booking.expectedCheckOutAt
)
},
onOpenBookingDetails = { booking -> onOpenBookingDetails = { booking ->
route.value = AppRoute.BookingDetailsTabs( route.value = AppRoute.BookingDetailsTabs(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
@@ -290,6 +305,28 @@ class MainActivity : ComponentActivity() {
) )
} }
) )
is AppRoute.PayuSettings -> PayuSettingsScreen(
propertyId = currentRoute.propertyId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.PayuQr -> PayuQrScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
pendingAmount = currentRoute.pendingAmount,
guestPhone = currentRoute.guestPhone,
onBack = {
route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt, bookingFromAt = currentRoute.fromAt,
@@ -413,6 +450,48 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
) )
},
onEditCheckout = { expectedCheckInAt, expectedCheckOutAt ->
route.value = AppRoute.BookingExpectedDates(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = "CHECKED_IN",
expectedCheckInAt = expectedCheckInAt,
expectedCheckOutAt = expectedCheckOutAt
)
},
onEditSignature = { guestId ->
route.value = AppRoute.GuestSignature(
currentRoute.propertyId,
currentRoute.bookingId,
guestId
)
},
onOpenPayuQr = { pendingAmount, guestPhone ->
route.value = AppRoute.PayuQr(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
pendingAmount = pendingAmount,
guestPhone = guestPhone
)
},
onOpenPayments = {
route.value = AppRoute.BookingPayments(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId
)
}
)
is AppRoute.BookingPayments -> BookingPaymentsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
canAddCash = canManagePayuSettings(currentRoute.propertyId),
onBack = {
route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
currentRoute.bookingId,
null
)
} }
) )
is AppRoute.Rooms -> RoomsScreen( is AppRoute.Rooms -> RoomsScreen(

View File

@@ -15,4 +15,6 @@ interface ApiService :
TransportApi, TransportApi,
InboundEmailApi, InboundEmailApi,
AmenityApi, AmenityApi,
RatePlanApi RatePlanApi,
PayuSettingsApi,
PayuPaymentLinkSettingsApi

View File

@@ -14,6 +14,12 @@ import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.RoomStayDto import com.android.trisolarispms.data.api.model.RoomStayDto
import com.android.trisolarispms.data.api.model.PayuQrRequest
import com.android.trisolarispms.data.api.model.PayuQrResponse
import com.android.trisolarispms.data.api.model.PayuLinkRequest
import com.android.trisolarispms.data.api.model.PayuLinkResponse
import com.android.trisolarispms.data.api.model.PaymentDto
import com.android.trisolarispms.data.api.model.PaymentCreateRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -95,4 +101,31 @@ interface BookingApi {
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingRoomStayCreateRequest @Body body: BookingRoomStayCreateRequest
): Response<RoomStayDto> ): Response<RoomStayDto>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/qr")
suspend fun generatePayuQr(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PayuQrRequest
): Response<PayuQrResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/payu/link")
suspend fun generatePayuLink(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PayuLinkRequest
): Response<PayuLinkResponse>
@GET("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun listPayments(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<List<PaymentDto>>
@POST("properties/{propertyId}/bookings/{bookingId}/payments")
suspend fun createPayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: PaymentCreateRequest
): Response<PaymentDto>
} }

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface PayuPaymentLinkSettingsApi {
@GET("properties/{propertyId}/payu-payment-link-settings")
suspend fun getPayuPaymentLinkSettings(
@Path("propertyId") propertyId: String
): Response<PayuPaymentLinkSettingsResponse>
@PUT("properties/{propertyId}/payu-payment-link-settings")
suspend fun updatePayuPaymentLinkSettings(
@Path("propertyId") propertyId: String,
@Body body: PayuPaymentLinkSettingsRequest
): Response<PayuPaymentLinkSettingsResponse>
}

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.PayuSettingsRequest
import com.android.trisolarispms.data.api.model.PayuSettingsResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface PayuSettingsApi {
@GET("properties/{propertyId}/payu-settings")
suspend fun getPayuSettings(
@Path("propertyId") propertyId: String
): Response<PayuSettingsResponse>
@PUT("properties/{propertyId}/payu-settings")
suspend fun updatePayuSettings(
@Path("propertyId") propertyId: String,
@Body body: PayuSettingsRequest
): Response<PayuSettingsResponse>
}

View File

@@ -56,6 +56,8 @@ data class BookingListItem(
val totalGuestCount: Int? = null, val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null, val expectedGuestCount: Int? = null,
val notes: String? = null val notes: String? = null
,
val pending: Long? = null
) )
data class BookingBulkCheckInRequest( data class BookingBulkCheckInRequest(

View File

@@ -0,0 +1,25 @@
package com.android.trisolarispms.data.api.model
data class PaymentDto(
val id: String? = null,
val bookingId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val method: String? = null,
val gatewayPaymentId: String? = null,
val gatewayTxnId: String? = null,
val bankRefNum: String? = null,
val mode: String? = null,
val pgType: String? = null,
val payerVpa: String? = null,
val payerName: String? = null,
val paymentSource: String? = null,
val reference: String? = null,
val notes: String? = null,
val receivedAt: String? = null,
val receivedByUserId: String? = null
)
data class PaymentCreateRequest(
val amount: Long
)

View File

@@ -0,0 +1,18 @@
package com.android.trisolarispms.data.api.model
data class PayuPaymentLinkSettingsRequest(
val merchantId: String,
val clientId: String,
val clientSecret: String,
val isTest: Boolean
)
data class PayuPaymentLinkSettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
val merchantId: String? = null,
val isTest: Boolean? = null,
val hasClientId: Boolean? = null,
val hasClientSecret: Boolean? = null,
val hasAccessToken: Boolean? = null
)

View File

@@ -0,0 +1,26 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.JsonElement
data class PayuQrRequest(
val amount: Long,
val deviceInfo: String
)
data class PayuQrResponse(
val txnid: String? = null,
val amount: Long? = null,
val currency: String? = null,
val payuResponse: JsonElement? = null
)
data class PayuLinkRequest(
val amount: Long
)
data class PayuLinkResponse(
val amount: Long? = null,
val currency: String? = null,
val paymentLink: String? = null,
val payuResponse: JsonElement? = null
)

View File

@@ -0,0 +1,21 @@
package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class PayuSettingsRequest(
val merchantKey: String,
val salt32: String? = null,
val salt256: String? = null,
@SerializedName("isTest") val isTest: Boolean,
val useSalt256: Boolean
)
data class PayuSettingsResponse(
val propertyId: String? = null,
val configured: Boolean? = null,
val merchantKey: String? = null,
@SerializedName("test") val isTest: Boolean? = null,
val useSalt256: Boolean? = null,
val hasSalt32: Boolean? = null,
val hasSalt256: Boolean? = null
)

View File

@@ -47,6 +47,10 @@ sealed interface AppRoute {
val bookingId: String, val bookingId: String,
val guestId: String? val guestId: String?
) : AppRoute ) : AppRoute
data class BookingPayments(
val propertyId: String,
val bookingId: 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
@@ -63,6 +67,13 @@ sealed interface AppRoute {
val ratePlanId: String, val ratePlanId: String,
val ratePlanCode: String val ratePlanCode: String
) : AppRoute ) : AppRoute
data class PayuSettings(val propertyId: String) : AppRoute
data class PayuQr(
val propertyId: String,
val bookingId: String,
val pendingAmount: Long?,
val guestPhone: String?
) : AppRoute
data object Amenities : AppRoute data object Amenities : AppRoute
data object AddAmenity : AppRoute data object AddAmenity : AppRoute
data class EditAmenity(val amenityId: String) : AppRoute data class EditAmenity(val amenityId: String) : AppRoute

View File

@@ -0,0 +1,164 @@
package com.android.trisolarispms.ui.payment
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.OutlinedTextField
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.PaymentDto
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingPaymentsScreen(
propertyId: String,
bookingId: String,
canAddCash: Boolean,
onBack: () -> Unit,
viewModel: BookingPaymentsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amountInput = remember { mutableStateOf("") }
LaunchedEffect(propertyId, bookingId) {
viewModel.load(propertyId, bookingId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Payments") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
if (canAddCash) {
OutlinedTextField(
value = amountInput.value,
onValueChange = { amountInput.value = it.filter { ch -> ch.isDigit() } },
label = { Text("Cash amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val amount = amountInput.value.toLongOrNull() ?: 0L
viewModel.addCashPayment(propertyId, bookingId, amount)
},
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Add cash payment")
}
Spacer(modifier = Modifier.height(12.dp))
}
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!state.isLoading && state.error == null && state.payments.isEmpty()) {
Text(text = "No payments yet")
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(state.payments) { payment ->
PaymentCard(payment = payment)
}
}
}
}
}
@Composable
private fun PaymentCard(payment: PaymentDto) {
val date = payment.receivedAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()
}
val dateText = date?.format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a"))
val isCash = payment.method == "CASH"
Card(
colors = CardDefaults.cardColors(
containerColor = if (isCash) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
val amountText = buildString {
append(payment.amount?.toString() ?: "-")
payment.currency?.let { append(" $it") }
}
Text(text = amountText, style = MaterialTheme.typography.titleMedium)
payment.method?.let {
Text(text = "Method: $it", style = MaterialTheme.typography.bodySmall)
}
payment.payerVpa?.takeIf { it.isNotBlank() }?.let {
Text(text = "UPI: $it", style = MaterialTheme.typography.bodySmall)
}
payment.payerName?.takeIf { it.isNotBlank() }?.let {
Text(text = "Payer: $it", style = MaterialTheme.typography.bodySmall)
}
dateText?.let {
Text(text = "Received: $it", style = MaterialTheme.typography.bodySmall)
}
payment.reference?.takeIf { it.isNotBlank() }?.let {
Text(text = "Ref: $it", style = MaterialTheme.typography.bodySmall)
}
payment.notes?.takeIf { it.isNotBlank() }?.let {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.android.trisolarispms.ui.payment
import com.android.trisolarispms.data.api.model.PaymentDto
data class BookingPaymentsState(
val isLoading: Boolean = false,
val error: String? = null,
val payments: List<PaymentDto> = emptyList()
)

View File

@@ -0,0 +1,90 @@
package com.android.trisolarispms.ui.payment
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
class BookingPaymentsViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingPaymentsState())
val state: StateFlow<BookingPaymentsState> = _state
fun load(propertyId: String, bookingId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listPayments(propertyId, bookingId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
payments = 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 addCashPayment(propertyId: String, bookingId: String, amount: Long) {
if (amount <= 0) {
_state.update { it.copy(error = "Amount must be greater than 0") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.createPayment(
propertyId = propertyId,
bookingId = bookingId,
body = com.android.trisolarispms.data.api.model.PaymentCreateRequest(amount = amount)
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(
isLoading = false,
payments = listOf(body) + current.payments,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Create failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Create failed"
)
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package com.android.trisolarispms.ui.payu
data class PayuPaymentLinkSettingsState(
val merchantId: String = "",
val clientId: String = "",
val clientSecret: String = "",
val isTest: Boolean = false,
val configured: Boolean = false,
val hasClientId: Boolean = false,
val hasClientSecret: Boolean = false,
val hasAccessToken: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
val message: String? = null
)

View File

@@ -0,0 +1,120 @@
package com.android.trisolarispms.ui.payu
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PayuPaymentLinkSettingsRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PayuPaymentLinkSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(PayuPaymentLinkSettingsState())
val state: StateFlow<PayuPaymentLinkSettingsState> = _state
fun load(propertyId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.getPayuPaymentLinkSettings(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
merchantId = body.merchantId.orEmpty(),
isTest = body.isTest == true,
configured = body.configured == true,
hasClientId = body.hasClientId == true,
hasClientSecret = body.hasClientSecret == true,
hasAccessToken = body.hasAccessToken == true,
clientId = "",
clientSecret = "",
isLoading = false,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed",
message = null
)
}
}
}
}
fun onMerchantIdChange(value: String) {
_state.update { it.copy(merchantId = value, error = null) }
}
fun onClientIdChange(value: String) {
_state.update { it.copy(clientId = value, error = null) }
}
fun onClientSecretChange(value: String) {
_state.update { it.copy(clientSecret = value, error = null) }
}
fun onIsTestChange(value: Boolean) {
_state.update { it.copy(isTest = value, error = null) }
}
fun save(propertyId: String) {
val current = state.value
val merchantId = current.merchantId.trim()
val clientId = current.clientId.trim()
val clientSecret = current.clientSecret.trim()
if (merchantId.isBlank() || clientId.isBlank() || clientSecret.isBlank()) {
_state.update { it.copy(error = "All fields are required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.updatePayuPaymentLinkSettings(
propertyId = propertyId,
body = PayuPaymentLinkSettingsRequest(
merchantId = merchantId,
clientId = clientId,
clientSecret = clientSecret,
isTest = current.isTest
)
)
if (response.isSuccessful) {
_state.update { it.copy(isSaving = false, message = "Saved", error = null) }
load(propertyId)
} else {
_state.update {
it.copy(
isSaving = false,
error = "Save failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isSaving = false,
error = e.localizedMessage ?: "Save failed",
message = null
)
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
package com.android.trisolarispms.ui.payu
import android.graphics.Bitmap
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
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.OutlinedTextField
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.common.BitMatrix
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PayuQrScreen(
propertyId: String,
bookingId: String,
pendingAmount: Long?,
guestPhone: String?,
onBack: () -> Unit,
viewModel: PayuQrViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
LaunchedEffect(pendingAmount) {
viewModel.setInitialAmount(pendingAmount)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("PayU QR") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
OutlinedTextField(
value = state.amountInput,
onValueChange = viewModel::onAmountChange,
label = { Text("Amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
if (!guestPhone.isNullOrBlank()) {
viewModel.generateLink(propertyId, bookingId) { link ->
val uri = Uri.parse("smsto:${guestPhone}")
val intent = Intent(Intent.ACTION_SENDTO, uri)
.putExtra("sms_body", "Pay using this link: $link")
context.startActivity(intent)
}
}
},
enabled = !state.isLoading && !guestPhone.isNullOrBlank(),
modifier = Modifier.weight(1f)
) {
Text("Share Link")
}
Button(
onClick = { viewModel.generate(propertyId, bookingId) },
enabled = !state.isLoading,
modifier = Modifier.weight(1f)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Generating...")
} else {
Text("Generate QR")
}
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
if (!state.qrString.isNullOrBlank()) {
Spacer(modifier = Modifier.height(16.dp))
val qrBitmap = remember(state.qrString) { state.qrString?.let { createQrBitmap(it) } }
qrBitmap?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = "PayU QR",
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Amount: ${state.amount ?: "-"} ${state.currency ?: ""}",
style = MaterialTheme.typography.bodyMedium
)
Button(
onClick = {
clipboard.setText(AnnotatedString(state.qrString.orEmpty()))
}
) {
Text("Copy QR string")
}
}
}
}
}
}
private fun createQrBitmap(content: String, size: Int = 600): Bitmap {
val matrix: BitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size)
val width = matrix.width
val height = matrix.height
val pixels = IntArray(width * height)
for (y in 0 until height) {
val offset = y * width
for (x in 0 until width) {
pixels[offset + x] = if (matrix.get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, width, 0, 0, width, height)
}
}

View File

@@ -0,0 +1,14 @@
package com.android.trisolarispms.ui.payu
data class PayuQrState(
val deviceInfo: String = "",
val amountInput: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val txnId: String? = null,
val amount: Long? = null,
val currency: String? = null,
val qrString: String? = null,
val payuResponseRaw: String? = null,
val paymentLink: String? = null
)

View File

@@ -0,0 +1,159 @@
package com.android.trisolarispms.ui.payu
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PayuLinkRequest
import com.android.trisolarispms.data.api.model.PayuQrRequest
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PayuQrViewModel : ViewModel() {
private val gson = Gson()
private val _state = MutableStateFlow(
PayuQrState(deviceInfo = buildDeviceInfo())
)
val state: StateFlow<PayuQrState> = _state
fun onAmountChange(value: String) {
val digits = value.filter { it.isDigit() }
_state.update { it.copy(amountInput = digits, error = null) }
}
fun setInitialAmount(amount: Long?) {
if (amount == null || amount <= 0) return
_state.update { current ->
if (current.amountInput.isBlank()) {
current.copy(amountInput = amount.toString())
} else {
current
}
}
}
fun generate(propertyId: String, bookingId: String) {
val current = state.value
val amount = current.amountInput.toLongOrNull()?.takeIf { it > 0 }
if (amount == null) {
_state.update { it.copy(error = "Amount is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.generatePayuQr(
propertyId = propertyId,
bookingId = bookingId,
body = PayuQrRequest(
amount = amount,
deviceInfo = current.deviceInfo
)
)
val body = response.body()
if (response.isSuccessful && body != null) {
val (qrString, rawPayload) = extractQrString(body.payuResponse)
_state.update {
it.copy(
isLoading = false,
txnId = body.txnid,
amount = body.amount,
currency = body.currency,
qrString = qrString,
payuResponseRaw = rawPayload,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "QR request failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "QR request failed"
)
}
}
}
}
fun generateLink(propertyId: String, bookingId: String, onReady: (String) -> Unit) {
val current = state.value
val amount = current.amountInput.toLongOrNull()?.takeIf { it > 0 }
if (amount == null) {
_state.update { it.copy(error = "Amount is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.generatePayuLink(
propertyId = propertyId,
bookingId = bookingId,
body = PayuLinkRequest(amount = amount)
)
val body = response.body()
if (response.isSuccessful && body?.paymentLink != null) {
_state.update {
it.copy(
isLoading = false,
paymentLink = body.paymentLink,
amount = body.amount,
currency = body.currency,
error = null
)
}
onReady(body.paymentLink)
} else {
_state.update {
it.copy(
isLoading = false,
error = "Link request failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Link request failed"
)
}
}
}
}
private fun extractQrString(payuResponse: JsonElement?): Pair<String?, String?> {
if (payuResponse == null) return null to null
val raw = gson.toJson(payuResponse)
val root = when {
payuResponse.isJsonObject -> payuResponse.asJsonObject
payuResponse.isJsonPrimitive && payuResponse.asJsonPrimitive.isString -> {
runCatching {
gson.fromJson(payuResponse.asString, JsonObject::class.java)
}.getOrNull()
}
else -> null
}
val qrString = root?.getAsJsonObject("result")?.get("qrString")?.asString
return qrString to raw
}
private fun buildDeviceInfo(): String {
val release = android.os.Build.VERSION.RELEASE
val model = android.os.Build.MODEL
return "Android $release; $model; PMS"
}
}

View File

@@ -0,0 +1,437 @@
package com.android.trisolarispms.ui.payu
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PayuSettingsScreen(
propertyId: String,
onBack: () -> Unit,
viewModel: PayuSettingsViewModel = viewModel(),
linkViewModel: PayuPaymentLinkSettingsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val linkState by linkViewModel.state.collectAsState()
val clipboard = LocalClipboardManager.current
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
val scope = rememberCoroutineScope()
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
linkViewModel.load(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Payu Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
text = { Text("QR") }
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
text = { Text("Payment Links") }
)
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
when (page) {
0 -> PayuQrSettingsTab(
propertyId = propertyId,
state = state,
onMerchantKeyChange = viewModel::onMerchantKeyChange,
onIsTestChange = viewModel::onIsTestChange,
onUseSalt256Change = viewModel::onUseSalt256Change,
onSalt32Change = viewModel::onSalt32Change,
onSalt256Change = viewModel::onSalt256Change,
onSave = { viewModel.save(propertyId) },
clipboard = clipboard
)
1 -> PayuPaymentLinksTab(
propertyId = propertyId,
state = linkState,
onMerchantIdChange = linkViewModel::onMerchantIdChange,
onClientIdChange = linkViewModel::onClientIdChange,
onClientSecretChange = linkViewModel::onClientSecretChange,
onIsTestChange = linkViewModel::onIsTestChange,
onSave = { linkViewModel.save(propertyId) }
)
}
}
}
}
}
@Composable
private fun PayuQrSettingsTab(
propertyId: String,
state: PayuSettingsState,
onMerchantKeyChange: (String) -> Unit,
onIsTestChange: (Boolean) -> Unit,
onUseSalt256Change: (Boolean) -> Unit,
onSalt32Change: (String) -> Unit,
onSalt256Change: (String) -> Unit,
onSave: () -> Unit,
clipboard: ClipboardManager
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
Text(
text = "Payu Settings",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "More payment settings will be added later. For now, configure PayU.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Success/failure URLs are derived by the server using this property ID.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(12.dp))
}
Text(
text = if (state.configured) "Status: Configured" else "Status: Not configured",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
state.message?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = state.merchantKey,
onValueChange = onMerchantKeyChange,
label = { Text("Merchant key") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Use PayU test URL")
Switch(
checked = state.isTest,
onCheckedChange = onIsTestChange
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = if (state.isTest) {
"Base URL: https://test.payu.in/_payment"
} else {
"Base URL: https://secure.payu.in/_payment"
},
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Use salt256")
Switch(
checked = state.useSalt256,
onCheckedChange = onUseSalt256Change
)
}
Spacer(modifier = Modifier.height(8.dp))
if (state.useSalt256) {
OutlinedTextField(
value = state.salt256,
onValueChange = onSalt256Change,
label = { Text("Salt256") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
if (state.hasSalt256) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Salt256 saved",
style = MaterialTheme.typography.bodySmall
)
}
} else {
OutlinedTextField(
value = state.salt32,
onValueChange = onSalt32Change,
label = { Text("Salt32") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
if (state.hasSalt32) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Salt32 saved",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSave,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Saving...")
} else {
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/success",
onValueChange = {},
label = { Text("Success URL") },
readOnly = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = "https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/failure",
onValueChange = {},
label = { Text("Failure URL") },
readOnly = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
clipboard.setText(
AnnotatedString(
"https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/success"
)
)
}
) {
Text("Copy success URL")
}
Button(
onClick = {
clipboard.setText(
AnnotatedString(
"https://api.hoteltrisolaris.in/properties/$propertyId/payu/return/failure"
)
)
}
) {
Text("Copy failure URL")
}
}
Spacer(modifier = Modifier.height(12.dp))
}
}
@Composable
private fun PayuPaymentLinksTab(
propertyId: String,
state: PayuPaymentLinkSettingsState,
onMerchantIdChange: (String) -> Unit,
onClientIdChange: (String) -> Unit,
onClientSecretChange: (String) -> Unit,
onIsTestChange: (Boolean) -> Unit,
onSave: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(12.dp))
}
Text(
text = if (state.configured) "Status: Configured" else "Status: Not configured",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
if (state.hasClientId) {
Text(text = "Client ID saved", style = MaterialTheme.typography.bodySmall)
}
if (state.hasClientSecret) {
Text(text = "Client Secret saved", style = MaterialTheme.typography.bodySmall)
}
if (state.hasAccessToken) {
Text(text = "Access token available", style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Payment Link Settings",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
state.message?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = state.merchantId,
onValueChange = onMerchantIdChange,
label = { Text("Merchant ID") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.clientId,
onValueChange = onClientIdChange,
label = { Text("Client ID") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.clientSecret,
onValueChange = onClientSecretChange,
label = { Text("Client Secret") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Use PayU test environment")
Switch(
checked = state.isTest,
onCheckedChange = onIsTestChange
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSave,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Saving...")
} else {
Text("Save")
}
}
}
}

View File

@@ -0,0 +1,16 @@
package com.android.trisolarispms.ui.payu
data class PayuSettingsState(
val merchantKey: String = "",
val salt32: String = "",
val salt256: String = "",
val isTest: Boolean = false,
val useSalt256: Boolean = false,
val hasSalt32: Boolean = false,
val hasSalt256: Boolean = false,
val configured: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
val message: String? = null
)

View File

@@ -0,0 +1,145 @@
package com.android.trisolarispms.ui.payu
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PayuSettingsRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PayuSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(PayuSettingsState())
val state: StateFlow<PayuSettingsState> = _state
fun load(propertyId: String) {
viewModelScope.launch {
loadInternal(propertyId = propertyId, showLoading = true, message = null)
}
}
fun onMerchantKeyChange(value: String) {
_state.update { it.copy(merchantKey = value, error = null) }
}
fun onSalt32Change(value: String) {
_state.update { it.copy(salt32 = value, error = null) }
}
fun onSalt256Change(value: String) {
_state.update { it.copy(salt256 = value, error = null) }
}
fun onIsTestChange(value: Boolean) {
_state.update { it.copy(isTest = value, error = null) }
}
fun onUseSalt256Change(value: Boolean) {
_state.update { it.copy(useSalt256 = value, error = null) }
}
fun save(propertyId: String) {
val current = state.value
val merchantKey = current.merchantKey.trim()
val salt32 = current.salt32.trim()
val salt256 = current.salt256.trim()
if (merchantKey.isBlank()) {
_state.update { it.copy(error = "Merchant key is required") }
return
}
if (current.useSalt256 && salt256.isBlank() && !current.hasSalt256) {
_state.update { it.copy(error = "Salt256 is required") }
return
}
if (!current.useSalt256 && salt32.isBlank() && !current.hasSalt32) {
_state.update { it.copy(error = "Salt32 is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.updatePayuSettings(
propertyId = propertyId,
body = PayuSettingsRequest(
merchantKey = merchantKey,
salt32 = salt32.ifBlank { null },
salt256 = salt256.ifBlank { null },
isTest = current.isTest,
useSalt256 = current.useSalt256
)
)
if (response.isSuccessful) {
loadInternal(propertyId = propertyId, showLoading = false, message = "Saved")
} else {
_state.update {
it.copy(
isSaving = false,
error = "Save failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isSaving = false,
error = e.localizedMessage ?: "Save failed",
message = null
)
}
}
}
}
private suspend fun loadInternal(propertyId: String, showLoading: Boolean, message: String?) {
if (showLoading) {
_state.update { it.copy(isLoading = true, error = null, message = message) }
} else {
_state.update { it.copy(error = null, message = message) }
}
try {
val api = ApiClient.create()
val response = api.getPayuSettings(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
merchantKey = body.merchantKey.orEmpty(),
isTest = body.isTest == true,
useSalt256 = body.useSalt256 == true,
hasSalt32 = body.hasSalt32 == true,
hasSalt256 = body.hasSalt256 == true,
configured = body.configured == true,
salt32 = "",
salt256 = "",
isLoading = false,
isSaving = false,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
isSaving = false,
error = "Load failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
isSaving = false,
error = e.localizedMessage ?: "Load failed",
message = null
)
}
}
}
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MeetingRoom import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Payment
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -51,9 +52,10 @@ fun ActiveRoomStaysScreen(
onBack: () -> Unit, onBack: () -> Unit,
onViewRooms: () -> Unit, onViewRooms: () -> Unit,
onCreateBooking: () -> Unit, onCreateBooking: () -> Unit,
showPayuSettings: Boolean,
onPayuSettings: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit, onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit,
onExtendBooking: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit,
viewModel: ActiveRoomStaysViewModel = viewModel() viewModel: ActiveRoomStaysViewModel = viewModel()
) { ) {
@@ -77,6 +79,11 @@ fun ActiveRoomStaysScreen(
IconButton(onClick = onViewRooms) { IconButton(onClick = onViewRooms) {
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
} }
if (showPayuSettings) {
IconButton(onClick = onPayuSettings) {
Icon(Icons.Default.Payment, contentDescription = "Payu Settings")
}
}
}, },
colors = TopAppBarDefaults.topAppBarColors() colors = TopAppBarDefaults.topAppBarColors()
) )
@@ -151,14 +158,6 @@ fun ActiveRoomStaysScreen(
TextButton(onClick = { selectedBooking.value = null }) { TextButton(onClick = { selectedBooking.value = null }) {
Text("Add photos") Text("Add photos")
} }
TextButton(
onClick = {
selectedBooking.value = null
onExtendBooking(booking)
}
) {
Text("Extend checkout")
}
TextButton(onClick = { selectedBooking.value = null }) { TextButton(onClick = { selectedBooking.value = null }) {
Text("Checkout") Text("Checkout")
} }
@@ -221,6 +220,15 @@ private fun CheckedInBookingCard(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text(text = notes, style = MaterialTheme.typography.bodySmall) Text(text = notes, style = MaterialTheme.typography.bodySmall)
} }
val pending = booking.pending
if (pending != null && pending > 0) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "$pending",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
val checkInAt = booking.checkInAt?.takeIf { it.isNotBlank() } val checkInAt = booking.checkInAt?.takeIf { it.isNotBlank() }
val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() } val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() }
if (checkInAt != null && checkOutAt != null) { if (checkInAt != null && checkOutAt != null) {

View File

@@ -3,60 +3,65 @@ package com.android.trisolarispms.ui.roomstay
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.ColumnScope
import kotlinx.coroutines.launch
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.ApiConstants
import com.google.firebase.auth.FirebaseAuth
import coil.ImageLoader import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.request.ImageRequest 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.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.launch
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import androidx.compose.ui.platform.LocalContext
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -65,6 +70,10 @@ fun BookingDetailsTabsScreen(
bookingId: String, bookingId: String,
guestId: String?, guestId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit,
onOpenPayuQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit,
staysViewModel: BookingRoomStaysViewModel = viewModel(), staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel() detailsViewModel: BookingDetailsViewModel = viewModel()
) { ) {
@@ -122,7 +131,11 @@ fun BookingDetailsTabsScreen(
details = detailsState.details, details = detailsState.details,
guestId = guestId, guestId = guestId,
isLoading = detailsState.isLoading, isLoading = detailsState.isLoading,
error = detailsState.error error = detailsState.error,
onEditCheckout = onEditCheckout,
onEditSignature = onEditSignature,
onOpenPayuQr = onOpenPayuQr,
onOpenPayments = onOpenPayments
) )
1 -> BookingRoomStaysTabContent(staysState, staysViewModel) 1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
} }
@@ -137,7 +150,11 @@ private fun GuestInfoTabContent(
details: BookingDetailsResponse?, details: BookingDetailsResponse?,
guestId: String?, guestId: String?,
isLoading: Boolean, isLoading: Boolean,
error: String? error: String?,
onEditCheckout: (String?, String?) -> Unit,
onEditSignature: (String) -> Unit,
onOpenPayuQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit
) { ) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") } val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
@@ -158,14 +175,13 @@ private fun GuestInfoTabContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
SectionCard(title = "Details") { SectionCard(title = "Details") {
GuestDetailRow(label = "Name", value = details?.guestName.orEmpty()) GuestDetailRow(label = "Name", value = details?.guestName)
GuestDetailRow(label = "Address", value = details?.guestAddressText.orEmpty()) GuestDetailRow(label = "Address", value = details?.guestAddressText)
GuestDetailRow(label = "Phone number", value = details?.guestPhone.orEmpty()) GuestDetailRow(label = "Phone number", value = details?.guestPhone)
GuestDetailRow(label = "Coming From", value = details?.fromCity.orEmpty()) GuestDetailRow(label = "Coming From", value = details?.fromCity)
GuestDetailRow(label = "Going To", value = details?.toCity.orEmpty()) GuestDetailRow(label = "Going To", value = details?.toCity)
GuestDetailRow(label = "Relation", value = details?.memberRelation.orEmpty()) GuestDetailRow(label = "Relation", value = details?.memberRelation)
GuestDetailRow(label = "Mode of transport", value = details?.transportMode.orEmpty()) GuestDetailRow(label = "Mode of transport", value = details?.transportMode)
GuestDetailRow(label = "Vehicle numbers", value = "")
} }
val checkIn = details?.checkInAt ?: details?.expectedCheckInAt val checkIn = details?.checkInAt ?: details?.expectedCheckInAt
@@ -175,55 +191,97 @@ private fun GuestInfoTabContent(
val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull() val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull()
GuestDetailRow( GuestDetailRow(
label = "Check In Time", label = "Check In Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }.orEmpty() value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }
) )
} else {
GuestDetailRow(label = "Check In Time", value = "")
} }
if (!checkOut.isNullOrBlank()) { if (!checkOut.isNullOrBlank()) {
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull() val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
GuestDetailRow( GuestDetailRow(
label = "Estimated Check Out Time", label = "Estimated Check Out Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }.orEmpty() value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }
) )
} else { }
GuestDetailRow(label = "Estimated Check Out Time", value = "") IconButton(
onClick = {
onEditCheckout(details?.expectedCheckInAt, details?.expectedCheckOutAt)
}
) {
Icon(Icons.Default.Edit, contentDescription = "Edit checkout")
}
}
} }
GuestDetailRow( GuestDetailRow(
label = "Rooms Booked", label = "Rooms Booked",
value = details?.roomNumbers?.takeIf { it.isNotEmpty() }?.joinToString(", ").orEmpty() value = details?.roomNumbers?.takeIf { it.isNotEmpty() }?.joinToString(", ")
) )
GuestDetailRow(label = "Total Adults", value = details?.adultCount?.toString().orEmpty()) GuestDetailRow(label = "Total Adults", value = details?.adultCount?.toString())
GuestDetailRow(label = "Total Males", value = details?.maleCount?.toString().orEmpty()) GuestDetailRow(label = "Total Males", value = details?.maleCount?.toString())
GuestDetailRow(label = "Total Females", value = details?.femaleCount?.toString().orEmpty()) GuestDetailRow(label = "Total Females", value = details?.femaleCount?.toString())
GuestDetailRow(label = "Total Children", value = details?.childCount?.toString().orEmpty()) GuestDetailRow(label = "Total Children", value = details?.childCount?.toString())
GuestDetailRow(label = "Total Guests", value = details?.totalGuestCount?.toString().orEmpty()) GuestDetailRow(label = "Total Guests", value = details?.totalGuestCount?.toString())
GuestDetailRow(label = "Expected Guests", value = details?.expectedGuestCount?.toString().orEmpty()) if (details?.totalGuestCount == null) {
GuestDetailRow(label = "Expected Guests", value = details?.expectedGuestCount?.toString())
}
} }
SectionCard(title = "Calculations") { val hasGuestName = details?.guestName?.isNotBlank() == true
GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString().orEmpty()) val hasGuestPhone = details?.guestPhone?.isNotBlank() == true
GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString().orEmpty()) val hasPending = (details?.pending ?: 0L) > 1L
GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString().orEmpty()) SectionCard(
GuestDetailRow(label = "Pending", value = details?.pending?.toString().orEmpty()) title = "Calculations",
headerContent = {
if (hasGuestName && hasGuestPhone && hasPending) {
IconButton(onClick = { onOpenPayuQr(details.pending, details.guestPhone) }) {
Icon(
imageVector = Icons.Default.QrCode,
contentDescription = "PayU QR",
tint = MaterialTheme.colorScheme.primary
)
}
}
IconButton(
onClick = onOpenPayments,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.ReceiptLong,
contentDescription = "Payments",
tint = MaterialTheme.colorScheme.primary
)
}
}
) {
GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString())
GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString())
GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString())
GuestDetailRow(label = "Pending", value = details?.pending?.toString())
} }
SectionCard(title = "Registered By") { SectionCard(title = "Registered By") {
GuestDetailRow(label = "Name", value = details?.registeredByName.orEmpty()) GuestDetailRow(label = "Name", value = details?.registeredByName)
GuestDetailRow(label = "Phone number", value = details?.registeredByPhone.orEmpty()) GuestDetailRow(label = "Phone number", value = details?.registeredByPhone)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val resolvedGuestId = details?.guestId ?: guestId
SignaturePreview( SignaturePreview(
propertyId = propertyId, propertyId = propertyId,
guestId = details?.guestId ?: guestId, guestId = resolvedGuestId,
signatureUrl = details?.guestSignatureUrl signatureUrl = details?.guestSignatureUrl,
onEditSignature = onEditSignature
) )
} }
} }
@Composable @Composable
private fun GuestDetailRow(label: String, value: String) { private fun GuestDetailRow(label: String, value: String?) {
if (value.isNullOrBlank()) return
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -235,20 +293,31 @@ private fun GuestDetailRow(label: String, value: String) {
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = value.ifBlank { "-" }, text = value,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
} }
} }
@Composable @Composable
private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { private fun SectionCard(
title: String,
headerContent: (@Composable RowScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
headerContent?.invoke(this)
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
content() content()
} }
@@ -257,7 +326,12 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un
} }
@Composable @Composable
private fun SignaturePreview(propertyId: String, guestId: String?, signatureUrl: String?) { private fun SignaturePreview(
propertyId: String,
guestId: String?,
signatureUrl: String?,
onEditSignature: (String) -> Unit
) {
val resolvedGuestId = guestId val resolvedGuestId = guestId
if (resolvedGuestId.isNullOrBlank() && signatureUrl.isNullOrBlank()) { if (resolvedGuestId.isNullOrBlank() && signatureUrl.isNullOrBlank()) {
Text(text = "Signature", style = MaterialTheme.typography.titleSmall) Text(text = "Signature", style = MaterialTheme.typography.titleSmall)
@@ -314,6 +388,12 @@ private fun SignaturePreview(propertyId: String, guestId: String?, signatureUrl:
.padding(8.dp) .padding(8.dp)
) )
} }
if (signatureUrl.isNullOrBlank() && !resolvedGuestId.isNullOrBlank()) {
Spacer(modifier = Modifier.height(6.dp))
TextButton(onClick = { onEditSignature(resolvedGuestId) }) {
Text("Add signature")
}
}
} }
} }
@@ -322,6 +402,8 @@ private fun BookingRoomStaysTabContent(
state: BookingRoomStaysState, state: BookingRoomStaysState,
viewModel: BookingRoomStaysViewModel viewModel: BookingRoomStaysViewModel
) { ) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -354,11 +436,17 @@ private fun BookingRoomStaysTabContent(
state.stays.forEach { stay -> state.stays.forEach { stay ->
val roomLine = "Room ${stay.roomNumber ?: "-"}${stay.roomTypeName ?: ""}".trim() val roomLine = "Room ${stay.roomNumber ?: "-"}${stay.roomTypeName ?: ""}".trim()
Text(text = roomLine, style = MaterialTheme.typography.titleMedium) Text(text = roomLine, style = MaterialTheme.typography.titleMedium)
val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString("") val fromAt = stay.fromAt?.let {
if (guestLine.isNotBlank()) { runCatching {
Text(text = guestLine, style = MaterialTheme.typography.bodyMedium) OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
}.getOrNull()
} }
val timeLine = listOfNotNull(stay.fromAt, stay.expectedCheckoutAt).joinToString("") val toAt = stay.expectedCheckoutAt?.let {
runCatching {
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
}.getOrNull()
}
val timeLine = listOfNotNull(fromAt, toAt).joinToString("")
if (timeLine.isNotBlank()) { if (timeLine.isNotBlank()) {
Text(text = timeLine, style = MaterialTheme.typography.bodySmall) Text(text = timeLine, style = MaterialTheme.typography.bodySmall)
} }

View File

@@ -22,6 +22,7 @@ coilSvg = "2.7.0"
lottieCompose = "6.7.1" lottieCompose = "6.7.1"
calendarCompose = "2.6.0" calendarCompose = "2.6.0"
libphonenumber = "8.13.34" libphonenumber = "8.13.34"
zxingCore = "3.5.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -54,6 +55,7 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coilSvg" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" } calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" } libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }