add payment ledger impl and payu impl
This commit is contained in:
@@ -35,6 +35,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@ dependencies {
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.calendar.compose)
|
||||
implementation(libs.libphonenumber)
|
||||
implementation(libs.zxing.core)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.auth.ktx)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
|
||||
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
|
||||
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
|
||||
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.room.RoomFormScreen
|
||||
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.EditImageTagScreen
|
||||
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.AddRoomTypeScreen
|
||||
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
|
||||
@@ -83,6 +86,11 @@ class MainActivity : ComponentActivity() {
|
||||
it == "ADMIN" || it == "MANAGER" || it == "STAFF"
|
||||
} == true
|
||||
}
|
||||
val canManagePayuSettings: (String) -> Boolean = { propertyId ->
|
||||
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
|
||||
it == "ADMIN" || it == "MANAGER"
|
||||
} == true
|
||||
}
|
||||
|
||||
BackHandler(enabled = currentRoute != AppRoute.Home) {
|
||||
when (currentRoute) {
|
||||
@@ -114,6 +122,15 @@ class MainActivity : ComponentActivity() {
|
||||
currentRoute.propertyId,
|
||||
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(
|
||||
currentRoute.propertyId,
|
||||
selectedPropertyName.value ?: "Property"
|
||||
@@ -157,6 +174,11 @@ class MainActivity : ComponentActivity() {
|
||||
currentRoute.propertyId,
|
||||
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 },
|
||||
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
|
||||
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
|
||||
showPayuSettings = canManagePayuSettings(currentRoute.propertyId),
|
||||
onPayuSettings = { route.value = AppRoute.PayuSettings(currentRoute.propertyId) },
|
||||
onManageRoomStay = { booking ->
|
||||
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
|
||||
?: booking.expectedCheckInAt.orEmpty()
|
||||
@@ -281,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(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingFromAt = currentRoute.fromAt,
|
||||
@@ -420,6 +466,32 @@ class MainActivity : ComponentActivity() {
|
||||
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(
|
||||
|
||||
@@ -15,4 +15,6 @@ interface ApiService :
|
||||
TransportApi,
|
||||
InboundEmailApi,
|
||||
AmenityApi,
|
||||
RatePlanApi
|
||||
RatePlanApi,
|
||||
PayuSettingsApi,
|
||||
PayuPaymentLinkSettingsApi
|
||||
|
||||
@@ -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.BookingDetailsResponse
|
||||
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.http.Body
|
||||
import retrofit2.http.GET
|
||||
@@ -95,4 +101,31 @@ interface BookingApi {
|
||||
@Path("bookingId") bookingId: String,
|
||||
@Body body: BookingRoomStayCreateRequest
|
||||
): 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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -47,6 +47,10 @@ sealed interface AppRoute {
|
||||
val bookingId: String,
|
||||
val guestId: String?
|
||||
) : AppRoute
|
||||
data class BookingPayments(
|
||||
val propertyId: String,
|
||||
val bookingId: String
|
||||
) : AppRoute
|
||||
data object AddProperty : AppRoute
|
||||
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
|
||||
data class Rooms(val propertyId: String) : AppRoute
|
||||
@@ -63,6 +67,13 @@ sealed interface AppRoute {
|
||||
val ratePlanId: String,
|
||||
val ratePlanCode: String
|
||||
) : 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 AddAmenity : AppRoute
|
||||
data class EditAmenity(val amenityId: String) : AppRoute
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MeetingRoom
|
||||
import androidx.compose.material.icons.filled.Payment
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -51,6 +52,8 @@ fun ActiveRoomStaysScreen(
|
||||
onBack: () -> Unit,
|
||||
onViewRooms: () -> Unit,
|
||||
onCreateBooking: () -> Unit,
|
||||
showPayuSettings: Boolean,
|
||||
onPayuSettings: () -> Unit,
|
||||
onManageRoomStay: (BookingListItem) -> Unit,
|
||||
onViewBookingStays: (BookingListItem) -> Unit,
|
||||
onOpenBookingDetails: (BookingListItem) -> Unit,
|
||||
@@ -76,6 +79,11 @@ fun ActiveRoomStaysScreen(
|
||||
IconButton(onClick = onViewRooms) {
|
||||
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
|
||||
}
|
||||
if (showPayuSettings) {
|
||||
IconButton(onClick = onPayuSettings) {
|
||||
Icon(Icons.Default.Payment, contentDescription = "Payu Settings")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors()
|
||||
)
|
||||
|
||||
@@ -5,11 +5,14 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -17,6 +20,8 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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
|
||||
@@ -67,6 +72,8 @@ fun BookingDetailsTabsScreen(
|
||||
onBack: () -> Unit,
|
||||
onEditCheckout: (String?, String?) -> Unit,
|
||||
onEditSignature: (String) -> Unit,
|
||||
onOpenPayuQr: (Long?, String?) -> Unit,
|
||||
onOpenPayments: () -> Unit,
|
||||
staysViewModel: BookingRoomStaysViewModel = viewModel(),
|
||||
detailsViewModel: BookingDetailsViewModel = viewModel()
|
||||
) {
|
||||
@@ -126,7 +133,9 @@ fun BookingDetailsTabsScreen(
|
||||
isLoading = detailsState.isLoading,
|
||||
error = detailsState.error,
|
||||
onEditCheckout = onEditCheckout,
|
||||
onEditSignature = onEditSignature
|
||||
onEditSignature = onEditSignature,
|
||||
onOpenPayuQr = onOpenPayuQr,
|
||||
onOpenPayments = onOpenPayments
|
||||
)
|
||||
1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
|
||||
}
|
||||
@@ -143,7 +152,9 @@ private fun GuestInfoTabContent(
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onEditCheckout: (String?, String?) -> Unit,
|
||||
onEditSignature: (String) -> Unit
|
||||
onEditSignature: (String) -> Unit,
|
||||
onOpenPayuQr: (Long?, String?) -> Unit,
|
||||
onOpenPayments: () -> Unit
|
||||
) {
|
||||
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
|
||||
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
|
||||
@@ -219,7 +230,33 @@ private fun GuestInfoTabContent(
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "Calculations") {
|
||||
val hasGuestName = details?.guestName?.isNotBlank() == true
|
||||
val hasGuestPhone = details?.guestPhone?.isNotBlank() == true
|
||||
val hasPending = (details?.pending ?: 0L) > 1L
|
||||
SectionCard(
|
||||
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())
|
||||
@@ -263,13 +300,24 @@ private fun GuestDetailRow(label: String, value: String?) {
|
||||
}
|
||||
|
||||
@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(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
headerContent?.invoke(this)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
content()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user